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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user