Files
astrolabe/src/app/services/transfer.ts
T

253 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<void> {
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 §56).
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 });
}
}