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:
@@ -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 () => {
|
||||
|
||||
@@ -5,24 +5,24 @@
|
||||
* in `@core/import-normalize` and `@core/export-envelope`; this layer reads the
|
||||
* stores, drives the browser file adapter, commits the merge, and reports.
|
||||
*
|
||||
* Order matters on import (spec §08 → Merge): datasets are committed **before**
|
||||
* snippets so a snippet's by-name reference resolves against the just-added
|
||||
* dataset (whose name may have been auto-suffixed to avoid a clash).
|
||||
* Order matters on import (spec §08 → Merge): datasets and custom themes are
|
||||
* committed **before** snippets so a snippet's by-name reference resolves
|
||||
* against the just-added dataset (whose name may have been auto-suffixed to
|
||||
* avoid a clash).
|
||||
*
|
||||
* Atomicity (spec §08 → Storage limit handling / spec §10 → Non-destructive
|
||||
* import): snippet writes are performed directly to IDB before touching the
|
||||
* Zustand store. If any write fails the already-written IDB records are deleted
|
||||
* and the just-added datasets are rolled back from the store, so neither the
|
||||
* store nor IDB retains a partial import. The write-through subscriber sees
|
||||
* and the just-added datasets/themes are rolled back from the store, so neither
|
||||
* the store nor IDB retains a partial import. The write-through subscriber sees
|
||||
* store changes only after all IDB writes succeed; because `put` is idempotent
|
||||
* the second (subscriber) write of each record is a harmless no-op.
|
||||
*/
|
||||
|
||||
import { buildExportEnvelope, exportFilename, exportSummaryMessage } from '@core/export-envelope';
|
||||
import { buildExportEnvelope, exportFilename, transferSummaryMessage } from '@core/export-envelope';
|
||||
import {
|
||||
applyDatasetRenamesToSnippets,
|
||||
dedupeIncomingDatasetNames,
|
||||
importSummaryMessage,
|
||||
dedupeIncomingNames,
|
||||
normalizeImport,
|
||||
reassignCollidingSnippetIds,
|
||||
} from '@core/import-normalize';
|
||||
@@ -31,6 +31,7 @@ import { humanizeBytes } from '@core/storage-estimate';
|
||||
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
|
||||
import { deleteSnippet, saveSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
|
||||
@@ -48,6 +49,7 @@ export function exportWorkspace(now: Date = new Date()): void {
|
||||
|
||||
const snippets = useSnippetStore.getState().snippets;
|
||||
const datasets = useDatasetStore.getState().datasets;
|
||||
const themes = useCustomThemeStore.getState().themes;
|
||||
|
||||
if (snippets.length === 0) {
|
||||
notify({
|
||||
@@ -58,13 +60,13 @@ export function exportWorkspace(now: Date = new Date()): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const envelope = buildExportEnvelope(snippets, datasets, { now });
|
||||
const envelope = buildExportEnvelope(snippets, datasets, themes, { now });
|
||||
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
|
||||
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Workspace exported',
|
||||
message: exportSummaryMessage(snippets.length, datasets.length),
|
||||
message: transferSummaryMessage('Exported', snippets.length, datasets.length, themes.length),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,7 +101,11 @@ export async function importWorkspace(file: File): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const { snippets: normSnippets, datasets: normDatasets } = normalizeImport(parsed);
|
||||
const {
|
||||
snippets: normSnippets,
|
||||
datasets: normDatasets,
|
||||
themes: normThemes,
|
||||
} = normalizeImport(parsed);
|
||||
|
||||
if (normSnippets.length === 0) {
|
||||
notify({
|
||||
@@ -112,23 +118,33 @@ export async function importWorkspace(file: File): Promise<void> {
|
||||
|
||||
// Datasets first: de-dupe their names against the library (and within the batch),
|
||||
// then propagate any rename into the imported snippets so their references still
|
||||
// resolve (spec §08 → Dataset conflicts; docs/architecture/07 §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<void> {
|
||||
// Roll back: delete every IDB record we already wrote.
|
||||
await Promise.allSettled(writtenSnippetIds.map((id) => deleteSnippet(id)));
|
||||
|
||||
// Roll back: remove the datasets we just added to the store so neither
|
||||
// store nor IDB retains any trace of this import.
|
||||
// Roll back: remove the datasets and themes we just added to the store so
|
||||
// neither store nor IDB retains any trace of this import.
|
||||
const addedDatasets = useDatasetStore
|
||||
.getState()
|
||||
.datasets.filter((d) => !datasetIdsBefore.has(d.id));
|
||||
for (const d of addedDatasets) {
|
||||
useDatasetStore.getState().remove(d.id);
|
||||
}
|
||||
const addedThemes = useCustomThemeStore
|
||||
.getState()
|
||||
.themes.filter((t) => !themeIdsBefore.has(t.id));
|
||||
for (const t of addedThemes) {
|
||||
useCustomThemeStore.getState().remove(t.id);
|
||||
}
|
||||
|
||||
// Surface a clear, actionable error (spec §08 "Quota failure"; NN/g #9 /
|
||||
// GOV.UK plain language — no codes, tell the user what to do next).
|
||||
@@ -192,13 +214,19 @@ export async function importWorkspace(file: File): Promise<void> {
|
||||
// All IDB writes succeeded — now make the snippets visible in the store.
|
||||
useSnippetStore.getState().addSnippets(finalSnippets);
|
||||
|
||||
// Feedback (spec §08 → Feedback): one summary toast; a warning when datasets were
|
||||
// Feedback (spec §08 → Feedback): one summary toast; a warning when records were
|
||||
// renamed or storage is over budget, otherwise a success.
|
||||
const summary = importSummaryMessage(finalSnippets.length, dedupedDatasets.length);
|
||||
const summary = transferSummaryMessage(
|
||||
'Imported',
|
||||
finalSnippets.length,
|
||||
dedupedDatasets.length,
|
||||
dedupedThemes.length,
|
||||
);
|
||||
const clauses: string[] = [];
|
||||
if (renames.length > 0) {
|
||||
const allRenames = [...renames, ...themeRenames];
|
||||
if (allRenames.length > 0) {
|
||||
clauses.push(
|
||||
`Renamed to avoid clashes: ${renames.map((r) => `${r.from} → ${r.to}`).join(', ')}.`,
|
||||
`Renamed to avoid clashes: ${allRenames.map((r) => `${r.from} → ${r.to}`).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (overage > 0) {
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
|
||||
@@ -72,6 +72,12 @@ export interface CustomThemeState {
|
||||
|
||||
/** Low-level: add a fully-formed theme and select it. Returns the record with its assigned id. */
|
||||
add: (theme: CustomTheme) => CustomTheme;
|
||||
/**
|
||||
* Low-level batch add (import path): reassign ids from the store's id
|
||||
* authority and append, WITHOUT touching the builder selection — an import
|
||||
* must not hijack an open Theme Builder draft.
|
||||
*/
|
||||
addThemes: (incoming: CustomTheme[]) => void;
|
||||
/** Low-level: merge a patch into a theme, advancing `modified`. */
|
||||
update: (id: number, patch: Partial<CustomTheme>, now?: Date) => void;
|
||||
/**
|
||||
@@ -208,6 +214,15 @@ export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
|
||||
return withId;
|
||||
},
|
||||
|
||||
addThemes: (incoming) => {
|
||||
if (incoming.length === 0) return;
|
||||
set((s) => {
|
||||
let nextId = nextThemeId(s.themes);
|
||||
const withIds = incoming.map((t) => ({ ...t, id: nextId++ }));
|
||||
return { themes: [...s.themes, ...withIds] };
|
||||
});
|
||||
},
|
||||
|
||||
update: (id, patch, now) => {
|
||||
const modified = patch.modified ?? (now ?? new Date()).toISOString();
|
||||
set((s) => ({
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { createCustomTheme, type CustomTheme } from './custom-theme';
|
||||
import { createDataset, type Dataset } from './dataset';
|
||||
import {
|
||||
EXPORT_ENVELOPE_VERSION,
|
||||
buildExportEnvelope,
|
||||
exportFilename,
|
||||
exportSummaryMessage,
|
||||
transferSummaryMessage,
|
||||
type ExportEnvelope,
|
||||
} from './export-envelope';
|
||||
import { createSnippet, type Snippet } from './snippet';
|
||||
@@ -16,6 +17,14 @@ function makeSnippet(overrides: Partial<Snippet> = {}): Snippet {
|
||||
return { ...createSnippet({ now: FIXED_NOW, id: 's1' }), ...overrides };
|
||||
}
|
||||
|
||||
function makeTheme(overrides: Partial<CustomTheme> = {}): CustomTheme {
|
||||
return {
|
||||
...createCustomTheme({ name: 'T1', config: { background: '#fff' }, now: FIXED_NOW }),
|
||||
id: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDataset(overrides: Partial<Dataset> = {}): Dataset {
|
||||
return {
|
||||
...createDataset({
|
||||
@@ -38,7 +47,7 @@ describe('EXPORT_ENVELOPE_VERSION', () => {
|
||||
|
||||
describe('buildExportEnvelope', () => {
|
||||
it('stamps version, ISO timestamp, and the fixed exporter tag', () => {
|
||||
const env = buildExportEnvelope([], [], { now: FIXED_NOW });
|
||||
const env = buildExportEnvelope([], [], [], { now: FIXED_NOW });
|
||||
expect(env.version).toBe('1.0');
|
||||
expect(env.exportedAt).toBe('2026-06-03T12:00:00.000Z');
|
||||
expect(env.exportedBy).toBe('Astrolabe');
|
||||
@@ -47,13 +56,15 @@ describe('buildExportEnvelope', () => {
|
||||
it('produces the full envelope shape (matches spec §08 example fields)', () => {
|
||||
const snippet = makeSnippet();
|
||||
const dataset = makeDataset();
|
||||
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
|
||||
const theme = makeTheme();
|
||||
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
|
||||
const expected: ExportEnvelope = {
|
||||
version: '1.0',
|
||||
exportedAt: '2026-06-03T12:00:00.000Z',
|
||||
exportedBy: 'Astrolabe',
|
||||
snippets: [snippet],
|
||||
datasets: [dataset],
|
||||
themes: [theme],
|
||||
};
|
||||
expect(env).toEqual(expected);
|
||||
});
|
||||
@@ -61,17 +72,21 @@ describe('buildExportEnvelope', () => {
|
||||
it('carries the records through unchanged', () => {
|
||||
const snippet = makeSnippet();
|
||||
const dataset = makeDataset();
|
||||
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
|
||||
const theme = makeTheme();
|
||||
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
|
||||
expect(env.snippets[0]).toBe(snippet);
|
||||
expect(env.datasets[0]).toBe(dataset);
|
||||
expect(env.themes[0]).toBe(theme);
|
||||
});
|
||||
|
||||
it("preserves each record's own version field", () => {
|
||||
const snippet = makeSnippet({ version: 1 });
|
||||
const dataset = makeDataset({ version: 1 });
|
||||
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
|
||||
const theme = makeTheme({ version: 1 });
|
||||
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
|
||||
expect(env.snippets[0].version).toBe(1);
|
||||
expect(env.datasets[0].version).toBe(1);
|
||||
expect(env.themes[0].version).toBe(1);
|
||||
// The per-record version is distinct from the envelope's file-format version.
|
||||
expect(env.version).toBe('1.0');
|
||||
});
|
||||
@@ -79,19 +94,23 @@ describe('buildExportEnvelope', () => {
|
||||
it("does not alias the caller's arrays (mutating inputs afterward is inert)", () => {
|
||||
const snippets = [makeSnippet()];
|
||||
const datasets = [makeDataset()];
|
||||
const env = buildExportEnvelope(snippets, datasets, { now: FIXED_NOW });
|
||||
const themes = [makeTheme()];
|
||||
const env = buildExportEnvelope(snippets, datasets, themes, { now: FIXED_NOW });
|
||||
|
||||
snippets.push(makeSnippet({ id: 's2' }));
|
||||
datasets.push(makeDataset({ id: 2, name: 'D2' }));
|
||||
themes.push(makeTheme({ id: 2, name: 'T2' }));
|
||||
|
||||
expect(env.snippets).toHaveLength(1);
|
||||
expect(env.datasets).toHaveLength(1);
|
||||
expect(env.themes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles empty arrays', () => {
|
||||
const env = buildExportEnvelope([], [], { now: FIXED_NOW });
|
||||
const env = buildExportEnvelope([], [], [], { now: FIXED_NOW });
|
||||
expect(env.snippets).toEqual([]);
|
||||
expect(env.datasets).toEqual([]);
|
||||
expect(env.themes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,24 +125,30 @@ describe('exportFilename', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportSummaryMessage', () => {
|
||||
it('reports both counts (plural)', () => {
|
||||
expect(exportSummaryMessage(4, 2)).toBe('Exported 4 snippets and 2 datasets');
|
||||
describe('transferSummaryMessage (shared by export and import feedback)', () => {
|
||||
it('reports snippet and dataset counts (plural)', () => {
|
||||
expect(transferSummaryMessage('Exported', 4, 2, 0)).toBe('Exported 4 snippets and 2 datasets');
|
||||
});
|
||||
|
||||
it('omits the dataset clause when there are no datasets', () => {
|
||||
expect(exportSummaryMessage(4, 0)).toBe('Exported 4 snippets');
|
||||
it('omits the dataset and theme clauses when their counts are zero', () => {
|
||||
expect(transferSummaryMessage('Exported', 4, 0, 0)).toBe('Exported 4 snippets');
|
||||
});
|
||||
|
||||
it('uses singular wording for counts of 1', () => {
|
||||
expect(exportSummaryMessage(1, 1)).toBe('Exported 1 snippet and 1 dataset');
|
||||
expect(transferSummaryMessage('Imported', 1, 1, 0)).toBe('Imported 1 snippet and 1 dataset');
|
||||
});
|
||||
|
||||
it('singular snippet with omitted dataset clause', () => {
|
||||
expect(exportSummaryMessage(1, 0)).toBe('Exported 1 snippet');
|
||||
it('pluralizes zero counts (and omits the other clauses)', () => {
|
||||
expect(transferSummaryMessage('Imported', 0, 0, 0)).toBe('Imported 0 snippets');
|
||||
});
|
||||
|
||||
it('pluralizes zero counts (and omits the dataset clause)', () => {
|
||||
expect(exportSummaryMessage(0, 0)).toBe('Exported 0 snippets');
|
||||
it('reports all three counts with comma-and joining', () => {
|
||||
expect(transferSummaryMessage('Exported', 4, 2, 1)).toBe(
|
||||
'Exported 4 snippets, 2 datasets and 1 theme',
|
||||
);
|
||||
});
|
||||
|
||||
it('joins snippets and themes with "and" when there are no datasets', () => {
|
||||
expect(transferSummaryMessage('Imported', 4, 0, 3)).toBe('Imported 4 snippets and 3 themes');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* migration target — see snippet.ts / dataset.ts).
|
||||
*/
|
||||
|
||||
import type { CustomTheme } from './custom-theme';
|
||||
import type { Dataset } from './dataset';
|
||||
import type { Snippet } from './snippet';
|
||||
|
||||
@@ -24,8 +25,9 @@ export const EXPORT_ENVELOPE_VERSION = '1.0';
|
||||
|
||||
/**
|
||||
* The downloaded file's top-level shape (spec §08 → Export envelope shape): format
|
||||
* metadata plus the two complete-record arrays. Each record keeps its own `version`
|
||||
* field unchanged.
|
||||
* metadata plus the complete-record arrays. Each record keeps its own `version`
|
||||
* field unchanged. `themes` is additive (always written, optional on read) so
|
||||
* pre-theme envelopes and importers remain compatible without a format bump.
|
||||
*/
|
||||
export interface ExportEnvelope {
|
||||
/** Export format version (currently `"1.0"`). */
|
||||
@@ -38,6 +40,8 @@ export interface ExportEnvelope {
|
||||
snippets: Snippet[];
|
||||
/** All datasets, as complete records (each including its record `version`). */
|
||||
datasets: Dataset[];
|
||||
/** All custom chart themes, as complete records (each including its record `version`). */
|
||||
themes: CustomTheme[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +54,7 @@ export interface ExportEnvelope {
|
||||
export function buildExportEnvelope(
|
||||
snippets: ReadonlyArray<Snippet>,
|
||||
datasets: ReadonlyArray<Dataset>,
|
||||
themes: ReadonlyArray<CustomTheme>,
|
||||
opts: { now: Date },
|
||||
): ExportEnvelope {
|
||||
return {
|
||||
@@ -58,6 +63,7 @@ export function buildExportEnvelope(
|
||||
exportedBy: 'Astrolabe',
|
||||
snippets: [...snippets],
|
||||
datasets: [...datasets],
|
||||
themes: [...themes],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,12 +88,21 @@ function countClause(count: number, noun: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Success-toast message reporting the export counts (spec §08 → Feedback), e.g.
|
||||
* "Exported 4 snippets and 2 datasets". The dataset clause is omitted entirely when
|
||||
* there are no datasets; singular/plural wording adapts to the counts.
|
||||
* Success-toast message reporting transfer counts (spec §08 → Feedback) for both
|
||||
* directions, e.g. "Exported 4 snippets, 2 datasets and 1 theme" / "Imported 1
|
||||
* snippet". The dataset and theme clauses are omitted entirely when their counts
|
||||
* are zero; singular/plural wording adapts. One builder for export and import so
|
||||
* a new record kind or wording change lands in both messages at once.
|
||||
*/
|
||||
export function exportSummaryMessage(snippetCount: number, datasetCount: number): string {
|
||||
const snippets = countClause(snippetCount, 'snippet');
|
||||
if (datasetCount === 0) return `Exported ${snippets}`;
|
||||
return `Exported ${snippets} and ${countClause(datasetCount, 'dataset')}`;
|
||||
export function transferSummaryMessage(
|
||||
verb: 'Exported' | 'Imported',
|
||||
snippetCount: number,
|
||||
datasetCount: number,
|
||||
themeCount: number,
|
||||
): string {
|
||||
const clauses = [countClause(snippetCount, 'snippet')];
|
||||
if (datasetCount > 0) clauses.push(countClause(datasetCount, 'dataset'));
|
||||
if (themeCount > 0) clauses.push(countClause(themeCount, 'theme'));
|
||||
const last = clauses.pop()!;
|
||||
return clauses.length === 0 ? `${verb} ${last}` : `${verb} ${clauses.join(', ')} and ${last}`;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,18 +6,21 @@
|
||||
* data in, plain data out. The app's ImportService orchestrates the file read,
|
||||
* store reads (for existing names/ids), and the commit; everything *deterministic*
|
||||
* about an import — recognizing the file shape, coercing each record onto the
|
||||
* current `Snippet`/`Dataset` shape, de-duping names, reassigning colliding ids,
|
||||
* and propagating renames into specs — lives here so it can be unit-tested hardest.
|
||||
* current `Snippet`/`Dataset`/`CustomTheme` shape, de-duping names, reassigning
|
||||
* colliding ids, and propagating renames into specs — lives here so it can be
|
||||
* unit-tested hardest.
|
||||
*
|
||||
* `crypto.randomUUID` is a platform global (like in snippet.ts), allowed in core.
|
||||
* Both id generators are injectable so tests can assert deterministically.
|
||||
*/
|
||||
|
||||
import { CURRENT_THEME_VERSION, type CustomTheme } from './custom-theme';
|
||||
import { CURRENT_DATASET_VERSION, type DataSource, type Dataset } from './dataset';
|
||||
import type { DataFormat } from './format-detection';
|
||||
import { makeUniqueName } from './naming';
|
||||
import type { ColumnStats } from './profile';
|
||||
import { CURRENT_SNIPPET_VERSION, type Snippet } from './snippet';
|
||||
import { isJsonObject } from './spec-config';
|
||||
import { extractDatasetRefs, recomputeDatasetRefs, renameDatasetInSpec } from './spec-refs';
|
||||
import type { ColumnType } from './type-inference';
|
||||
|
||||
@@ -28,10 +31,11 @@ const IMPORTED_TAG = 'imported';
|
||||
export interface NormalizedImport {
|
||||
snippets: Snippet[];
|
||||
datasets: Dataset[];
|
||||
themes: CustomTheme[];
|
||||
}
|
||||
|
||||
/** A single dataset rename applied during dedupe (`from` original → `to` unique). */
|
||||
export interface DatasetRename {
|
||||
/** A single rename applied during name dedupe (`from` original → `to` unique). */
|
||||
export interface NameRename {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
@@ -198,6 +202,30 @@ function normalizeDataset(raw: unknown, nowIso: string): Dataset {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one raw custom-theme record onto the current `CustomTheme` shape
|
||||
* (spec §08 → Accepted inputs). Themes only travel inside Astrolabe envelopes,
|
||||
* so this is gap-filling rather than foreign-shape mapping: a non-object
|
||||
* `config` becomes `{}`, missing timestamps derive like datasets, and `version`
|
||||
* is stamped current. Ids are provisional — the store's id authority reassigns
|
||||
* them on insert, so collisions need no handling here.
|
||||
*/
|
||||
function normalizeCustomTheme(raw: unknown, nowIso: string): CustomTheme {
|
||||
const r = isPlainObject(raw) ? raw : {};
|
||||
|
||||
const created = asNonEmptyString(r.created) ?? nowIso;
|
||||
const modified = asNonEmptyString(r.modified) ?? created;
|
||||
|
||||
return {
|
||||
id: typeof r.id === 'number' ? r.id : Number(r.id) || 0,
|
||||
version: CURRENT_THEME_VERSION,
|
||||
name: typeof r.name === 'string' ? r.name : 'Untitled',
|
||||
config: isJsonObject(r.config) ? r.config : {},
|
||||
created,
|
||||
modified,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Shape detection (spec §08 "Accepted inputs")
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -206,9 +234,9 @@ function normalizeDataset(raw: unknown, nowIso: string): Dataset {
|
||||
* Detect the import shape and normalize every record onto the current model.
|
||||
*
|
||||
* - Envelope: object with a `version` AND a `snippets` array → its snippets
|
||||
* (+ optional `datasets` array).
|
||||
* - Bare array: a top-level array → a list of snippets, no datasets.
|
||||
* - Single object: any other object → one snippet, no datasets.
|
||||
* (+ optional `datasets` and `themes` arrays).
|
||||
* - Bare array: a top-level array → a list of snippets, no datasets/themes.
|
||||
* - Single object: any other object → one snippet, no datasets/themes.
|
||||
* - junk (null / non-object / non-array) → empty.
|
||||
*/
|
||||
export function normalizeImport(
|
||||
@@ -220,6 +248,7 @@ export function normalizeImport(
|
||||
|
||||
let rawSnippets: unknown[] = [];
|
||||
let rawDatasets: unknown[] = [];
|
||||
let rawThemes: unknown[] = [];
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
// Bare array of snippets.
|
||||
@@ -229,16 +258,18 @@ export function normalizeImport(
|
||||
if (hasEnvelope) {
|
||||
rawSnippets = parsed.snippets as unknown[];
|
||||
if (Array.isArray(parsed.datasets)) rawDatasets = parsed.datasets;
|
||||
if (Array.isArray(parsed.themes)) rawThemes = parsed.themes;
|
||||
} else {
|
||||
// Single snippet object.
|
||||
rawSnippets = [parsed];
|
||||
}
|
||||
}
|
||||
// else: null / non-object / non-array junk → both stay empty.
|
||||
// else: null / non-object / non-array junk → all stay empty.
|
||||
|
||||
return {
|
||||
snippets: rawSnippets.map((s) => normalizeSnippet(s, nowIso, makeId)),
|
||||
datasets: rawDatasets.map((d) => normalizeDataset(d, nowIso)),
|
||||
themes: rawThemes.map((t) => normalizeCustomTheme(t, nowIso)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,27 +278,28 @@ export function normalizeImport(
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* De-dupe incoming dataset names against the existing library and within the
|
||||
* batch itself (architecture 07 §5). Names are reserved *as we go* so two incoming
|
||||
* `Sales` become `Sales 2`, `Sales 3` — never two `Sales 2`. Existing datasets are
|
||||
* never overwritten. Every rename is collected for reporting. Only datasets whose
|
||||
* De-dupe incoming record names against the existing collection and within the
|
||||
* batch itself (architecture 07 §5) — used for datasets and custom themes, both
|
||||
* of which key on a unique name. Names are reserved *as we go* so two incoming
|
||||
* `Sales` become `Sales 2`, `Sales 3` — never two `Sales 2`. Existing records are
|
||||
* never overwritten. Every rename is collected for reporting. Only records whose
|
||||
* name actually changed are cloned.
|
||||
*/
|
||||
export function dedupeIncomingDatasetNames(
|
||||
export function dedupeIncomingNames<T extends { name: string }>(
|
||||
existing: ReadonlyArray<string>,
|
||||
incoming: ReadonlyArray<Dataset>,
|
||||
): { datasets: Dataset[]; renames: DatasetRename[] } {
|
||||
incoming: ReadonlyArray<T>,
|
||||
): { records: T[]; renames: NameRename[] } {
|
||||
const reserved = new Set(existing.map((n) => n.toLowerCase()));
|
||||
const renames: DatasetRename[] = [];
|
||||
const renames: NameRename[] = [];
|
||||
|
||||
const datasets = incoming.map((d) => {
|
||||
const unique = makeUniqueName(d.name, reserved);
|
||||
const records = incoming.map((r) => {
|
||||
const unique = makeUniqueName(r.name, reserved);
|
||||
reserved.add(unique.toLowerCase()); // reserve so later imports don't collide
|
||||
if (unique !== d.name) renames.push({ from: d.name, to: unique });
|
||||
return unique === d.name ? d : { ...d, name: unique };
|
||||
if (unique !== r.name) renames.push({ from: r.name, to: unique });
|
||||
return unique === r.name ? r : { ...r, name: unique };
|
||||
});
|
||||
|
||||
return { datasets, renames };
|
||||
return { records, renames };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,7 +329,7 @@ export function reassignCollidingSnippetIds(
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate dataset renames (from `dedupeIncomingDatasetNames`) into the imported
|
||||
* Propagate dataset renames (from `dedupeIncomingNames`) into the imported
|
||||
* snippets that reference them (architecture 07 §6): for each rename, rewrite both
|
||||
* `spec` and `draftSpec` via `renameDatasetInSpec`, then recompute `datasetRefs`
|
||||
* from the rewritten draft — the draft is the source of truth, `datasetRefs`
|
||||
@@ -309,7 +341,7 @@ export function reassignCollidingSnippetIds(
|
||||
*/
|
||||
export function applyDatasetRenamesToSnippets(
|
||||
snippets: ReadonlyArray<Snippet>,
|
||||
renames: ReadonlyArray<DatasetRename>,
|
||||
renames: ReadonlyArray<NameRename>,
|
||||
): Snippet[] {
|
||||
if (renames.length === 0) return snippets.slice();
|
||||
|
||||
@@ -344,22 +376,3 @@ export function applyDatasetRenamesToSnippets(
|
||||
return { ...snippet, spec, draftSpec, datasetRefs: recomputeDatasetRefs(draftSpec) };
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Feedback
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** Pluralize a count: `1 thing`, `0 things`, `4 things`. */
|
||||
function plural(count: number, noun: string): string {
|
||||
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The import success message (spec §08 "Feedback"), e.g.
|
||||
* "Imported 4 snippets and 2 datasets". The dataset clause is omitted when
|
||||
* `datasetCount` is 0; singular/plural adapts for any counts ≥ 0.
|
||||
*/
|
||||
export function importSummaryMessage(snippetCount: number, datasetCount: number): string {
|
||||
const head = `Imported ${plural(snippetCount, 'snippet')}`;
|
||||
return datasetCount > 0 ? `${head} and ${plural(datasetCount, 'dataset')}` : head;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user