fix: allocate export names in linear time

This commit is contained in:
2026-07-22 20:13:08 +02:00
parent 141a06dbf3
commit 6a87ef6510
2 changed files with 50 additions and 9 deletions

View File

@@ -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 {

View File

@@ -110,30 +110,49 @@ export function filenameExtension(filename: string): string | undefined {
export class SafeExportNameAllocator {
private readonly used = new Set<string>();
private readonly nextSuffixByBase = new Map<string, number>();
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;