mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Workspace transfer: custom themes ride the export/import envelope
This commit is contained in:
@@ -286,42 +286,44 @@ reactive code.
|
|||||||
|
|
||||||
## 5. Import: auto-suffix collisions, then report
|
## 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
|
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
|
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
|
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`.
|
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
|
```ts
|
||||||
// src/core/import-normalize.ts
|
// src/core/import-normalize.ts
|
||||||
|
|
||||||
import { makeUniqueName } from './naming';
|
import { makeUniqueName } from './naming';
|
||||||
import type { Dataset } from './dataset';
|
|
||||||
|
|
||||||
export interface DatasetRename {
|
export interface NameRename {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns incoming datasets with collision-free names, plus the renames applied.
|
* Returns incoming records with collision-free names, plus the renames applied.
|
||||||
* `existing` are names already in the library; `incoming` are datasets to add.
|
* `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>,
|
existing: ReadonlyArray<string>,
|
||||||
incoming: ReadonlyArray<Dataset>,
|
incoming: ReadonlyArray<T>,
|
||||||
): { datasets: Dataset[]; renames: DatasetRename[] } {
|
): { records: T[]; renames: NameRename[] } {
|
||||||
const reserved = new Set(existing.map((n) => n.toLowerCase()));
|
const reserved = new Set(existing.map((n) => n.toLowerCase()));
|
||||||
const renames: DatasetRename[] = [];
|
const renames: NameRename[] = [];
|
||||||
|
|
||||||
const datasets = incoming.map((d) => {
|
const records = incoming.map((r) => {
|
||||||
const unique = makeUniqueName(d.name, reserved);
|
const unique = makeUniqueName(r.name, reserved);
|
||||||
reserved.add(unique.toLowerCase()); // reserve so later imports don't collide
|
reserved.add(unique.toLowerCase()); // reserve so later imports don't collide
|
||||||
if (unique !== d.name) renames.push({ from: d.name, to: unique });
|
if (unique !== r.name) renames.push({ from: r.name, to: unique });
|
||||||
return unique === d.name ? d : { ...d, name: 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 |
|
| `renameDatasetInSpec` | `src/core/spec-refs.ts` | yes | unit |
|
||||||
| `snippetsReferencingDataset`, `datasetUsageCounts` (reverse-lookup scan) | `src/core/relationships.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 |
|
| `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
|
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
|
**core** and unit-tested in isolation; anything that reaches into a Zustand store
|
||||||
|
|||||||
@@ -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,
|
(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 —
|
render-identical), or lift `spec.config` out to the clipboard (copy before remove —
|
||||||
a failed copy aborts).
|
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
|
`{ 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
|
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
|
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**
|
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 (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
|
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
|
active one falls back to Astrolabe. Custom themes travel in the §08 workspace
|
||||||
the library (touches the §08 envelope + import-normalize atomicity).
|
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
|
5. **Shipped font roster** — fontsource packages, `@font-face` registration, selector
|
||||||
metadata (which themes/fonts pair), `document.fonts.load` gate in the render path,
|
metadata (which themes/fonts pair), `document.fonts.load` gate in the render path,
|
||||||
precache strategy above. Roster finalized via visual specimen.
|
precache strategy above. Roster finalized via visual specimen.
|
||||||
@@ -166,6 +167,16 @@ it without a second mechanism).
|
|||||||
|
|
||||||
## 5. Status log
|
## 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.**
|
- **2026-06-12 (slice 4)** — **custom named themes + Theme Builder shipped.**
|
||||||
`CustomTheme` entity through the full stack (core → theme-store @ DB v2 →
|
`CustomTheme` entity through the full stack (core → theme-store @ DB v2 →
|
||||||
CustomThemeStore → theme-persistence → startup hydrate); selection model extended to
|
CustomThemeStore → theme-persistence → startup hydrate); selection model extended to
|
||||||
|
|||||||
@@ -6,17 +6,17 @@ Separately, a single chart can be exported on its own — its spec or its render
|
|||||||
|
|
||||||
## Export
|
## 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).
|
- **Trigger**: the **Export** header control runs the export immediately (no intermediate dialog).
|
||||||
- **Contents**: all snippets and all datasets currently stored, plus envelope metadata.
|
- **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 exist.
|
- **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).
|
- **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
|
### 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
|
```json
|
||||||
{
|
{
|
||||||
@@ -28,6 +28,9 @@ The downloaded file is a single JSON object: an envelope with a format `version`
|
|||||||
],
|
],
|
||||||
"datasets": [
|
"datasets": [
|
||||||
/* full dataset objects (see Data Model) */
|
/* 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"`).
|
- `version` — export format version (currently `"1.0"`).
|
||||||
- `exportedAt` — ISO 8601 timestamp of the export.
|
- `exportedAt` — ISO 8601 timestamp of the export.
|
||||||
- `exportedBy` — fixed identifier `"Astrolabe"`.
|
- `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
|
## 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:
|
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.
|
- **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).
|
- **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.
|
- **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:
|
- **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.
|
- 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.
|
- 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.
|
- **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.
|
- 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.
|
- If a single dataset fails to import, it is skipped and the rest of the import continues.
|
||||||
|
|
||||||
### Storage limit handling
|
### Storage limit handling
|
||||||
@@ -105,8 +110,8 @@ Snippet storage has an approximate 5 MB budget (see _Snippet Library_ storage mo
|
|||||||
|
|
||||||
### Feedback
|
### Feedback
|
||||||
|
|
||||||
- **Success**: a toast reports how many snippets (and datasets, when any) were imported, e.g. "Imported 4 snippets and 2 datasets".
|
- **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 were renamed, the success message is shown as a warning toast that also lists the renames.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ vi.mock('../infrastructure/snippet-store', async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { createCustomTheme } from '@core/custom-theme';
|
||||||
import { createDataset } from '@core/dataset';
|
import { createDataset } from '@core/dataset';
|
||||||
import { createSnippet } from '@core/snippet';
|
import { createSnippet } from '@core/snippet';
|
||||||
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
|
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
|
||||||
import { deleteSnippet, saveSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
|
import { deleteSnippet, saveSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
|
||||||
|
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||||
import { useDatasetStore } from '../stores/DatasetStore';
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
import { useNotificationStore } from '../stores/NotificationStore';
|
import { useNotificationStore } from '../stores/NotificationStore';
|
||||||
import { useSnippetStore } from '../stores/SnippetStore';
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
@@ -50,6 +52,7 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
useSnippetStore.getState().reset();
|
useSnippetStore.getState().reset();
|
||||||
useDatasetStore.getState().reset();
|
useDatasetStore.getState().reset();
|
||||||
|
useCustomThemeStore.getState().reset();
|
||||||
useNotificationStore.getState().clear();
|
useNotificationStore.getState().clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,18 +71,25 @@ describe('exportWorkspace', () => {
|
|||||||
.addDatasets([
|
.addDatasets([
|
||||||
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }),
|
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'));
|
exportWorkspace(new Date('2026-06-07T12:00:00.000Z'));
|
||||||
|
|
||||||
expect(mockedDownload).toHaveBeenCalledTimes(1);
|
expect(mockedDownload).toHaveBeenCalledTimes(1);
|
||||||
const [filename, json] = mockedDownload.mock.calls[0];
|
const [filename, json] = mockedDownload.mock.calls[0];
|
||||||
expect(filename).toBe('astrolabe-project-2026-06-07.json');
|
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.version).toBe('1.0');
|
||||||
expect(env.snippets).toHaveLength(1);
|
expect(env.snippets).toHaveLength(1);
|
||||||
expect(env.datasets).toHaveLength(1);
|
expect(env.datasets).toHaveLength(1);
|
||||||
|
expect(env.themes).toHaveLength(1);
|
||||||
expect(lastNote()).toMatchObject({ kind: 'success' });
|
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');
|
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 () => {
|
it('reassigns a colliding snippet id, keeping the existing one', async () => {
|
||||||
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'Existing' })]);
|
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'Existing' })]);
|
||||||
|
|
||||||
@@ -224,8 +261,8 @@ describe('importWorkspace', () => {
|
|||||||
expect(mockedDelete).toHaveBeenCalledTimes(1);
|
expect(mockedDelete).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rolls back datasets added to the store before the snippet write failed', async () => {
|
it('rolls back datasets and themes added to the store before the snippet write failed', async () => {
|
||||||
// The import contains both a dataset and a snippet; the snippet write fails.
|
// The import contains a dataset, a theme, and a snippet; the snippet write fails.
|
||||||
mockedSave.mockRejectedValueOnce(new StorageQuotaError());
|
mockedSave.mockRejectedValueOnce(new StorageQuotaError());
|
||||||
|
|
||||||
await importJson(
|
await importJson(
|
||||||
@@ -235,12 +272,14 @@ describe('importWorkspace', () => {
|
|||||||
{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'S', spec: '{"mark":"bar"}' },
|
{ 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' }],
|
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(useSnippetStore.getState().snippets).toHaveLength(0);
|
||||||
expect(useDatasetStore.getState().datasets).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 () => {
|
it('surfaces a quota error as a clear actionable notification without a detail field', async () => {
|
||||||
|
|||||||
@@ -5,24 +5,24 @@
|
|||||||
* in `@core/import-normalize` and `@core/export-envelope`; this layer reads the
|
* in `@core/import-normalize` and `@core/export-envelope`; this layer reads the
|
||||||
* stores, drives the browser file adapter, commits the merge, and reports.
|
* stores, drives the browser file adapter, commits the merge, and reports.
|
||||||
*
|
*
|
||||||
* Order matters on import (spec §08 → Merge): datasets are committed **before**
|
* Order matters on import (spec §08 → Merge): datasets and custom themes are
|
||||||
* snippets so a snippet's by-name reference resolves against the just-added
|
* committed **before** snippets so a snippet's by-name reference resolves
|
||||||
* dataset (whose name may have been auto-suffixed to avoid a clash).
|
* 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
|
* Atomicity (spec §08 → Storage limit handling / spec §10 → Non-destructive
|
||||||
* import): snippet writes are performed directly to IDB before touching the
|
* import): snippet writes are performed directly to IDB before touching the
|
||||||
* Zustand store. If any write fails the already-written IDB records are deleted
|
* 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
|
* and the just-added datasets/themes are rolled back from the store, so neither
|
||||||
* store nor IDB retains a partial import. The write-through subscriber sees
|
* 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
|
* store changes only after all IDB writes succeed; because `put` is idempotent
|
||||||
* the second (subscriber) write of each record is a harmless no-op.
|
* 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 {
|
import {
|
||||||
applyDatasetRenamesToSnippets,
|
applyDatasetRenamesToSnippets,
|
||||||
dedupeIncomingDatasetNames,
|
dedupeIncomingNames,
|
||||||
importSummaryMessage,
|
|
||||||
normalizeImport,
|
normalizeImport,
|
||||||
reassignCollidingSnippetIds,
|
reassignCollidingSnippetIds,
|
||||||
} from '@core/import-normalize';
|
} from '@core/import-normalize';
|
||||||
@@ -31,6 +31,7 @@ import { humanizeBytes } from '@core/storage-estimate';
|
|||||||
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
|
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
|
||||||
import { deleteSnippet, saveSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
|
import { deleteSnippet, saveSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
|
||||||
import { notify } from '../stores/NotificationStore';
|
import { notify } from '../stores/NotificationStore';
|
||||||
|
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||||
import { useDatasetStore } from '../stores/DatasetStore';
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
import { useSnippetStore } from '../stores/SnippetStore';
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ export function exportWorkspace(now: Date = new Date()): void {
|
|||||||
|
|
||||||
const snippets = useSnippetStore.getState().snippets;
|
const snippets = useSnippetStore.getState().snippets;
|
||||||
const datasets = useDatasetStore.getState().datasets;
|
const datasets = useDatasetStore.getState().datasets;
|
||||||
|
const themes = useCustomThemeStore.getState().themes;
|
||||||
|
|
||||||
if (snippets.length === 0) {
|
if (snippets.length === 0) {
|
||||||
notify({
|
notify({
|
||||||
@@ -58,13 +60,13 @@ export function exportWorkspace(now: Date = new Date()): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const envelope = buildExportEnvelope(snippets, datasets, { now });
|
const envelope = buildExportEnvelope(snippets, datasets, themes, { now });
|
||||||
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
|
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
|
||||||
|
|
||||||
notify({
|
notify({
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
title: 'Workspace exported',
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { snippets: normSnippets, datasets: normDatasets } = normalizeImport(parsed);
|
const {
|
||||||
|
snippets: normSnippets,
|
||||||
|
datasets: normDatasets,
|
||||||
|
themes: normThemes,
|
||||||
|
} = normalizeImport(parsed);
|
||||||
|
|
||||||
if (normSnippets.length === 0) {
|
if (normSnippets.length === 0) {
|
||||||
notify({
|
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),
|
// 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
|
// 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 existingDatasetNames = useDatasetStore.getState().datasets.map((d) => d.name);
|
||||||
const { datasets: dedupedDatasets, renames } = dedupeIncomingDatasetNames(
|
const { records: dedupedDatasets, renames } = dedupeIncomingNames(
|
||||||
existingDatasetNames,
|
existingDatasetNames,
|
||||||
normDatasets,
|
normDatasets,
|
||||||
);
|
);
|
||||||
const renamedSnippets = applyDatasetRenamesToSnippets(normSnippets, renames);
|
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).
|
// Reassign incoming snippet ids that clash with the library (spec §08 → ID collisions).
|
||||||
const existingSnippetIds = useSnippetStore.getState().snippets.map((s) => s.id);
|
const existingSnippetIds = useSnippetStore.getState().snippets.map((s) => s.id);
|
||||||
const finalSnippets = reassignCollidingSnippetIds(existingSnippetIds, renamedSnippets);
|
const finalSnippets = reassignCollidingSnippetIds(existingSnippetIds, renamedSnippets);
|
||||||
|
|
||||||
// Commit datasets BEFORE snippets so by-name references resolve (spec §08).
|
// Commit datasets and themes BEFORE snippets so by-name references resolve
|
||||||
// Record which dataset ids existed before the add so we can roll them back if
|
// (spec §08). Record which ids existed before the add so we can roll them back
|
||||||
// the subsequent snippet writes fail.
|
// if the subsequent snippet writes fail.
|
||||||
const datasetIdsBefore = new Set(useDatasetStore.getState().datasets.map((d) => d.id));
|
const datasetIdsBefore = new Set(useDatasetStore.getState().datasets.map((d) => d.id));
|
||||||
useDatasetStore.getState().addDatasets(dedupedDatasets);
|
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
|
// Storage budget pre-check (spec §08 → Storage limit handling): warn on overage
|
||||||
// but still attempt the save.
|
// 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.
|
// Roll back: delete every IDB record we already wrote.
|
||||||
await Promise.allSettled(writtenSnippetIds.map((id) => deleteSnippet(id)));
|
await Promise.allSettled(writtenSnippetIds.map((id) => deleteSnippet(id)));
|
||||||
|
|
||||||
// Roll back: remove the datasets we just added to the store so neither
|
// Roll back: remove the datasets and themes we just added to the store so
|
||||||
// store nor IDB retains any trace of this import.
|
// neither store nor IDB retains any trace of this import.
|
||||||
const addedDatasets = useDatasetStore
|
const addedDatasets = useDatasetStore
|
||||||
.getState()
|
.getState()
|
||||||
.datasets.filter((d) => !datasetIdsBefore.has(d.id));
|
.datasets.filter((d) => !datasetIdsBefore.has(d.id));
|
||||||
for (const d of addedDatasets) {
|
for (const d of addedDatasets) {
|
||||||
useDatasetStore.getState().remove(d.id);
|
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 /
|
// 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).
|
// 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.
|
// All IDB writes succeeded — now make the snippets visible in the store.
|
||||||
useSnippetStore.getState().addSnippets(finalSnippets);
|
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.
|
// 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[] = [];
|
const clauses: string[] = [];
|
||||||
if (renames.length > 0) {
|
const allRenames = [...renames, ...themeRenames];
|
||||||
|
if (allRenames.length > 0) {
|
||||||
clauses.push(
|
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) {
|
if (overage > 0) {
|
||||||
|
|||||||
@@ -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', () => {
|
describe('draft editing', () => {
|
||||||
beforeEach(() => store().createTheme('Brand', { font: 'Helvetica' }));
|
beforeEach(() => store().createTheme('Brand', { font: 'Helvetica' }));
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ export interface CustomThemeState {
|
|||||||
|
|
||||||
/** Low-level: add a fully-formed theme and select it. Returns the record with its assigned id. */
|
/** Low-level: add a fully-formed theme and select it. Returns the record with its assigned id. */
|
||||||
add: (theme: CustomTheme) => CustomTheme;
|
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`. */
|
/** Low-level: merge a patch into a theme, advancing `modified`. */
|
||||||
update: (id: number, patch: Partial<CustomTheme>, now?: Date) => void;
|
update: (id: number, patch: Partial<CustomTheme>, now?: Date) => void;
|
||||||
/**
|
/**
|
||||||
@@ -208,6 +214,15 @@ export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
|
|||||||
return withId;
|
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) => {
|
update: (id, patch, now) => {
|
||||||
const modified = patch.modified ?? (now ?? new Date()).toISOString();
|
const modified = patch.modified ?? (now ?? new Date()).toISOString();
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { createCustomTheme, type CustomTheme } from './custom-theme';
|
||||||
import { createDataset, type Dataset } from './dataset';
|
import { createDataset, type Dataset } from './dataset';
|
||||||
import {
|
import {
|
||||||
EXPORT_ENVELOPE_VERSION,
|
EXPORT_ENVELOPE_VERSION,
|
||||||
buildExportEnvelope,
|
buildExportEnvelope,
|
||||||
exportFilename,
|
exportFilename,
|
||||||
exportSummaryMessage,
|
transferSummaryMessage,
|
||||||
type ExportEnvelope,
|
type ExportEnvelope,
|
||||||
} from './export-envelope';
|
} from './export-envelope';
|
||||||
import { createSnippet, type Snippet } from './snippet';
|
import { createSnippet, type Snippet } from './snippet';
|
||||||
@@ -16,6 +17,14 @@ function makeSnippet(overrides: Partial<Snippet> = {}): Snippet {
|
|||||||
return { ...createSnippet({ now: FIXED_NOW, id: 's1' }), ...overrides };
|
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 {
|
function makeDataset(overrides: Partial<Dataset> = {}): Dataset {
|
||||||
return {
|
return {
|
||||||
...createDataset({
|
...createDataset({
|
||||||
@@ -38,7 +47,7 @@ describe('EXPORT_ENVELOPE_VERSION', () => {
|
|||||||
|
|
||||||
describe('buildExportEnvelope', () => {
|
describe('buildExportEnvelope', () => {
|
||||||
it('stamps version, ISO timestamp, and the fixed exporter tag', () => {
|
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.version).toBe('1.0');
|
||||||
expect(env.exportedAt).toBe('2026-06-03T12:00:00.000Z');
|
expect(env.exportedAt).toBe('2026-06-03T12:00:00.000Z');
|
||||||
expect(env.exportedBy).toBe('Astrolabe');
|
expect(env.exportedBy).toBe('Astrolabe');
|
||||||
@@ -47,13 +56,15 @@ describe('buildExportEnvelope', () => {
|
|||||||
it('produces the full envelope shape (matches spec §08 example fields)', () => {
|
it('produces the full envelope shape (matches spec §08 example fields)', () => {
|
||||||
const snippet = makeSnippet();
|
const snippet = makeSnippet();
|
||||||
const dataset = makeDataset();
|
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 = {
|
const expected: ExportEnvelope = {
|
||||||
version: '1.0',
|
version: '1.0',
|
||||||
exportedAt: '2026-06-03T12:00:00.000Z',
|
exportedAt: '2026-06-03T12:00:00.000Z',
|
||||||
exportedBy: 'Astrolabe',
|
exportedBy: 'Astrolabe',
|
||||||
snippets: [snippet],
|
snippets: [snippet],
|
||||||
datasets: [dataset],
|
datasets: [dataset],
|
||||||
|
themes: [theme],
|
||||||
};
|
};
|
||||||
expect(env).toEqual(expected);
|
expect(env).toEqual(expected);
|
||||||
});
|
});
|
||||||
@@ -61,17 +72,21 @@ describe('buildExportEnvelope', () => {
|
|||||||
it('carries the records through unchanged', () => {
|
it('carries the records through unchanged', () => {
|
||||||
const snippet = makeSnippet();
|
const snippet = makeSnippet();
|
||||||
const dataset = makeDataset();
|
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.snippets[0]).toBe(snippet);
|
||||||
expect(env.datasets[0]).toBe(dataset);
|
expect(env.datasets[0]).toBe(dataset);
|
||||||
|
expect(env.themes[0]).toBe(theme);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves each record's own version field", () => {
|
it("preserves each record's own version field", () => {
|
||||||
const snippet = makeSnippet({ version: 1 });
|
const snippet = makeSnippet({ version: 1 });
|
||||||
const dataset = makeDataset({ 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.snippets[0].version).toBe(1);
|
||||||
expect(env.datasets[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.
|
// The per-record version is distinct from the envelope's file-format version.
|
||||||
expect(env.version).toBe('1.0');
|
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)", () => {
|
it("does not alias the caller's arrays (mutating inputs afterward is inert)", () => {
|
||||||
const snippets = [makeSnippet()];
|
const snippets = [makeSnippet()];
|
||||||
const datasets = [makeDataset()];
|
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' }));
|
snippets.push(makeSnippet({ id: 's2' }));
|
||||||
datasets.push(makeDataset({ id: 2, name: 'D2' }));
|
datasets.push(makeDataset({ id: 2, name: 'D2' }));
|
||||||
|
themes.push(makeTheme({ id: 2, name: 'T2' }));
|
||||||
|
|
||||||
expect(env.snippets).toHaveLength(1);
|
expect(env.snippets).toHaveLength(1);
|
||||||
expect(env.datasets).toHaveLength(1);
|
expect(env.datasets).toHaveLength(1);
|
||||||
|
expect(env.themes).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty arrays', () => {
|
it('handles empty arrays', () => {
|
||||||
const env = buildExportEnvelope([], [], { now: FIXED_NOW });
|
const env = buildExportEnvelope([], [], [], { now: FIXED_NOW });
|
||||||
expect(env.snippets).toEqual([]);
|
expect(env.snippets).toEqual([]);
|
||||||
expect(env.datasets).toEqual([]);
|
expect(env.datasets).toEqual([]);
|
||||||
|
expect(env.themes).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,24 +125,30 @@ describe('exportFilename', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('exportSummaryMessage', () => {
|
describe('transferSummaryMessage (shared by export and import feedback)', () => {
|
||||||
it('reports both counts (plural)', () => {
|
it('reports snippet and dataset counts (plural)', () => {
|
||||||
expect(exportSummaryMessage(4, 2)).toBe('Exported 4 snippets and 2 datasets');
|
expect(transferSummaryMessage('Exported', 4, 2, 0)).toBe('Exported 4 snippets and 2 datasets');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits the dataset clause when there are no datasets', () => {
|
it('omits the dataset and theme clauses when their counts are zero', () => {
|
||||||
expect(exportSummaryMessage(4, 0)).toBe('Exported 4 snippets');
|
expect(transferSummaryMessage('Exported', 4, 0, 0)).toBe('Exported 4 snippets');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses singular wording for counts of 1', () => {
|
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', () => {
|
it('pluralizes zero counts (and omits the other clauses)', () => {
|
||||||
expect(exportSummaryMessage(1, 0)).toBe('Exported 1 snippet');
|
expect(transferSummaryMessage('Imported', 0, 0, 0)).toBe('Imported 0 snippets');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pluralizes zero counts (and omits the dataset clause)', () => {
|
it('reports all three counts with comma-and joining', () => {
|
||||||
expect(exportSummaryMessage(0, 0)).toBe('Exported 0 snippets');
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* migration target — see snippet.ts / dataset.ts).
|
* migration target — see snippet.ts / dataset.ts).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { CustomTheme } from './custom-theme';
|
||||||
import type { Dataset } from './dataset';
|
import type { Dataset } from './dataset';
|
||||||
import type { Snippet } from './snippet';
|
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
|
* 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`
|
* metadata plus the complete-record arrays. Each record keeps its own `version`
|
||||||
* field unchanged.
|
* 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 interface ExportEnvelope {
|
||||||
/** Export format version (currently `"1.0"`). */
|
/** Export format version (currently `"1.0"`). */
|
||||||
@@ -38,6 +40,8 @@ export interface ExportEnvelope {
|
|||||||
snippets: Snippet[];
|
snippets: Snippet[];
|
||||||
/** All datasets, as complete records (each including its record `version`). */
|
/** All datasets, as complete records (each including its record `version`). */
|
||||||
datasets: Dataset[];
|
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(
|
export function buildExportEnvelope(
|
||||||
snippets: ReadonlyArray<Snippet>,
|
snippets: ReadonlyArray<Snippet>,
|
||||||
datasets: ReadonlyArray<Dataset>,
|
datasets: ReadonlyArray<Dataset>,
|
||||||
|
themes: ReadonlyArray<CustomTheme>,
|
||||||
opts: { now: Date },
|
opts: { now: Date },
|
||||||
): ExportEnvelope {
|
): ExportEnvelope {
|
||||||
return {
|
return {
|
||||||
@@ -58,6 +63,7 @@ export function buildExportEnvelope(
|
|||||||
exportedBy: 'Astrolabe',
|
exportedBy: 'Astrolabe',
|
||||||
snippets: [...snippets],
|
snippets: [...snippets],
|
||||||
datasets: [...datasets],
|
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.
|
* Success-toast message reporting transfer counts (spec §08 → Feedback) for both
|
||||||
* "Exported 4 snippets and 2 datasets". The dataset clause is omitted entirely when
|
* directions, e.g. "Exported 4 snippets, 2 datasets and 1 theme" / "Imported 1
|
||||||
* there are no datasets; singular/plural wording adapts to the counts.
|
* 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 {
|
export function transferSummaryMessage(
|
||||||
const snippets = countClause(snippetCount, 'snippet');
|
verb: 'Exported' | 'Imported',
|
||||||
if (datasetCount === 0) return `Exported ${snippets}`;
|
snippetCount: number,
|
||||||
return `Exported ${snippets} and ${countClause(datasetCount, 'dataset')}`;
|
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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { CURRENT_THEME_VERSION } from './custom-theme';
|
||||||
import { CURRENT_DATASET_VERSION, type Dataset } from './dataset';
|
import { CURRENT_DATASET_VERSION, type Dataset } from './dataset';
|
||||||
import {
|
import {
|
||||||
applyDatasetRenamesToSnippets,
|
applyDatasetRenamesToSnippets,
|
||||||
dedupeIncomingDatasetNames,
|
dedupeIncomingNames,
|
||||||
importSummaryMessage,
|
|
||||||
normalizeImport,
|
normalizeImport,
|
||||||
reassignCollidingSnippetIds,
|
reassignCollidingSnippetIds,
|
||||||
} from './import-normalize';
|
} from './import-normalize';
|
||||||
@@ -107,10 +107,11 @@ describe('normalizeImport — shape detection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty for null / non-object / non-array junk', () => {
|
it('returns empty for null / non-object / non-array junk', () => {
|
||||||
expect(normalizeImport(null)).toEqual({ snippets: [], datasets: [] });
|
const empty = { snippets: [], datasets: [], themes: [] };
|
||||||
expect(normalizeImport(42)).toEqual({ snippets: [], datasets: [] });
|
expect(normalizeImport(null)).toEqual(empty);
|
||||||
expect(normalizeImport('hello')).toEqual({ snippets: [], datasets: [] });
|
expect(normalizeImport(42)).toEqual(empty);
|
||||||
expect(normalizeImport(undefined)).toEqual({ snippets: [], datasets: [] });
|
expect(normalizeImport('hello')).toEqual(empty);
|
||||||
|
expect(normalizeImport(undefined)).toEqual(empty);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores a non-array datasets field on the envelope', () => {
|
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 });
|
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||||
expect(result.datasets).toEqual([]);
|
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', () => {
|
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', () => {
|
it('returns names unchanged when there are no collisions', () => {
|
||||||
const { datasets, renames } = dedupeIncomingDatasetNames(
|
const { records, renames } = dedupeIncomingNames(['Other'], [datasetRecord({ name: 'Sales' })]);
|
||||||
['Other'],
|
expect(records[0].name).toBe('Sales');
|
||||||
[datasetRecord({ name: 'Sales' })],
|
|
||||||
);
|
|
||||||
expect(datasets[0].name).toBe('Sales');
|
|
||||||
expect(renames).toEqual([]);
|
expect(renames).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('suffixes a collision with an existing library name', () => {
|
it('suffixes a collision with an existing library name', () => {
|
||||||
const { datasets, renames } = dedupeIncomingDatasetNames(
|
const { records, renames } = dedupeIncomingNames(['Sales'], [datasetRecord({ name: 'Sales' })]);
|
||||||
['Sales'],
|
expect(records[0].name).toBe('Sales 2');
|
||||||
[datasetRecord({ name: 'Sales' })],
|
|
||||||
);
|
|
||||||
expect(datasets[0].name).toBe('Sales 2');
|
|
||||||
expect(renames).toEqual([{ from: 'Sales', to: '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', () => {
|
it('reserves names as-you-go so intra-batch dupes become Sales 2, Sales 3', () => {
|
||||||
const { datasets, renames } = dedupeIncomingDatasetNames(
|
const { records, renames } = dedupeIncomingNames(
|
||||||
['Sales'],
|
['Sales'],
|
||||||
[datasetRecord({ name: 'Sales' }), datasetRecord({ name: '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([
|
expect(renames).toEqual([
|
||||||
{ from: 'Sales', to: 'Sales 2' },
|
{ from: 'Sales', to: 'Sales 2' },
|
||||||
{ from: 'Sales', to: 'Sales 3' },
|
{ from: 'Sales', to: 'Sales 3' },
|
||||||
@@ -319,18 +383,24 @@ describe('dedupeIncomingDatasetNames', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('matches collisions case-insensitively (Sales vs sales)', () => {
|
it('matches collisions case-insensitively (Sales vs sales)', () => {
|
||||||
const { datasets, renames } = dedupeIncomingDatasetNames(
|
const { records, renames } = dedupeIncomingNames(['Sales'], [datasetRecord({ name: 'sales' })]);
|
||||||
['Sales'],
|
expect(records[0].name).toBe('sales 2'); // preserves incoming casing
|
||||||
[datasetRecord({ name: 'sales' })],
|
|
||||||
);
|
|
||||||
expect(datasets[0].name).toBe('sales 2'); // preserves incoming casing
|
|
||||||
expect(renames).toEqual([{ from: 'sales', to: 'sales 2' }]);
|
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 keep = datasetRecord({ name: 'Untouched' });
|
||||||
const { datasets } = dedupeIncomingDatasetNames(['Sales'], [keep]);
|
const { records } = dedupeIncomingNames(['Sales'], [keep]);
|
||||||
expect(datasets[0]).toBe(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']);
|
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -6,18 +6,21 @@
|
|||||||
* data in, plain data out. The app's ImportService orchestrates the file read,
|
* data in, plain data out. The app's ImportService orchestrates the file read,
|
||||||
* store reads (for existing names/ids), and the commit; everything *deterministic*
|
* store reads (for existing names/ids), and the commit; everything *deterministic*
|
||||||
* about an import — recognizing the file shape, coercing each record onto the
|
* about an import — recognizing the file shape, coercing each record onto the
|
||||||
* current `Snippet`/`Dataset` shape, de-duping names, reassigning colliding ids,
|
* current `Snippet`/`Dataset`/`CustomTheme` shape, de-duping names, reassigning
|
||||||
* and propagating renames into specs — lives here so it can be unit-tested hardest.
|
* 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.
|
* `crypto.randomUUID` is a platform global (like in snippet.ts), allowed in core.
|
||||||
* Both id generators are injectable so tests can assert deterministically.
|
* 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 { CURRENT_DATASET_VERSION, type DataSource, type Dataset } from './dataset';
|
||||||
import type { DataFormat } from './format-detection';
|
import type { DataFormat } from './format-detection';
|
||||||
import { makeUniqueName } from './naming';
|
import { makeUniqueName } from './naming';
|
||||||
import type { ColumnStats } from './profile';
|
import type { ColumnStats } from './profile';
|
||||||
import { CURRENT_SNIPPET_VERSION, type Snippet } from './snippet';
|
import { CURRENT_SNIPPET_VERSION, type Snippet } from './snippet';
|
||||||
|
import { isJsonObject } from './spec-config';
|
||||||
import { extractDatasetRefs, recomputeDatasetRefs, renameDatasetInSpec } from './spec-refs';
|
import { extractDatasetRefs, recomputeDatasetRefs, renameDatasetInSpec } from './spec-refs';
|
||||||
import type { ColumnType } from './type-inference';
|
import type { ColumnType } from './type-inference';
|
||||||
|
|
||||||
@@ -28,10 +31,11 @@ const IMPORTED_TAG = 'imported';
|
|||||||
export interface NormalizedImport {
|
export interface NormalizedImport {
|
||||||
snippets: Snippet[];
|
snippets: Snippet[];
|
||||||
datasets: Dataset[];
|
datasets: Dataset[];
|
||||||
|
themes: CustomTheme[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single dataset rename applied during dedupe (`from` original → `to` unique). */
|
/** A single rename applied during name dedupe (`from` original → `to` unique). */
|
||||||
export interface DatasetRename {
|
export interface NameRename {
|
||||||
from: string;
|
from: string;
|
||||||
to: 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")
|
// 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.
|
* Detect the import shape and normalize every record onto the current model.
|
||||||
*
|
*
|
||||||
* - Envelope: object with a `version` AND a `snippets` array → its snippets
|
* - Envelope: object with a `version` AND a `snippets` array → its snippets
|
||||||
* (+ optional `datasets` array).
|
* (+ optional `datasets` and `themes` arrays).
|
||||||
* - Bare array: a top-level array → a list of snippets, no datasets.
|
* - Bare array: a top-level array → a list of snippets, no datasets/themes.
|
||||||
* - Single object: any other object → one snippet, no datasets.
|
* - Single object: any other object → one snippet, no datasets/themes.
|
||||||
* - junk (null / non-object / non-array) → empty.
|
* - junk (null / non-object / non-array) → empty.
|
||||||
*/
|
*/
|
||||||
export function normalizeImport(
|
export function normalizeImport(
|
||||||
@@ -220,6 +248,7 @@ export function normalizeImport(
|
|||||||
|
|
||||||
let rawSnippets: unknown[] = [];
|
let rawSnippets: unknown[] = [];
|
||||||
let rawDatasets: unknown[] = [];
|
let rawDatasets: unknown[] = [];
|
||||||
|
let rawThemes: unknown[] = [];
|
||||||
|
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
// Bare array of snippets.
|
// Bare array of snippets.
|
||||||
@@ -229,16 +258,18 @@ export function normalizeImport(
|
|||||||
if (hasEnvelope) {
|
if (hasEnvelope) {
|
||||||
rawSnippets = parsed.snippets as unknown[];
|
rawSnippets = parsed.snippets as unknown[];
|
||||||
if (Array.isArray(parsed.datasets)) rawDatasets = parsed.datasets;
|
if (Array.isArray(parsed.datasets)) rawDatasets = parsed.datasets;
|
||||||
|
if (Array.isArray(parsed.themes)) rawThemes = parsed.themes;
|
||||||
} else {
|
} else {
|
||||||
// Single snippet object.
|
// Single snippet object.
|
||||||
rawSnippets = [parsed];
|
rawSnippets = [parsed];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// else: null / non-object / non-array junk → both stay empty.
|
// else: null / non-object / non-array junk → all stay empty.
|
||||||
|
|
||||||
return {
|
return {
|
||||||
snippets: rawSnippets.map((s) => normalizeSnippet(s, nowIso, makeId)),
|
snippets: rawSnippets.map((s) => normalizeSnippet(s, nowIso, makeId)),
|
||||||
datasets: rawDatasets.map((d) => normalizeDataset(d, nowIso)),
|
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
|
* De-dupe incoming record names against the existing collection and within the
|
||||||
* batch itself (architecture 07 §5). Names are reserved *as we go* so two incoming
|
* batch itself (architecture 07 §5) — used for datasets and custom themes, both
|
||||||
* `Sales` become `Sales 2`, `Sales 3` — never two `Sales 2`. Existing datasets are
|
* of which key on a unique name. Names are reserved *as we go* so two incoming
|
||||||
* never overwritten. Every rename is collected for reporting. Only datasets whose
|
* `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.
|
* name actually changed are cloned.
|
||||||
*/
|
*/
|
||||||
export function dedupeIncomingDatasetNames(
|
export function dedupeIncomingNames<T extends { name: string }>(
|
||||||
existing: ReadonlyArray<string>,
|
existing: ReadonlyArray<string>,
|
||||||
incoming: ReadonlyArray<Dataset>,
|
incoming: ReadonlyArray<T>,
|
||||||
): { datasets: Dataset[]; renames: DatasetRename[] } {
|
): { records: T[]; renames: NameRename[] } {
|
||||||
const reserved = new Set(existing.map((n) => n.toLowerCase()));
|
const reserved = new Set(existing.map((n) => n.toLowerCase()));
|
||||||
const renames: DatasetRename[] = [];
|
const renames: NameRename[] = [];
|
||||||
|
|
||||||
const datasets = incoming.map((d) => {
|
const records = incoming.map((r) => {
|
||||||
const unique = makeUniqueName(d.name, reserved);
|
const unique = makeUniqueName(r.name, reserved);
|
||||||
reserved.add(unique.toLowerCase()); // reserve so later imports don't collide
|
reserved.add(unique.toLowerCase()); // reserve so later imports don't collide
|
||||||
if (unique !== d.name) renames.push({ from: d.name, to: unique });
|
if (unique !== r.name) renames.push({ from: r.name, to: unique });
|
||||||
return unique === d.name ? d : { ...d, name: 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
|
* snippets that reference them (architecture 07 §6): for each rename, rewrite both
|
||||||
* `spec` and `draftSpec` via `renameDatasetInSpec`, then recompute `datasetRefs`
|
* `spec` and `draftSpec` via `renameDatasetInSpec`, then recompute `datasetRefs`
|
||||||
* from the rewritten draft — the draft is the source of truth, `datasetRefs`
|
* from the rewritten draft — the draft is the source of truth, `datasetRefs`
|
||||||
@@ -309,7 +341,7 @@ export function reassignCollidingSnippetIds(
|
|||||||
*/
|
*/
|
||||||
export function applyDatasetRenamesToSnippets(
|
export function applyDatasetRenamesToSnippets(
|
||||||
snippets: ReadonlyArray<Snippet>,
|
snippets: ReadonlyArray<Snippet>,
|
||||||
renames: ReadonlyArray<DatasetRename>,
|
renames: ReadonlyArray<NameRename>,
|
||||||
): Snippet[] {
|
): Snippet[] {
|
||||||
if (renames.length === 0) return snippets.slice();
|
if (renames.length === 0) return snippets.slice();
|
||||||
|
|
||||||
@@ -344,22 +376,3 @@ export function applyDatasetRenamesToSnippets(
|
|||||||
return { ...snippet, spec, draftSpec, datasetRefs: recomputeDatasetRefs(draftSpec) };
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user