/** * Import / Export service (spec §08) — orchestration over the pure core helpers * and the stores. The deterministic work (shape detection, normalization, name * de-dupe, id reassignment, rename propagation, envelope/message building) lives * 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 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/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, transferSummaryMessage } from '@core/export-envelope'; import { applyDatasetRenamesToSnippets, dedupeIncomingNames, normalizeImport, reassignCollidingSnippetIds, } from '@core/import-normalize'; import { snippetSizeBytes } from '@core/snippet'; 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'; /** Practical snippet-storage budget (spec §02 storage monitor / §08). */ const SNIPPET_BUDGET_BYTES = 5 * 1024 * 1024; /** * Export the whole workspace to a downloaded JSON file (spec §08 → Export). * Flushes the live editor buffer first so in-progress draft edits are included. * An empty library is reported and nothing is downloaded, even if datasets exist. */ export function exportWorkspace(now: Date = new Date()): void { // Commit any valid, uncommitted buffer so the export reflects current work. useSnippetStore.getState().commitDraft(now); const snippets = useSnippetStore.getState().snippets; const datasets = useDatasetStore.getState().datasets; const themes = useCustomThemeStore.getState().themes; if (snippets.length === 0) { notify({ kind: 'info', title: 'Nothing to export', message: 'No snippets to export. Create a snippet first, then export your workspace.', }); return; } // TODO(fonts §08): the envelope does not yet carry user-uploaded FontAsset // bytes, so a theme/snippet referencing an uploaded font imports on another // machine with the fallback family. Embed the referenced faces (base64) here — // best done with task #3's shared font-bytes→embeddable-string helper. const envelope = buildExportEnvelope(snippets, datasets, themes, { now }); downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2)); notify({ kind: 'success', title: 'Workspace exported', message: transferSummaryMessage('Exported', snippets.length, datasets.length, themes.length), }); } /** * Import a picked JSON file: normalize, merge (never overwrite), and save * (spec §08 → Import). The existing workspace is never lost — an unreadable or * unparseable file leaves it untouched, and merges only ever append. */ export async function importWorkspace(file: File): Promise { let text: string; try { text = await readTextFile(file); } catch (err) { notify({ kind: 'error', title: "Couldn't read the file", message: 'The selected file could not be read. Choose the file again and retry.', detail: err instanceof Error ? `${err.name}: ${err.message}` : String(err), }); return; } let parsed: unknown; try { parsed = JSON.parse(text); } catch { notify({ kind: 'error', title: 'Import failed', message: 'Failed to import. Please check that the file is valid JSON.', }); return; } const { snippets: normSnippets, datasets: normDatasets, themes: normThemes, } = normalizeImport(parsed); if (normSnippets.length === 0) { notify({ kind: 'info', title: 'Nothing imported', message: 'No snippets found in file.', }); return; } // 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 → Name conflicts; docs/architecture/07 §5–6). const existingDatasetNames = useDatasetStore.getState().datasets.map((d) => d.name); 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 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. const existingBytes = useSnippetStore .getState() .snippets.reduce((n, s) => n + snippetSizeBytes(s), 0); const incomingBytes = finalSnippets.reduce((n, s) => n + snippetSizeBytes(s), 0); const overage = existingBytes + incomingBytes - SNIPPET_BUDGET_BYTES; // Atomic snippet write (spec §08 → Storage limit handling / spec §10 → // Non-destructive import): write every snippet to IDB before updating the // Zustand store. A quota failure mid-write is caught here, the already-written // records are deleted, the just-added datasets are removed from the store, and // we surface a clear actionable error — leaving the workspace exactly as before. // After all writes succeed, `addSnippets` makes them visible in the store; the // write-through subscriber re-runs `saveSnippet` for each, but `put` is // idempotent so those second writes are harmless. const writtenSnippetIds: string[] = []; try { for (const snippet of finalSnippets) { await saveSnippet(snippet); writtenSnippetIds.push(snippet.id); } } catch (err) { // Roll back: delete every IDB record we already wrote. await Promise.allSettled(writtenSnippetIds.map((id) => deleteSnippet(id))); // 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). if (err instanceof StorageQuotaError) { notify({ kind: 'error', title: 'Import failed — storage full', message: 'The import could not be saved because snippet storage is full. ' + 'Delete snippets you no longer need to free up space, then try importing again.', }); } else { notify({ kind: 'error', title: 'Import failed', message: 'A storage error prevented the import from completing. The workspace was not changed. ' + 'If this keeps happening, your browser may be blocking local storage.', detail: err instanceof Error ? `Snippet save failed: ${err.name}: ${err.message}` : String(err), }); } return; } // 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 records were // renamed or storage is over budget, otherwise a success. const summary = transferSummaryMessage( 'Imported', finalSnippets.length, dedupedDatasets.length, dedupedThemes.length, ); const clauses: string[] = []; const allRenames = [...renames, ...themeRenames]; if (allRenames.length > 0) { clauses.push( `Renamed to avoid clashes: ${allRenames.map((r) => `${r.from} → ${r.to}`).join(', ')}.`, ); } if (overage > 0) { clauses.push( `This puts snippet storage about ${humanizeBytes(overage)} over the ~5 MB budget; ` + `consider deleting some snippets.`, ); } if (clauses.length > 0) { notify({ kind: 'warning', title: 'Import complete', message: `${summary}. ${clauses.join(' ')}`, }); } else { notify({ kind: 'success', title: 'Import complete', message: summary }); } }