diff --git a/docs/architecture/07-naming-and-relationships.md b/docs/architecture/07-naming-and-relationships.md index d7bc136..0fda734 100644 --- a/docs/architecture/07-naming-and-relationships.md +++ b/docs/architecture/07-naming-and-relationships.md @@ -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( existing: ReadonlyArray, - incoming: ReadonlyArray, -): { datasets: Dataset[]; renames: DatasetRename[] } { + incoming: ReadonlyArray, +): { 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 diff --git a/docs/chart-theming-scope.md b/docs/chart-theming-scope.md index 40b506f..e1d7134 100644 --- a/docs/chart-theming-scope.md +++ b/docs/chart-theming-scope.md @@ -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:` (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 diff --git a/docs/spec/08-import-export.md b/docs/spec/08-import-export.md index 9fb05ac..2830f10 100644 --- a/docs/spec/08-import-export.md +++ b/docs/spec/08-import-export.md @@ -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. diff --git a/src/app/services/transfer.test.ts b/src/app/services/transfer.test.ts index 1d990f0..0980515 100644 --- a/src/app/services/transfer.test.ts +++ b/src/app/services/transfer.test.ts @@ -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 () => { diff --git a/src/app/services/transfer.ts b/src/app/services/transfer.ts index 62aea29..8ff58c7 100644 --- a/src/app/services/transfer.ts +++ b/src/app/services/transfer.ts @@ -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 { 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 { // 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 §5–6). + // resolve (spec §08 → Name conflicts; docs/architecture/07 §5–6). 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 { // 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 { // 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) { diff --git a/src/app/stores/CustomThemeStore.test.ts b/src/app/stores/CustomThemeStore.test.ts index 064f16c..9cd37fa 100644 --- a/src/app/stores/CustomThemeStore.test.ts +++ b/src/app/stores/CustomThemeStore.test.ts @@ -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' })); diff --git a/src/app/stores/CustomThemeStore.ts b/src/app/stores/CustomThemeStore.ts index 19a748e..9d701f6 100644 --- a/src/app/stores/CustomThemeStore.ts +++ b/src/app/stores/CustomThemeStore.ts @@ -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, now?: Date) => void; /** @@ -208,6 +214,15 @@ export const useCustomThemeStore = create((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) => ({ diff --git a/src/core/export-envelope.test.ts b/src/core/export-envelope.test.ts index 731978b..b179111 100644 --- a/src/core/export-envelope.test.ts +++ b/src/core/export-envelope.test.ts @@ -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 { return { ...createSnippet({ now: FIXED_NOW, id: 's1' }), ...overrides }; } +function makeTheme(overrides: Partial = {}): CustomTheme { + return { + ...createCustomTheme({ name: 'T1', config: { background: '#fff' }, now: FIXED_NOW }), + id: 1, + ...overrides, + }; +} + function makeDataset(overrides: Partial = {}): 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'); }); }); diff --git a/src/core/export-envelope.ts b/src/core/export-envelope.ts index 7447ce0..1b6e672 100644 --- a/src/core/export-envelope.ts +++ b/src/core/export-envelope.ts @@ -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, datasets: ReadonlyArray, + themes: ReadonlyArray, 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}`; } diff --git a/src/core/import-normalize.test.ts b/src/core/import-normalize.test.ts index e1c5405..c4dee65 100644 --- a/src/core/import-normalize.test.ts +++ b/src/core/import-normalize.test.ts @@ -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'); - }); -}); diff --git a/src/core/import-normalize.ts b/src/core/import-normalize.ts index 09ea657..6e03ceb 100644 --- a/src/core/import-normalize.ts +++ b/src/core/import-normalize.ts @@ -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( existing: ReadonlyArray, - incoming: ReadonlyArray, -): { datasets: Dataset[]; renames: DatasetRename[] } { + incoming: ReadonlyArray, +): { 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, - renames: ReadonlyArray, + renames: ReadonlyArray, ): 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; -}