mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
/**
|
||
* 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 §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 });
|
||
}
|
||
}
|