Workspace transfer: custom themes ride the export/import envelope

This commit is contained in:
2026-06-12 21:45:44 +03:00
parent 80d13c9b6f
commit dbb4522d78
11 changed files with 405 additions and 173 deletions
+50 -22
View File
@@ -5,24 +5,24 @@
* in `@core/import-normalize` and `@core/export-envelope`; this layer reads the
* stores, drives the browser file adapter, commits the merge, and reports.
*
* Order matters on import (spec §08 → Merge): datasets are committed **before**
* snippets so a snippet's by-name reference resolves against the just-added
* dataset (whose name may have been auto-suffixed to avoid a clash).
* Order matters on import (spec §08 → Merge): datasets and custom themes are
* committed **before** snippets so a snippet's by-name reference resolves
* against the just-added dataset (whose name may have been auto-suffixed to
* avoid a clash).
*
* Atomicity (spec §08 → Storage limit handling / spec §10 → Non-destructive
* import): snippet writes are performed directly to IDB before touching the
* Zustand store. If any write fails the already-written IDB records are deleted
* and the just-added datasets are rolled back from the store, so neither the
* store nor IDB retains a partial import. The write-through subscriber sees
* and the just-added datasets/themes are rolled back from the store, so neither
* the store nor IDB retains a partial import. The write-through subscriber sees
* store changes only after all IDB writes succeed; because `put` is idempotent
* the second (subscriber) write of each record is a harmless no-op.
*/
import { buildExportEnvelope, exportFilename, exportSummaryMessage } from '@core/export-envelope';
import { buildExportEnvelope, exportFilename, transferSummaryMessage } from '@core/export-envelope';
import {
applyDatasetRenamesToSnippets,
dedupeIncomingDatasetNames,
importSummaryMessage,
dedupeIncomingNames,
normalizeImport,
reassignCollidingSnippetIds,
} from '@core/import-normalize';
@@ -31,6 +31,7 @@ import { humanizeBytes } from '@core/storage-estimate';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { deleteSnippet, saveSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
import { notify } from '../stores/NotificationStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
@@ -48,6 +49,7 @@ export function exportWorkspace(now: Date = new Date()): void {
const snippets = useSnippetStore.getState().snippets;
const datasets = useDatasetStore.getState().datasets;
const themes = useCustomThemeStore.getState().themes;
if (snippets.length === 0) {
notify({
@@ -58,13 +60,13 @@ export function exportWorkspace(now: Date = new Date()): void {
return;
}
const envelope = buildExportEnvelope(snippets, datasets, { now });
const envelope = buildExportEnvelope(snippets, datasets, themes, { now });
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
notify({
kind: 'success',
title: 'Workspace exported',
message: exportSummaryMessage(snippets.length, datasets.length),
message: transferSummaryMessage('Exported', snippets.length, datasets.length, themes.length),
});
}
@@ -99,7 +101,11 @@ export async function importWorkspace(file: File): Promise<void> {
return;
}
const { snippets: normSnippets, datasets: normDatasets } = normalizeImport(parsed);
const {
snippets: normSnippets,
datasets: normDatasets,
themes: normThemes,
} = normalizeImport(parsed);
if (normSnippets.length === 0) {
notify({
@@ -112,23 +118,33 @@ export async function importWorkspace(file: File): Promise<void> {
// Datasets first: de-dupe their names against the library (and within the batch),
// then propagate any rename into the imported snippets so their references still
// resolve (spec §08 → Dataset conflicts; docs/architecture/07 §56).
// resolve (spec §08 → Name conflicts; docs/architecture/07 §56).
const existingDatasetNames = useDatasetStore.getState().datasets.map((d) => d.name);
const { datasets: dedupedDatasets, renames } = dedupeIncomingDatasetNames(
const { records: dedupedDatasets, renames } = dedupeIncomingNames(
existingDatasetNames,
normDatasets,
);
const renamedSnippets = applyDatasetRenamesToSnippets(normSnippets, renames);
// Themes key on a unique name too, but nothing references them by name, so a
// rename needs no propagation — only reporting (spec §08 → Name conflicts).
const existingThemeNames = useCustomThemeStore.getState().themes.map((t) => t.name);
const { records: dedupedThemes, renames: themeRenames } = dedupeIncomingNames(
existingThemeNames,
normThemes,
);
// Reassign incoming snippet ids that clash with the library (spec §08 → ID collisions).
const existingSnippetIds = useSnippetStore.getState().snippets.map((s) => s.id);
const finalSnippets = reassignCollidingSnippetIds(existingSnippetIds, renamedSnippets);
// Commit datasets BEFORE snippets so by-name references resolve (spec §08).
// Record which dataset ids existed before the add so we can roll them back if
// the subsequent snippet writes fail.
// Commit datasets and themes BEFORE snippets so by-name references resolve
// (spec §08). Record which ids existed before the add so we can roll them back
// if the subsequent snippet writes fail.
const datasetIdsBefore = new Set(useDatasetStore.getState().datasets.map((d) => d.id));
useDatasetStore.getState().addDatasets(dedupedDatasets);
const themeIdsBefore = new Set(useCustomThemeStore.getState().themes.map((t) => t.id));
useCustomThemeStore.getState().addThemes(dedupedThemes);
// Storage budget pre-check (spec §08 → Storage limit handling): warn on overage
// but still attempt the save.
@@ -156,14 +172,20 @@ export async function importWorkspace(file: File): Promise<void> {
// Roll back: delete every IDB record we already wrote.
await Promise.allSettled(writtenSnippetIds.map((id) => deleteSnippet(id)));
// Roll back: remove the datasets we just added to the store so neither
// store nor IDB retains any trace of this import.
// Roll back: remove the datasets and themes we just added to the store so
// neither store nor IDB retains any trace of this import.
const addedDatasets = useDatasetStore
.getState()
.datasets.filter((d) => !datasetIdsBefore.has(d.id));
for (const d of addedDatasets) {
useDatasetStore.getState().remove(d.id);
}
const addedThemes = useCustomThemeStore
.getState()
.themes.filter((t) => !themeIdsBefore.has(t.id));
for (const t of addedThemes) {
useCustomThemeStore.getState().remove(t.id);
}
// Surface a clear, actionable error (spec §08 "Quota failure"; NN/g #9 /
// GOV.UK plain language — no codes, tell the user what to do next).
@@ -192,13 +214,19 @@ export async function importWorkspace(file: File): Promise<void> {
// All IDB writes succeeded — now make the snippets visible in the store.
useSnippetStore.getState().addSnippets(finalSnippets);
// Feedback (spec §08 → Feedback): one summary toast; a warning when datasets were
// Feedback (spec §08 → Feedback): one summary toast; a warning when records were
// renamed or storage is over budget, otherwise a success.
const summary = importSummaryMessage(finalSnippets.length, dedupedDatasets.length);
const summary = transferSummaryMessage(
'Imported',
finalSnippets.length,
dedupedDatasets.length,
dedupedThemes.length,
);
const clauses: string[] = [];
if (renames.length > 0) {
const allRenames = [...renames, ...themeRenames];
if (allRenames.length > 0) {
clauses.push(
`Renamed to avoid clashes: ${renames.map((r) => `${r.from}${r.to}`).join(', ')}.`,
`Renamed to avoid clashes: ${allRenames.map((r) => `${r.from}${r.to}`).join(', ')}.`,
);
}
if (overage > 0) {