mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add distributed settings and workspace import/export (M5)
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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 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).
|
||||
*/
|
||||
|
||||
import { buildExportEnvelope, exportFilename, exportSummaryMessage } from '@core/export-envelope';
|
||||
import {
|
||||
applyDatasetRenamesToSnippets,
|
||||
dedupeIncomingDatasetNames,
|
||||
importSummaryMessage,
|
||||
normalizeImport,
|
||||
reassignCollidingSnippetIds,
|
||||
} from '@core/import-normalize';
|
||||
import { snippetSizeBytes } from '@core/snippet';
|
||||
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
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;
|
||||
|
||||
/** Round bytes to a short KB/MB string for the overage warning. */
|
||||
function formatBytes(bytes: number): string {
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${Math.max(1, Math.round(kb))} KB`;
|
||||
return `${Math.round(kb / 1024)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const envelope = buildExportEnvelope(snippets, datasets, { now });
|
||||
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
|
||||
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Workspace exported',
|
||||
message: exportSummaryMessage(snippets.length, datasets.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 } = 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 → Dataset conflicts; docs/architecture/07 §5–6).
|
||||
const existingDatasetNames = useDatasetStore.getState().datasets.map((d) => d.name);
|
||||
const { datasets: dedupedDatasets, renames } = dedupeIncomingDatasetNames(
|
||||
existingDatasetNames,
|
||||
normDatasets,
|
||||
);
|
||||
const renamedSnippets = applyDatasetRenamesToSnippets(normSnippets, renames);
|
||||
|
||||
// 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).
|
||||
useDatasetStore.getState().addDatasets(dedupedDatasets);
|
||||
|
||||
// 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;
|
||||
|
||||
// TODO (spec §08 → Storage limit handling): a *hard* quota failure on save
|
||||
// should commit no partial snippet import. The write-through subscriber
|
||||
// currently surfaces such a failure as a per-record error toast, but the
|
||||
// in-memory store keeps the records (they vanish on reload). True atomic
|
||||
// rollback needs a write-before-store import path — a persistence-architecture
|
||||
// change deferred until the M6 storage monitor (web.dev quota estimate) lands.
|
||||
useSnippetStore.getState().addSnippets(finalSnippets);
|
||||
|
||||
// Feedback (spec §08 → Feedback): one summary toast; a warning when datasets were
|
||||
// renamed or storage is over budget, otherwise a success.
|
||||
const summary = importSummaryMessage(finalSnippets.length, dedupedDatasets.length);
|
||||
const clauses: string[] = [];
|
||||
if (renames.length > 0) {
|
||||
clauses.push(
|
||||
`Renamed to avoid clashes: ${renames.map((r) => `${r.from} → ${r.to}`).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (overage > 0) {
|
||||
clauses.push(
|
||||
`This puts snippet storage about ${formatBytes(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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user