Workspace transfer: custom themes ride the export/import envelope

This commit is contained in:
2026-06-12 21:45:44 +03:00
parent 80d13c9b6f
commit dbb4522d78
11 changed files with 405 additions and 173 deletions
@@ -286,42 +286,44 @@ reactive code.
## 5. Import: auto-suffix collisions, then report
On import we never overwrite an existing dataset. A dataset whose name collides
On import we never overwrite an existing record. A record whose name collides
is renamed to a unique name via `makeUniqueName`, and **every rename is
collected and reported to the user** (toast / summary) so the change is never
silent. Crucially, names are reserved _as we go_ — within a single import, two
incoming `Sales` datasets become `Sales 2` and `Sales 3`, not two `Sales 2`.
The helper is generic over `{ name: string }` because two record kinds key on a
unique name: datasets and custom chart themes (only dataset renames need
propagation — nothing references a theme by name).
```ts
// src/core/import-normalize.ts
import { makeUniqueName } from './naming';
import type { Dataset } from './dataset';
export interface DatasetRename {
export interface NameRename {
from: string;
to: string;
}
/**
* Returns incoming datasets with collision-free names, plus the renames applied.
* `existing` are names already in the library; `incoming` are datasets to add.
* Returns incoming records with collision-free names, plus the renames applied.
* `existing` are names already in the collection; `incoming` are records to add.
*/
export function dedupeIncomingDatasetNames(
export function dedupeIncomingNames<T extends { name: string }>(
existing: ReadonlyArray<string>,
incoming: ReadonlyArray<Dataset>,
): { datasets: Dataset[]; renames: DatasetRename[] } {
incoming: ReadonlyArray<T>,
): { records: T[]; renames: NameRename[] } {
const reserved = new Set(existing.map((n) => n.toLowerCase()));
const renames: DatasetRename[] = [];
const renames: NameRename[] = [];
const datasets = incoming.map((d) => {
const unique = makeUniqueName(d.name, reserved);
const records = incoming.map((r) => {
const unique = makeUniqueName(r.name, reserved);
reserved.add(unique.toLowerCase()); // reserve so later imports don't collide
if (unique !== d.name) renames.push({ from: d.name, to: unique });
return unique === d.name ? d : { ...d, name: unique };
if (unique !== r.name) renames.push({ from: r.name, to: unique });
return unique === r.name ? r : { ...r, name: unique };
});
return { datasets, renames };
return { records, renames };
}
```
@@ -471,7 +473,7 @@ user action — there is no separate coordinator module to call.
| `renameDatasetInSpec` | `src/core/spec-refs.ts` | yes | unit |
| `snippetsReferencingDataset`, `datasetUsageCounts` (reverse-lookup scan) | `src/core/relationships.ts` | yes | unit |
| `renameDatasetRefs` → updated count (rename propagation) | `src/app/stores/SnippetStore.ts` | no (mutates stores) | integration |
| `dedupeIncomingDatasetNames` | `src/core/import-normalize.ts` | yes | unit |
| `dedupeIncomingNames` (datasets + custom themes) | `src/core/import-normalize.ts` | yes | unit |
The dividing line: anything that takes plain data and returns plain data is
**core** and unit-tested in isolation; anything that reaches into a Zustand store