diff --git a/src/onenote/export/export.test.ts b/src/onenote/export/export.test.ts index 7249427..fe44ab7 100644 --- a/src/onenote/export/export.test.ts +++ b/src/onenote/export/export.test.ts @@ -436,6 +436,28 @@ describe('OneNote export filenames', () => { expect(allocator.allocate('Report.txt')).toBe('Report.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 { diff --git a/src/onenote/export/naming.ts b/src/onenote/export/naming.ts index 1111eaf..0cae1c4 100644 --- a/src/onenote/export/naming.ts +++ b/src/onenote/export/naming.ts @@ -110,30 +110,49 @@ export function filenameExtension(filename: string): string | undefined { export class SafeExportNameAllocator { private readonly used = new Set(); + private readonly nextSuffixByBase = new Map(); allocate(value: string | undefined, fallback = 'untitled'): string { const safe = sanitizeExportFilename(value, fallback); if (this.reserve(safe)) return safe; - const extension = filenameExtension(safe); - const stem = extension ? safe.slice(0, -(extension.length + 1)) : safe; - for (let suffix = 2; suffix < 1_000_000; suffix += 1) { - const candidate = truncateFilename( - `${stem}-${suffix}${extension ? `.${extension}` : ''}`, - MAX_FILENAME_CODEPOINTS - ); - if (this.reserve(candidate)) return candidate; + const baseKey = normalizeFilenameKey(safe); + let suffix = this.nextSuffixByBase.get(baseKey) ?? 2; + for (; suffix < 1_000_000; suffix += 1) { + const candidate = appendNumericSuffix(safe, suffix); + if (this.reserve(candidate)) { + this.nextSuffixByBase.set(baseKey, suffix + 1); + return candidate; + } } throw new Error('Could not allocate a unique export filename'); } private reserve(value: string): boolean { - const key = value.toLocaleLowerCase('en-US'); + const key = normalizeFilenameKey(value); if (this.used.has(key)) return false; this.used.add(key); 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 { const characters = [...value]; if (characters.length <= maxCodepoints) return value;