fix: allocate export names in linear time
This commit is contained in:
@@ -436,6 +436,28 @@ describe('OneNote export filenames', () => {
|
|||||||
expect(allocator.allocate('Report.txt')).toBe('Report.txt');
|
expect(allocator.allocate('Report.txt')).toBe('Report.txt');
|
||||||
expect(allocator.allocate('report.TXT')).toBe('report-2.txt');
|
expect(allocator.allocate('report.TXT')).toBe('report-2.txt');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps collision suffixes within the filename cap', () => {
|
||||||
|
const allocator = new SafeExportNameAllocator();
|
||||||
|
const filename = `${'x'.repeat(116)}.txt`;
|
||||||
|
expect(filename).toHaveLength(120);
|
||||||
|
expect(allocator.allocate(filename)).toBe(filename);
|
||||||
|
|
||||||
|
const duplicate = allocator.allocate(filename);
|
||||||
|
expect(duplicate).toBe(`${'x'.repeat(114)}-2.txt`);
|
||||||
|
expect([...duplicate]).toHaveLength(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allocates many duplicate names without rescanning prior suffixes', () => {
|
||||||
|
const allocator = new SafeExportNameAllocator();
|
||||||
|
const names = Array.from({ length: 10_000 }, () =>
|
||||||
|
allocator.allocate('Report.txt')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(names[0]).toBe('Report.txt');
|
||||||
|
expect(names.at(-1)).toBe('Report-10000.txt');
|
||||||
|
expect(new Set(names)).toHaveLength(names.length);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function fixtureSnapshot(): OneNoteExportSnapshot {
|
function fixtureSnapshot(): OneNoteExportSnapshot {
|
||||||
|
|||||||
@@ -110,30 +110,49 @@ export function filenameExtension(filename: string): string | undefined {
|
|||||||
|
|
||||||
export class SafeExportNameAllocator {
|
export class SafeExportNameAllocator {
|
||||||
private readonly used = new Set<string>();
|
private readonly used = new Set<string>();
|
||||||
|
private readonly nextSuffixByBase = new Map<string, number>();
|
||||||
|
|
||||||
allocate(value: string | undefined, fallback = 'untitled'): string {
|
allocate(value: string | undefined, fallback = 'untitled'): string {
|
||||||
const safe = sanitizeExportFilename(value, fallback);
|
const safe = sanitizeExportFilename(value, fallback);
|
||||||
if (this.reserve(safe)) return safe;
|
if (this.reserve(safe)) return safe;
|
||||||
const extension = filenameExtension(safe);
|
const baseKey = normalizeFilenameKey(safe);
|
||||||
const stem = extension ? safe.slice(0, -(extension.length + 1)) : safe;
|
let suffix = this.nextSuffixByBase.get(baseKey) ?? 2;
|
||||||
for (let suffix = 2; suffix < 1_000_000; suffix += 1) {
|
for (; suffix < 1_000_000; suffix += 1) {
|
||||||
const candidate = truncateFilename(
|
const candidate = appendNumericSuffix(safe, suffix);
|
||||||
`${stem}-${suffix}${extension ? `.${extension}` : ''}`,
|
if (this.reserve(candidate)) {
|
||||||
MAX_FILENAME_CODEPOINTS
|
this.nextSuffixByBase.set(baseKey, suffix + 1);
|
||||||
);
|
return candidate;
|
||||||
if (this.reserve(candidate)) return candidate;
|
}
|
||||||
}
|
}
|
||||||
throw new Error('Could not allocate a unique export filename');
|
throw new Error('Could not allocate a unique export filename');
|
||||||
}
|
}
|
||||||
|
|
||||||
private reserve(value: string): boolean {
|
private reserve(value: string): boolean {
|
||||||
const key = value.toLocaleLowerCase('en-US');
|
const key = normalizeFilenameKey(value);
|
||||||
if (this.used.has(key)) return false;
|
if (this.used.has(key)) return false;
|
||||||
this.used.add(key);
|
this.used.add(key);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendNumericSuffix(value: string, suffix: number): string {
|
||||||
|
const extensionIndex = value.lastIndexOf('.');
|
||||||
|
const extension = filenameExtension(value);
|
||||||
|
const extensionPart =
|
||||||
|
extension && [...extension].length <= 16 ? `.${extension}` : '';
|
||||||
|
const stem = extensionPart ? value.slice(0, extensionIndex) : value;
|
||||||
|
const suffixPart = `-${suffix}`;
|
||||||
|
const stemLimit =
|
||||||
|
MAX_FILENAME_CODEPOINTS -
|
||||||
|
[...suffixPart].length -
|
||||||
|
[...extensionPart].length;
|
||||||
|
return `${[...stem].slice(0, stemLimit).join('')}${suffixPart}${extensionPart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilenameKey(value: string): string {
|
||||||
|
return value.toLocaleLowerCase('en-US');
|
||||||
|
}
|
||||||
|
|
||||||
function truncateFilename(value: string, maxCodepoints: number): string {
|
function truncateFilename(value: string, maxCodepoints: number): string {
|
||||||
const characters = [...value];
|
const characters = [...value];
|
||||||
if (characters.length <= maxCodepoints) return value;
|
if (characters.length <= maxCodepoints) return value;
|
||||||
|
|||||||
Reference in New Issue
Block a user