/** * Name uniqueness for datasets (spec §05 → Naming & Uniqueness; * docs/architecture/07 §2). * * Portable core: no browser APIs, no React, no store access — plain data in, * plain data out. Dataset names are the key snippets reference via * `datasetRefs` and the contract a Vega-Lite spec uses through * `{ "data": { "name": "..." } }`, so they must be unique. Comparisons are * **case-insensitive** throughout (`Sales` and `sales` collide) so a single * display name maps to a single dataset regardless of how a reference is typed. * * - `isNameTaken` — reject duplicate create/rename in the UI. `excludeId` lets * a rename ignore the record being renamed (renaming `Sales` to `Sales`, or a * case-only edit, is not a self-collision). * - `makeUniqueName` — for non-interactive paths (import, extract, build chart) * where blocking the user is worse than a silent, reported rename: derive the * next free `${base} ${n}` (n ≥ 2). We never parse meaning out of a name; a * base already ending in a number still just gets a suffix (`Q1 2024 2`). */ /** Case-insensitive: is `desired` already used by another dataset (minus `excludeId`)? */ export function isNameTaken( desired: string, datasets: ReadonlyArray<{ id: number; name: string }>, excludeId?: number, ): boolean { const lower = desired.trim().toLowerCase(); return datasets.some((d) => d.id !== excludeId && d.name.toLowerCase() === lower); } /** * Returns `desired` (trimmed) if free, else the first available `${desired} ${n}` * (n ≥ 2). `existingNames` is the set of names already in the collection. * Comparison is case-insensitive; the returned name preserves `desired`'s casing. */ export function makeUniqueName(desired: string, existingNames: Iterable): string { const taken = new Set(); for (const n of existingNames) taken.add(n.toLowerCase()); const base = desired.trim(); if (!taken.has(base.toLowerCase())) return base; let n = 2; while (taken.has(`${base} ${n}`.toLowerCase())) n++; return `${base} ${n}`; }