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-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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user