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
+14 -3
View File
@@ -142,7 +142,7 @@ ships, it is an explicit per-font user action, never automatic.
(spec §03G): bake the active theme into `spec.config` (existing keys win,
render-identical), or lift `spec.config` out to the clipboard (copy before remove —
a failed copy aborts).
4. **Custom named themes** ✅ (2026-06-12, except export/import) — IndexedDB entity
4. **Custom named themes** ✅ (2026-06-12) — IndexedDB entity
`{ id, name, config }` (`core/custom-theme.ts`, themes store @ DB v2) + the **Theme
Builder** modal: theme list, JSON config editor, a font control that populates one
family across every font slot (`applyFontToConfig`), and a live multi-chart gallery
@@ -151,8 +151,9 @@ ships, it is an explicit per-font user action, never automatic.
theme (house/preset/custom) or via the editor's **Extract Config to New Theme**
action (spec §03G); appears in the selector as `custom:<id>` (the "Edit themes…"
action row sits right after the customs, before the preset roster); deleting the
active one falls back to Astrolabe. **Remaining:** export/import as JSON alongside
the library (touches the §08 envelope + import-normalize atomicity).
active one falls back to Astrolabe. Custom themes travel in the §08 workspace
export/import envelope (additive `themes` array, name auto-suffix on clash, ids
reassigned by the store, rolled back with datasets on a failed import).
5. **Shipped font roster** — fontsource packages, `@font-face` registration, selector
metadata (which themes/fonts pair), `document.fonts.load` gate in the render path,
precache strategy above. Roster finalized via visual specimen.
@@ -166,6 +167,16 @@ it without a second mechanism).
## 5. Status log
- **2026-06-12 (slice 4 close-out)** — **custom themes in the §08 envelope.** The
workspace export now writes a `themes` array (additive — no format bump; importers
treat it as optional, so pre-theme envelopes stay valid). Import normalizes each
record (`normalizeCustomTheme`), auto-suffixes name clashes via the generalized
`dedupeIncomingNames` (the dataset dedupe, now shared), reassigns ids through
`CustomThemeStore.addThemes` (selection untouched), and rolls themes back together
with datasets when the atomic snippet write fails. Toast counts gain a theme
clause. Spec §08 updated ("Dataset conflicts" → "Name conflicts"). Slice 4 is now
fully done; next is slice 5 (shipped font roster).
- **2026-06-12 (slice 4)** — **custom named themes + Theme Builder shipped.**
`CustomTheme` entity through the full stack (core → theme-store @ DB v2 →
CustomThemeStore → theme-persistence → startup hydrate); selection model extended to
+20 -15
View File
@@ -6,17 +6,17 @@ Separately, a single chart can be exported on its own — its spec or its render
## Export
Export produces one downloadable JSON file containing every snippet (see _Snippet Library_) and every dataset (see _Datasets_), wrapped in an envelope carrying format metadata.
Export produces one downloadable JSON file containing every snippet (see _Snippet Library_), every dataset (see _Datasets_), and every custom chart theme (see _Live Preview → Chart theme_), wrapped in an envelope carrying format metadata.
- **Trigger**: the **Export** header control runs the export immediately (no intermediate dialog).
- **Contents**: all snippets and all datasets currently stored, plus envelope metadata.
- **Empty workspace**: if there are no snippets, the user is informed ("No snippets to export") and no file is downloaded — even if datasets exist.
- **Contents**: all snippets, datasets, and custom chart themes currently stored, plus envelope metadata.
- **Empty workspace**: if there are no snippets, the user is informed ("No snippets to export") and no file is downloaded — even if datasets or themes exist.
- **Filename**: `astrolabe-project-YYYY-MM-DD.json`, where the date is today's date (export day).
- **Feedback**: on success a toast reports the counts, e.g. "Exported 4 snippets and 2 datasets" (the dataset clause is omitted when there are no datasets; singular/plural wording adapts to the counts).
- **Feedback**: on success a toast reports the counts, e.g. "Exported 4 snippets, 2 datasets and 1 theme" (the dataset and theme clauses are omitted when their counts are zero; singular/plural wording adapts to the counts).
### Export envelope shape
The downloaded file is a single JSON object: an envelope with a format `version`, an export timestamp, an exporter tag, and the two data arrays.
The downloaded file is a single JSON object: an envelope with a format `version`, an export timestamp, an exporter tag, and the data arrays.
```json
{
@@ -28,6 +28,9 @@ The downloaded file is a single JSON object: an envelope with a format `version`
],
"datasets": [
/* full dataset objects (see Data Model) */
],
"themes": [
/* full custom chart theme objects (see Data Model) */
]
}
```
@@ -35,7 +38,7 @@ The downloaded file is a single JSON object: an envelope with a format `version`
- `version` — export format version (currently `"1.0"`).
- `exportedAt` — ISO 8601 timestamp of the export.
- `exportedBy` — fixed identifier `"Astrolabe"`.
- `snippets` / `datasets` — arrays of complete records as defined in _Data Model_, each including its record `version` field. (This is the per-record schema version, not the envelope `version` above.)
- `snippets` / `datasets` / `themes` — arrays of complete records as defined in _Data Model_, each including its record `version` field. (This is the per-record schema version, not the envelope `version` above.) `themes` is additive: exports always write it, and importers treat it as optional, so pre-theme envelopes remain valid `"1.0"` files.
## Per-chart export
@@ -70,8 +73,8 @@ Import lets the user pick a JSON file from their device; its contents are normal
The importer recognizes several shapes so that both Astrolabe exports and looser snippet files work:
- **Astrolabe export envelope** — an object with a `version` and a `snippets` array; an optional `datasets` array is imported too.
- **Bare array of snippets** — a top-level JSON array is treated as a list of snippets (no datasets).
- **Astrolabe export envelope** — an object with a `version` and a `snippets` array; optional `datasets` and `themes` arrays are imported too.
- **Bare array of snippets** — a top-level JSON array is treated as a list of snippets (no datasets or themes).
- **Single snippet object** — any other object is treated as one snippet.
- **Older / foreign snippet shapes** — snippets that do not match the current model are normalized onto it:
- Alternative field names are mapped: `content` → spec, `draft` → draft spec, `createdAt` → creation timestamp.
@@ -85,14 +88,16 @@ A snippet is treated as already in current Astrolabe format when it carries an I
- Imported snippets are **appended** to the existing library; nothing is overwritten or removed.
- **ID collisions** (an incoming snippet whose id already exists) are resolved by assigning the incoming snippet a fresh unique id; the original snippet keeps its id.
- Datasets are imported **before** snippets so that snippet dataset references can resolve.
- Datasets and custom themes are imported **before** snippets so that snippet dataset references can resolve.
- Imported custom themes always receive fresh ids from the theme library; an envelope's theme ids never displace existing records.
### Dataset conflicts
### Name conflicts (datasets and themes)
When an imported dataset's name already exists in the library, it is auto-renamed to a unique name rather than overwriting the existing one (see _Datasets_).
When an imported dataset's or custom theme's name already exists in the library, it is auto-renamed to a unique name rather than overwriting the existing one (see _Datasets_).
- A numeric suffix is appended to the original name; further suffixes are added until the name is unique.
- The renamed datasets are reported to the user via a warning toast listing each `original -> new` rename.
- The renamed records are reported to the user via a warning toast listing each `original -> new` rename.
- A dataset rename is propagated into the imported snippets that reference it; theme renames need no propagation (nothing references a theme by name).
- If a single dataset fails to import, it is skipped and the rest of the import continues.
### Storage limit handling
@@ -105,8 +110,8 @@ Snippet storage has an approximate 5 MB budget (see _Snippet Library_ storage mo
### Feedback
- **Success**: a toast reports how many snippets (and datasets, when any) were imported, e.g. "Imported 4 snippets and 2 datasets".
- **Renames**: when datasets were renamed, the success message is shown as a warning toast that also lists the renames.
- **Empty file**: if no snippets are found in the file, the user is informed ("No snippets found in file") and nothing is imported.
- **Success**: a toast reports how many snippets (and datasets and themes, when any) were imported, e.g. "Imported 4 snippets, 2 datasets and 1 theme".
- **Renames**: when datasets or themes were renamed, the success message is shown as a warning toast that also lists the renames.
- **Empty file**: if no snippets are found in the file, the user is informed ("No snippets found in file") and nothing is imported — even if the file carries datasets or themes.
- **Quota failure**: a clear error advising the user to delete snippets and retry.
- **Invalid file**: a non-JSON or unparseable file produces a clear error ("Failed to import. Please check that the file is valid JSON."); an unreadable file produces a read error. In all error cases the existing workspace is left unchanged.
+44 -5
View File
@@ -20,10 +20,12 @@ vi.mock('../infrastructure/snippet-store', async (importOriginal) => {
};
});
import { createCustomTheme } from '@core/custom-theme';
import { createDataset } from '@core/dataset';
import { createSnippet } from '@core/snippet';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { deleteSnippet, saveSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useNotificationStore } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
@@ -50,6 +52,7 @@ beforeEach(() => {
vi.clearAllMocks();
useSnippetStore.getState().reset();
useDatasetStore.getState().reset();
useCustomThemeStore.getState().reset();
useNotificationStore.getState().clear();
});
@@ -68,18 +71,25 @@ describe('exportWorkspace', () => {
.addDatasets([
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }),
]);
useCustomThemeStore.getState().addThemes([createCustomTheme({ name: 'Brand', config: {} })]);
exportWorkspace(new Date('2026-06-07T12:00:00.000Z'));
expect(mockedDownload).toHaveBeenCalledTimes(1);
const [filename, json] = mockedDownload.mock.calls[0];
expect(filename).toBe('astrolabe-project-2026-06-07.json');
const env = JSON.parse(json) as { version: string; snippets: unknown[]; datasets: unknown[] };
const env = JSON.parse(json) as {
version: string;
snippets: unknown[];
datasets: unknown[];
themes: unknown[];
};
expect(env.version).toBe('1.0');
expect(env.snippets).toHaveLength(1);
expect(env.datasets).toHaveLength(1);
expect(env.themes).toHaveLength(1);
expect(lastNote()).toMatchObject({ kind: 'success' });
expect(lastNote().message).toBe('Exported 1 snippet and 1 dataset');
expect(lastNote().message).toBe('Exported 1 snippet, 1 dataset and 1 theme');
});
});
@@ -153,6 +163,33 @@ describe('importWorkspace', () => {
expect(lastNote().message).toContain('Sales → Sales 2');
});
it('merges envelope themes: ids reassigned, clashing names auto-suffixed and reported', async () => {
const existing = useCustomThemeStore
.getState()
.add(createCustomTheme({ name: 'Brand', config: { background: '#000' } }));
await importJson(
JSON.stringify({
version: '1.0',
snippets: [{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'A', spec: '{}' }],
themes: [
{ id: existing.id, name: 'Brand', config: { background: '#fff' } },
{ id: 99, name: 'Mono', config: { font: 'Courier' } },
],
}),
);
const themes = useCustomThemeStore.getState().themes;
expect(themes.map((t) => t.name).sort()).toEqual(['Brand', 'Brand 2', 'Mono']);
// The existing record is untouched; incoming ids were reassigned past it.
expect(themes.find((t) => t.id === existing.id)!.config).toEqual({ background: '#000' });
expect(new Set(themes.map((t) => t.id)).size).toBe(3);
expect(lastNote()).toMatchObject({ kind: 'warning' });
expect(lastNote().message).toContain('Imported 1 snippet and 2 themes');
expect(lastNote().message).toContain('Brand → Brand 2');
});
it('reassigns a colliding snippet id, keeping the existing one', async () => {
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'Existing' })]);
@@ -224,8 +261,8 @@ describe('importWorkspace', () => {
expect(mockedDelete).toHaveBeenCalledTimes(1);
});
it('rolls back datasets added to the store before the snippet write failed', async () => {
// The import contains both a dataset and a snippet; the snippet write fails.
it('rolls back datasets and themes added to the store before the snippet write failed', async () => {
// The import contains a dataset, a theme, and a snippet; the snippet write fails.
mockedSave.mockRejectedValueOnce(new StorageQuotaError());
await importJson(
@@ -235,12 +272,14 @@ describe('importWorkspace', () => {
{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'S', spec: '{"mark":"bar"}' },
],
datasets: [{ id: 1, name: 'DS', data: [{ x: 1 }], format: 'json', source: 'inline' }],
themes: [{ id: 1, name: 'T', config: {} }],
}),
);
// Neither snippets nor datasets should persist in the store.
// Neither snippets, datasets, nor themes should persist in the store.
expect(useSnippetStore.getState().snippets).toHaveLength(0);
expect(useDatasetStore.getState().datasets).toHaveLength(0);
expect(useCustomThemeStore.getState().themes).toHaveLength(0);
});
it('surfaces a quota error as a clear actionable notification without a detail field', async () => {
+50 -22
View File
@@ -5,24 +5,24 @@
* in `@core/import-normalize` and `@core/export-envelope`; this layer reads the
* stores, drives the browser file adapter, commits the merge, and reports.
*
* Order matters on import (spec §08 → Merge): datasets are committed **before**
* snippets so a snippet's by-name reference resolves against the just-added
* dataset (whose name may have been auto-suffixed to avoid a clash).
* Order matters on import (spec §08 → Merge): datasets and custom themes are
* committed **before** snippets so a snippet's by-name reference resolves
* against the just-added dataset (whose name may have been auto-suffixed to
* avoid a clash).
*
* Atomicity (spec §08 → Storage limit handling / spec §10 → Non-destructive
* import): snippet writes are performed directly to IDB before touching the
* Zustand store. If any write fails the already-written IDB records are deleted
* and the just-added datasets are rolled back from the store, so neither the
* store nor IDB retains a partial import. The write-through subscriber sees
* and the just-added datasets/themes are rolled back from the store, so neither
* the store nor IDB retains a partial import. The write-through subscriber sees
* store changes only after all IDB writes succeed; because `put` is idempotent
* the second (subscriber) write of each record is a harmless no-op.
*/
import { buildExportEnvelope, exportFilename, exportSummaryMessage } from '@core/export-envelope';
import { buildExportEnvelope, exportFilename, transferSummaryMessage } from '@core/export-envelope';
import {
applyDatasetRenamesToSnippets,
dedupeIncomingDatasetNames,
importSummaryMessage,
dedupeIncomingNames,
normalizeImport,
reassignCollidingSnippetIds,
} from '@core/import-normalize';
@@ -31,6 +31,7 @@ import { humanizeBytes } from '@core/storage-estimate';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { deleteSnippet, saveSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
import { notify } from '../stores/NotificationStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
@@ -48,6 +49,7 @@ export function exportWorkspace(now: Date = new Date()): void {
const snippets = useSnippetStore.getState().snippets;
const datasets = useDatasetStore.getState().datasets;
const themes = useCustomThemeStore.getState().themes;
if (snippets.length === 0) {
notify({
@@ -58,13 +60,13 @@ export function exportWorkspace(now: Date = new Date()): void {
return;
}
const envelope = buildExportEnvelope(snippets, datasets, { now });
const envelope = buildExportEnvelope(snippets, datasets, themes, { now });
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
notify({
kind: 'success',
title: 'Workspace exported',
message: exportSummaryMessage(snippets.length, datasets.length),
message: transferSummaryMessage('Exported', snippets.length, datasets.length, themes.length),
});
}
@@ -99,7 +101,11 @@ export async function importWorkspace(file: File): Promise<void> {
return;
}
const { snippets: normSnippets, datasets: normDatasets } = normalizeImport(parsed);
const {
snippets: normSnippets,
datasets: normDatasets,
themes: normThemes,
} = normalizeImport(parsed);
if (normSnippets.length === 0) {
notify({
@@ -112,23 +118,33 @@ export async function importWorkspace(file: File): Promise<void> {
// Datasets first: de-dupe their names against the library (and within the batch),
// then propagate any rename into the imported snippets so their references still
// resolve (spec §08 → Dataset conflicts; docs/architecture/07 §56).
// resolve (spec §08 → Name conflicts; docs/architecture/07 §56).
const existingDatasetNames = useDatasetStore.getState().datasets.map((d) => d.name);
const { datasets: dedupedDatasets, renames } = dedupeIncomingDatasetNames(
const { records: dedupedDatasets, renames } = dedupeIncomingNames(
existingDatasetNames,
normDatasets,
);
const renamedSnippets = applyDatasetRenamesToSnippets(normSnippets, renames);
// Themes key on a unique name too, but nothing references them by name, so a
// rename needs no propagation — only reporting (spec §08 → Name conflicts).
const existingThemeNames = useCustomThemeStore.getState().themes.map((t) => t.name);
const { records: dedupedThemes, renames: themeRenames } = dedupeIncomingNames(
existingThemeNames,
normThemes,
);
// Reassign incoming snippet ids that clash with the library (spec §08 → ID collisions).
const existingSnippetIds = useSnippetStore.getState().snippets.map((s) => s.id);
const finalSnippets = reassignCollidingSnippetIds(existingSnippetIds, renamedSnippets);
// Commit datasets BEFORE snippets so by-name references resolve (spec §08).
// Record which dataset ids existed before the add so we can roll them back if
// the subsequent snippet writes fail.
// Commit datasets and themes BEFORE snippets so by-name references resolve
// (spec §08). Record which ids existed before the add so we can roll them back
// if the subsequent snippet writes fail.
const datasetIdsBefore = new Set(useDatasetStore.getState().datasets.map((d) => d.id));
useDatasetStore.getState().addDatasets(dedupedDatasets);
const themeIdsBefore = new Set(useCustomThemeStore.getState().themes.map((t) => t.id));
useCustomThemeStore.getState().addThemes(dedupedThemes);
// Storage budget pre-check (spec §08 → Storage limit handling): warn on overage
// but still attempt the save.
@@ -156,14 +172,20 @@ export async function importWorkspace(file: File): Promise<void> {
// Roll back: delete every IDB record we already wrote.
await Promise.allSettled(writtenSnippetIds.map((id) => deleteSnippet(id)));
// Roll back: remove the datasets we just added to the store so neither
// store nor IDB retains any trace of this import.
// Roll back: remove the datasets and themes we just added to the store so
// neither store nor IDB retains any trace of this import.
const addedDatasets = useDatasetStore
.getState()
.datasets.filter((d) => !datasetIdsBefore.has(d.id));
for (const d of addedDatasets) {
useDatasetStore.getState().remove(d.id);
}
const addedThemes = useCustomThemeStore
.getState()
.themes.filter((t) => !themeIdsBefore.has(t.id));
for (const t of addedThemes) {
useCustomThemeStore.getState().remove(t.id);
}
// Surface a clear, actionable error (spec §08 "Quota failure"; NN/g #9 /
// GOV.UK plain language — no codes, tell the user what to do next).
@@ -192,13 +214,19 @@ export async function importWorkspace(file: File): Promise<void> {
// All IDB writes succeeded — now make the snippets visible in the store.
useSnippetStore.getState().addSnippets(finalSnippets);
// Feedback (spec §08 → Feedback): one summary toast; a warning when datasets were
// Feedback (spec §08 → Feedback): one summary toast; a warning when records were
// renamed or storage is over budget, otherwise a success.
const summary = importSummaryMessage(finalSnippets.length, dedupedDatasets.length);
const summary = transferSummaryMessage(
'Imported',
finalSnippets.length,
dedupedDatasets.length,
dedupedThemes.length,
);
const clauses: string[] = [];
if (renames.length > 0) {
const allRenames = [...renames, ...themeRenames];
if (allRenames.length > 0) {
clauses.push(
`Renamed to avoid clashes: ${renames.map((r) => `${r.from}${r.to}`).join(', ')}.`,
`Renamed to avoid clashes: ${allRenames.map((r) => `${r.from}${r.to}`).join(', ')}.`,
);
}
if (overage > 0) {
+27
View File
@@ -37,6 +37,33 @@ describe('createTheme', () => {
});
});
describe('addThemes (import batch)', () => {
it('reassigns ids from the store id authority and appends', () => {
const existing = store().createTheme('Existing', {});
store().addThemes([
{ id: existing.id, version: 1, name: 'A', config: {}, created: 'x', modified: 'x' },
{ id: existing.id, version: 1, name: 'B', config: {}, created: 'x', modified: 'x' },
]);
const themes = store().themes;
expect(themes.map((t) => t.name)).toEqual(['Existing', 'A', 'B']);
expect(new Set(themes.map((t) => t.id)).size).toBe(3);
});
it('does not touch the builder selection or draft', () => {
store().createTheme('Open', {});
const before = store().selectedId;
store().addThemes([{ id: 9, version: 1, name: 'A', config: {}, created: 'x', modified: 'x' }]);
expect(store().selectedId).toBe(before);
expect(store().draft?.name).toBe('Open');
});
it('is a no-op for an empty batch (no state churn)', () => {
const before = store().themes;
store().addThemes([]);
expect(store().themes).toBe(before);
});
});
describe('draft editing', () => {
beforeEach(() => store().createTheme('Brand', { font: 'Helvetica' }));
+15
View File
@@ -72,6 +72,12 @@ export interface CustomThemeState {
/** Low-level: add a fully-formed theme and select it. Returns the record with its assigned id. */
add: (theme: CustomTheme) => CustomTheme;
/**
* Low-level batch add (import path): reassign ids from the store's id
* authority and append, WITHOUT touching the builder selection — an import
* must not hijack an open Theme Builder draft.
*/
addThemes: (incoming: CustomTheme[]) => void;
/** Low-level: merge a patch into a theme, advancing `modified`. */
update: (id: number, patch: Partial<CustomTheme>, now?: Date) => void;
/**
@@ -208,6 +214,15 @@ export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
return withId;
},
addThemes: (incoming) => {
if (incoming.length === 0) return;
set((s) => {
let nextId = nextThemeId(s.themes);
const withIds = incoming.map((t) => ({ ...t, id: nextId++ }));
return { themes: [...s.themes, ...withIds] };
});
},
update: (id, patch, now) => {
const modified = patch.modified ?? (now ?? new Date()).toISOString();
set((s) => ({
+42 -17
View File
@@ -1,11 +1,12 @@
import { describe, it, expect } from 'vitest';
import { createCustomTheme, type CustomTheme } from './custom-theme';
import { createDataset, type Dataset } from './dataset';
import {
EXPORT_ENVELOPE_VERSION,
buildExportEnvelope,
exportFilename,
exportSummaryMessage,
transferSummaryMessage,
type ExportEnvelope,
} from './export-envelope';
import { createSnippet, type Snippet } from './snippet';
@@ -16,6 +17,14 @@ function makeSnippet(overrides: Partial<Snippet> = {}): Snippet {
return { ...createSnippet({ now: FIXED_NOW, id: 's1' }), ...overrides };
}
function makeTheme(overrides: Partial<CustomTheme> = {}): CustomTheme {
return {
...createCustomTheme({ name: 'T1', config: { background: '#fff' }, now: FIXED_NOW }),
id: 1,
...overrides,
};
}
function makeDataset(overrides: Partial<Dataset> = {}): Dataset {
return {
...createDataset({
@@ -38,7 +47,7 @@ describe('EXPORT_ENVELOPE_VERSION', () => {
describe('buildExportEnvelope', () => {
it('stamps version, ISO timestamp, and the fixed exporter tag', () => {
const env = buildExportEnvelope([], [], { now: FIXED_NOW });
const env = buildExportEnvelope([], [], [], { now: FIXED_NOW });
expect(env.version).toBe('1.0');
expect(env.exportedAt).toBe('2026-06-03T12:00:00.000Z');
expect(env.exportedBy).toBe('Astrolabe');
@@ -47,13 +56,15 @@ describe('buildExportEnvelope', () => {
it('produces the full envelope shape (matches spec §08 example fields)', () => {
const snippet = makeSnippet();
const dataset = makeDataset();
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
const theme = makeTheme();
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
const expected: ExportEnvelope = {
version: '1.0',
exportedAt: '2026-06-03T12:00:00.000Z',
exportedBy: 'Astrolabe',
snippets: [snippet],
datasets: [dataset],
themes: [theme],
};
expect(env).toEqual(expected);
});
@@ -61,17 +72,21 @@ describe('buildExportEnvelope', () => {
it('carries the records through unchanged', () => {
const snippet = makeSnippet();
const dataset = makeDataset();
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
const theme = makeTheme();
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
expect(env.snippets[0]).toBe(snippet);
expect(env.datasets[0]).toBe(dataset);
expect(env.themes[0]).toBe(theme);
});
it("preserves each record's own version field", () => {
const snippet = makeSnippet({ version: 1 });
const dataset = makeDataset({ version: 1 });
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
const theme = makeTheme({ version: 1 });
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
expect(env.snippets[0].version).toBe(1);
expect(env.datasets[0].version).toBe(1);
expect(env.themes[0].version).toBe(1);
// The per-record version is distinct from the envelope's file-format version.
expect(env.version).toBe('1.0');
});
@@ -79,19 +94,23 @@ describe('buildExportEnvelope', () => {
it("does not alias the caller's arrays (mutating inputs afterward is inert)", () => {
const snippets = [makeSnippet()];
const datasets = [makeDataset()];
const env = buildExportEnvelope(snippets, datasets, { now: FIXED_NOW });
const themes = [makeTheme()];
const env = buildExportEnvelope(snippets, datasets, themes, { now: FIXED_NOW });
snippets.push(makeSnippet({ id: 's2' }));
datasets.push(makeDataset({ id: 2, name: 'D2' }));
themes.push(makeTheme({ id: 2, name: 'T2' }));
expect(env.snippets).toHaveLength(1);
expect(env.datasets).toHaveLength(1);
expect(env.themes).toHaveLength(1);
});
it('handles empty arrays', () => {
const env = buildExportEnvelope([], [], { now: FIXED_NOW });
const env = buildExportEnvelope([], [], [], { now: FIXED_NOW });
expect(env.snippets).toEqual([]);
expect(env.datasets).toEqual([]);
expect(env.themes).toEqual([]);
});
});
@@ -106,24 +125,30 @@ describe('exportFilename', () => {
});
});
describe('exportSummaryMessage', () => {
it('reports both counts (plural)', () => {
expect(exportSummaryMessage(4, 2)).toBe('Exported 4 snippets and 2 datasets');
describe('transferSummaryMessage (shared by export and import feedback)', () => {
it('reports snippet and dataset counts (plural)', () => {
expect(transferSummaryMessage('Exported', 4, 2, 0)).toBe('Exported 4 snippets and 2 datasets');
});
it('omits the dataset clause when there are no datasets', () => {
expect(exportSummaryMessage(4, 0)).toBe('Exported 4 snippets');
it('omits the dataset and theme clauses when their counts are zero', () => {
expect(transferSummaryMessage('Exported', 4, 0, 0)).toBe('Exported 4 snippets');
});
it('uses singular wording for counts of 1', () => {
expect(exportSummaryMessage(1, 1)).toBe('Exported 1 snippet and 1 dataset');
expect(transferSummaryMessage('Imported', 1, 1, 0)).toBe('Imported 1 snippet and 1 dataset');
});
it('singular snippet with omitted dataset clause', () => {
expect(exportSummaryMessage(1, 0)).toBe('Exported 1 snippet');
it('pluralizes zero counts (and omits the other clauses)', () => {
expect(transferSummaryMessage('Imported', 0, 0, 0)).toBe('Imported 0 snippets');
});
it('pluralizes zero counts (and omits the dataset clause)', () => {
expect(exportSummaryMessage(0, 0)).toBe('Exported 0 snippets');
it('reports all three counts with comma-and joining', () => {
expect(transferSummaryMessage('Exported', 4, 2, 1)).toBe(
'Exported 4 snippets, 2 datasets and 1 theme',
);
});
it('joins snippets and themes with "and" when there are no datasets', () => {
expect(transferSummaryMessage('Imported', 4, 0, 3)).toBe('Imported 4 snippets and 3 themes');
});
});
+24 -9
View File
@@ -12,6 +12,7 @@
* migration target — see snippet.ts / dataset.ts).
*/
import type { CustomTheme } from './custom-theme';
import type { Dataset } from './dataset';
import type { Snippet } from './snippet';
@@ -24,8 +25,9 @@ export const EXPORT_ENVELOPE_VERSION = '1.0';
/**
* The downloaded file's top-level shape (spec §08 → Export envelope shape): format
* metadata plus the two complete-record arrays. Each record keeps its own `version`
* field unchanged.
* metadata plus the complete-record arrays. Each record keeps its own `version`
* field unchanged. `themes` is additive (always written, optional on read) so
* pre-theme envelopes and importers remain compatible without a format bump.
*/
export interface ExportEnvelope {
/** Export format version (currently `"1.0"`). */
@@ -38,6 +40,8 @@ export interface ExportEnvelope {
snippets: Snippet[];
/** All datasets, as complete records (each including its record `version`). */
datasets: Dataset[];
/** All custom chart themes, as complete records (each including its record `version`). */
themes: CustomTheme[];
}
/**
@@ -50,6 +54,7 @@ export interface ExportEnvelope {
export function buildExportEnvelope(
snippets: ReadonlyArray<Snippet>,
datasets: ReadonlyArray<Dataset>,
themes: ReadonlyArray<CustomTheme>,
opts: { now: Date },
): ExportEnvelope {
return {
@@ -58,6 +63,7 @@ export function buildExportEnvelope(
exportedBy: 'Astrolabe',
snippets: [...snippets],
datasets: [...datasets],
themes: [...themes],
};
}
@@ -82,12 +88,21 @@ function countClause(count: number, noun: string): string {
}
/**
* Success-toast message reporting the export counts (spec §08 → Feedback), e.g.
* "Exported 4 snippets and 2 datasets". The dataset clause is omitted entirely when
* there are no datasets; singular/plural wording adapts to the counts.
* Success-toast message reporting transfer counts (spec §08 → Feedback) for both
* directions, e.g. "Exported 4 snippets, 2 datasets and 1 theme" / "Imported 1
* snippet". The dataset and theme clauses are omitted entirely when their counts
* are zero; singular/plural wording adapts. One builder for export and import so
* a new record kind or wording change lands in both messages at once.
*/
export function exportSummaryMessage(snippetCount: number, datasetCount: number): string {
const snippets = countClause(snippetCount, 'snippet');
if (datasetCount === 0) return `Exported ${snippets}`;
return `Exported ${snippets} and ${countClause(datasetCount, 'dataset')}`;
export function transferSummaryMessage(
verb: 'Exported' | 'Imported',
snippetCount: number,
datasetCount: number,
themeCount: number,
): string {
const clauses = [countClause(snippetCount, 'snippet')];
if (datasetCount > 0) clauses.push(countClause(datasetCount, 'dataset'));
if (themeCount > 0) clauses.push(countClause(themeCount, 'theme'));
const last = clauses.pop()!;
return clauses.length === 0 ? `${verb} ${last}` : `${verb} ${clauses.join(', ')} and ${last}`;
}
+97 -45
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest';
import { CURRENT_THEME_VERSION } from './custom-theme';
import { CURRENT_DATASET_VERSION, type Dataset } from './dataset';
import {
applyDatasetRenamesToSnippets,
dedupeIncomingDatasetNames,
importSummaryMessage,
dedupeIncomingNames,
normalizeImport,
reassignCollidingSnippetIds,
} from './import-normalize';
@@ -107,10 +107,11 @@ describe('normalizeImport — shape detection', () => {
});
it('returns empty for null / non-object / non-array junk', () => {
expect(normalizeImport(null)).toEqual({ snippets: [], datasets: [] });
expect(normalizeImport(42)).toEqual({ snippets: [], datasets: [] });
expect(normalizeImport('hello')).toEqual({ snippets: [], datasets: [] });
expect(normalizeImport(undefined)).toEqual({ snippets: [], datasets: [] });
const empty = { snippets: [], datasets: [], themes: [] };
expect(normalizeImport(null)).toEqual(empty);
expect(normalizeImport(42)).toEqual(empty);
expect(normalizeImport('hello')).toEqual(empty);
expect(normalizeImport(undefined)).toEqual(empty);
});
it('ignores a non-array datasets field on the envelope', () => {
@@ -118,6 +119,26 @@ describe('normalizeImport — shape detection', () => {
const result = normalizeImport(parsed, { now: FIXED_NOW });
expect(result.datasets).toEqual([]);
});
it('reads an optional themes array on the envelope (absent → empty)', () => {
const withThemes = {
version: '1.0',
snippets: [currentSnippetRecord()],
themes: [{ id: 7, name: 'Corporate', config: { background: '#fff' } }],
};
expect(normalizeImport(withThemes, { now: FIXED_NOW }).themes).toHaveLength(1);
const without = { version: '1.0', snippets: [currentSnippetRecord()] };
expect(normalizeImport(without, { now: FIXED_NOW }).themes).toEqual([]);
const junkThemes = { version: '1.0', snippets: [currentSnippetRecord()], themes: 'nope' };
expect(normalizeImport(junkThemes, { now: FIXED_NOW }).themes).toEqual([]);
});
it('does not read themes from bare-array or single-object shapes', () => {
expect(normalizeImport([currentSnippetRecord()], { now: FIXED_NOW }).themes).toEqual([]);
expect(normalizeImport(currentSnippetRecord(), { now: FIXED_NOW }).themes).toEqual([]);
});
});
describe('normalizeImport — snippet normalization', () => {
@@ -287,31 +308,74 @@ describe('normalizeImport — dataset normalization', () => {
});
});
describe('dedupeIncomingDatasetNames', () => {
describe('normalizeImport — custom themes', () => {
const envelope = (themes: unknown[]) => ({
version: '1.0',
snippets: [currentSnippetRecord()],
themes,
});
it('preserves a complete record and stamps the current version', () => {
const [t] = normalizeImport(
envelope([
{
id: 7,
version: 99,
name: 'Corporate',
config: { background: '#fff', font: 'Georgia' },
created: '2025-03-01T00:00:00.000Z',
modified: '2025-03-02T00:00:00.000Z',
},
]),
{ now: FIXED_NOW },
).themes;
expect(t.id).toBe(7);
expect(t.version).toBe(CURRENT_THEME_VERSION);
expect(t.name).toBe('Corporate');
expect(t.config).toEqual({ background: '#fff', font: 'Georgia' });
expect(t.created).toBe('2025-03-01T00:00:00.000Z');
expect(t.modified).toBe('2025-03-02T00:00:00.000Z');
});
it('fills gaps with defaults (junk record)', () => {
const [t] = normalizeImport(envelope(['junk']), { now: FIXED_NOW }).themes;
expect(t.id).toBe(0);
expect(t.version).toBe(CURRENT_THEME_VERSION);
expect(t.name).toBe('Untitled');
expect(t.config).toEqual({});
expect(t.created).toBe(FIXED_NOW_ISO);
expect(t.modified).toBe(FIXED_NOW_ISO);
});
it('coerces a non-object config to {} and derives modified from created', () => {
const [t] = normalizeImport(
envelope([{ name: 'Odd', config: [1, 2], created: '2025-03-01T00:00:00.000Z' }]),
{ now: FIXED_NOW },
).themes;
expect(t.config).toEqual({});
expect(t.modified).toBe('2025-03-01T00:00:00.000Z');
});
});
describe('dedupeIncomingNames', () => {
it('returns names unchanged when there are no collisions', () => {
const { datasets, renames } = dedupeIncomingDatasetNames(
['Other'],
[datasetRecord({ name: 'Sales' })],
);
expect(datasets[0].name).toBe('Sales');
const { records, renames } = dedupeIncomingNames(['Other'], [datasetRecord({ name: 'Sales' })]);
expect(records[0].name).toBe('Sales');
expect(renames).toEqual([]);
});
it('suffixes a collision with an existing library name', () => {
const { datasets, renames } = dedupeIncomingDatasetNames(
['Sales'],
[datasetRecord({ name: 'Sales' })],
);
expect(datasets[0].name).toBe('Sales 2');
const { records, renames } = dedupeIncomingNames(['Sales'], [datasetRecord({ name: 'Sales' })]);
expect(records[0].name).toBe('Sales 2');
expect(renames).toEqual([{ from: 'Sales', to: 'Sales 2' }]);
});
it('reserves names as-you-go so intra-batch dupes become Sales 2, Sales 3', () => {
const { datasets, renames } = dedupeIncomingDatasetNames(
const { records, renames } = dedupeIncomingNames(
['Sales'],
[datasetRecord({ name: 'Sales' }), datasetRecord({ name: 'Sales' })],
);
expect(datasets.map((d) => d.name)).toEqual(['Sales 2', 'Sales 3']);
expect(records.map((d) => d.name)).toEqual(['Sales 2', 'Sales 3']);
expect(renames).toEqual([
{ from: 'Sales', to: 'Sales 2' },
{ from: 'Sales', to: 'Sales 3' },
@@ -319,18 +383,24 @@ describe('dedupeIncomingDatasetNames', () => {
});
it('matches collisions case-insensitively (Sales vs sales)', () => {
const { datasets, renames } = dedupeIncomingDatasetNames(
['Sales'],
[datasetRecord({ name: 'sales' })],
);
expect(datasets[0].name).toBe('sales 2'); // preserves incoming casing
const { records, renames } = dedupeIncomingNames(['Sales'], [datasetRecord({ name: 'sales' })]);
expect(records[0].name).toBe('sales 2'); // preserves incoming casing
expect(renames).toEqual([{ from: 'sales', to: 'sales 2' }]);
});
it('only clones datasets whose name changed', () => {
it('only clones records whose name changed', () => {
const keep = datasetRecord({ name: 'Untouched' });
const { datasets } = dedupeIncomingDatasetNames(['Sales'], [keep]);
expect(datasets[0]).toBe(keep);
const { records } = dedupeIncomingNames(['Sales'], [keep]);
expect(records[0]).toBe(keep);
});
it('works for any named record (custom themes)', () => {
const { records, renames } = dedupeIncomingNames(
['Corporate'],
[{ name: 'Corporate', config: {} }],
);
expect(records[0].name).toBe('Corporate 2');
expect(renames).toEqual([{ from: 'Corporate', to: 'Corporate 2' }]);
});
});
@@ -457,21 +527,3 @@ describe('applyDatasetRenamesToSnippets', () => {
expect(out.datasetRefs).toEqual(['Regions 2', 'Sales 2']);
});
});
describe('importSummaryMessage', () => {
it('pluralizes snippets and includes the dataset clause', () => {
expect(importSummaryMessage(4, 2)).toBe('Imported 4 snippets and 2 datasets');
});
it('uses singular wording for counts of 1', () => {
expect(importSummaryMessage(1, 1)).toBe('Imported 1 snippet and 1 dataset');
});
it('omits the dataset clause when datasetCount is 0', () => {
expect(importSummaryMessage(3, 0)).toBe('Imported 3 snippets');
});
it('pluralizes zero counts correctly', () => {
expect(importSummaryMessage(0, 0)).toBe('Imported 0 snippets');
});
});
+55 -42
View File
@@ -6,18 +6,21 @@
* data in, plain data out. The app's ImportService orchestrates the file read,
* store reads (for existing names/ids), and the commit; everything *deterministic*
* about an import — recognizing the file shape, coercing each record onto the
* current `Snippet`/`Dataset` shape, de-duping names, reassigning colliding ids,
* and propagating renames into specs — lives here so it can be unit-tested hardest.
* current `Snippet`/`Dataset`/`CustomTheme` shape, de-duping names, reassigning
* colliding ids, and propagating renames into specs — lives here so it can be
* unit-tested hardest.
*
* `crypto.randomUUID` is a platform global (like in snippet.ts), allowed in core.
* Both id generators are injectable so tests can assert deterministically.
*/
import { CURRENT_THEME_VERSION, type CustomTheme } from './custom-theme';
import { CURRENT_DATASET_VERSION, type DataSource, type Dataset } from './dataset';
import type { DataFormat } from './format-detection';
import { makeUniqueName } from './naming';
import type { ColumnStats } from './profile';
import { CURRENT_SNIPPET_VERSION, type Snippet } from './snippet';
import { isJsonObject } from './spec-config';
import { extractDatasetRefs, recomputeDatasetRefs, renameDatasetInSpec } from './spec-refs';
import type { ColumnType } from './type-inference';
@@ -28,10 +31,11 @@ const IMPORTED_TAG = 'imported';
export interface NormalizedImport {
snippets: Snippet[];
datasets: Dataset[];
themes: CustomTheme[];
}
/** A single dataset rename applied during dedupe (`from` original → `to` unique). */
export interface DatasetRename {
/** A single rename applied during name dedupe (`from` original → `to` unique). */
export interface NameRename {
from: string;
to: string;
}
@@ -198,6 +202,30 @@ function normalizeDataset(raw: unknown, nowIso: string): Dataset {
};
}
/**
* Normalize one raw custom-theme record onto the current `CustomTheme` shape
* (spec §08 → Accepted inputs). Themes only travel inside Astrolabe envelopes,
* so this is gap-filling rather than foreign-shape mapping: a non-object
* `config` becomes `{}`, missing timestamps derive like datasets, and `version`
* is stamped current. Ids are provisional — the store's id authority reassigns
* them on insert, so collisions need no handling here.
*/
function normalizeCustomTheme(raw: unknown, nowIso: string): CustomTheme {
const r = isPlainObject(raw) ? raw : {};
const created = asNonEmptyString(r.created) ?? nowIso;
const modified = asNonEmptyString(r.modified) ?? created;
return {
id: typeof r.id === 'number' ? r.id : Number(r.id) || 0,
version: CURRENT_THEME_VERSION,
name: typeof r.name === 'string' ? r.name : 'Untitled',
config: isJsonObject(r.config) ? r.config : {},
created,
modified,
};
}
// ----------------------------------------------------------------------------
// Shape detection (spec §08 "Accepted inputs")
// ----------------------------------------------------------------------------
@@ -206,9 +234,9 @@ function normalizeDataset(raw: unknown, nowIso: string): Dataset {
* Detect the import shape and normalize every record onto the current model.
*
* - Envelope: object with a `version` AND a `snippets` array → its snippets
* (+ optional `datasets` array).
* - Bare array: a top-level array → a list of snippets, no datasets.
* - Single object: any other object → one snippet, no datasets.
* (+ optional `datasets` and `themes` arrays).
* - Bare array: a top-level array → a list of snippets, no datasets/themes.
* - Single object: any other object → one snippet, no datasets/themes.
* - junk (null / non-object / non-array) → empty.
*/
export function normalizeImport(
@@ -220,6 +248,7 @@ export function normalizeImport(
let rawSnippets: unknown[] = [];
let rawDatasets: unknown[] = [];
let rawThemes: unknown[] = [];
if (Array.isArray(parsed)) {
// Bare array of snippets.
@@ -229,16 +258,18 @@ export function normalizeImport(
if (hasEnvelope) {
rawSnippets = parsed.snippets as unknown[];
if (Array.isArray(parsed.datasets)) rawDatasets = parsed.datasets;
if (Array.isArray(parsed.themes)) rawThemes = parsed.themes;
} else {
// Single snippet object.
rawSnippets = [parsed];
}
}
// else: null / non-object / non-array junk → both stay empty.
// else: null / non-object / non-array junk → all stay empty.
return {
snippets: rawSnippets.map((s) => normalizeSnippet(s, nowIso, makeId)),
datasets: rawDatasets.map((d) => normalizeDataset(d, nowIso)),
themes: rawThemes.map((t) => normalizeCustomTheme(t, nowIso)),
};
}
@@ -247,27 +278,28 @@ export function normalizeImport(
// ----------------------------------------------------------------------------
/**
* De-dupe incoming dataset names against the existing library and within the
* batch itself (architecture 07 §5). Names are reserved *as we go* so two incoming
* `Sales` become `Sales 2`, `Sales 3` — never two `Sales 2`. Existing datasets are
* never overwritten. Every rename is collected for reporting. Only datasets whose
* De-dupe incoming record names against the existing collection and within the
* batch itself (architecture 07 §5) — used for datasets and custom themes, both
* of which key on a unique name. Names are reserved *as we go* so two incoming
* `Sales` become `Sales 2`, `Sales 3` — never two `Sales 2`. Existing records are
* never overwritten. Every rename is collected for reporting. Only records whose
* name actually changed are cloned.
*/
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 };
}
/**
@@ -297,7 +329,7 @@ export function reassignCollidingSnippetIds(
}
/**
* Propagate dataset renames (from `dedupeIncomingDatasetNames`) into the imported
* Propagate dataset renames (from `dedupeIncomingNames`) into the imported
* snippets that reference them (architecture 07 §6): for each rename, rewrite both
* `spec` and `draftSpec` via `renameDatasetInSpec`, then recompute `datasetRefs`
* from the rewritten draft — the draft is the source of truth, `datasetRefs`
@@ -309,7 +341,7 @@ export function reassignCollidingSnippetIds(
*/
export function applyDatasetRenamesToSnippets(
snippets: ReadonlyArray<Snippet>,
renames: ReadonlyArray<DatasetRename>,
renames: ReadonlyArray<NameRename>,
): Snippet[] {
if (renames.length === 0) return snippets.slice();
@@ -344,22 +376,3 @@ export function applyDatasetRenamesToSnippets(
return { ...snippet, spec, draftSpec, datasetRefs: recomputeDatasetRefs(draftSpec) };
});
}
// ----------------------------------------------------------------------------
// Feedback
// ----------------------------------------------------------------------------
/** Pluralize a count: `1 thing`, `0 things`, `4 things`. */
function plural(count: number, noun: string): string {
return `${count} ${noun}${count === 1 ? '' : 's'}`;
}
/**
* The import success message (spec §08 "Feedback"), e.g.
* "Imported 4 snippets and 2 datasets". The dataset clause is omitted when
* `datasetCount` is 0; singular/plural adapts for any counts ≥ 0.
*/
export function importSummaryMessage(snippetCount: number, datasetCount: number): string {
const head = `Imported ${plural(snippetCount, 'snippet')}`;
return datasetCount > 0 ? `${head} and ${plural(datasetCount, 'dataset')}` : head;
}