/** * Export envelope — the single JSON object Astrolabe downloads to back up or * transfer a whole workspace (spec §08 → Export, Export envelope shape). * * Portable core: no browser APIs, no React. Builds the in-memory envelope, the * download filename, and the success-toast message as plain data. The actual file * download (Blob/anchor/DOM) is browser code that lives in the app layer; this * module only shapes what that code serializes and reports. * * The envelope `version` is the **file-format** version, distinct from the * per-record `version` fields each snippet/dataset carries (their read-time * migration target — see snippet.ts / dataset.ts). */ import type { CustomTheme } from './custom-theme'; import type { Dataset } from './dataset'; import { serializeFontAsset, type FontAsset, type SerializedFontAsset } from './font-asset'; import type { Snippet } from './snippet'; /** * Current export file-format version (spec §08 → "currently `\"1.0\"`"). Bump when * the envelope shape changes in a way importers must branch on; this is NOT the * per-record schema version. */ export const EXPORT_ENVELOPE_VERSION = '1.0'; /** * The downloaded file's top-level shape (spec §08 → Export envelope shape): format * metadata plus the complete-record arrays. Each record keeps its own `version` * field unchanged. `themes` and `fonts` are additive (always written, optional on * read) so older envelopes and importers remain compatible without a format bump. */ export interface ExportEnvelope { /** Export format version (currently `"1.0"`). */ version: string; /** ISO 8601 timestamp of the export. */ exportedAt: string; /** Fixed exporter tag. */ exportedBy: 'Astrolabe'; /** All snippets, as complete records (each including its record `version`). */ snippets: Snippet[]; /** All datasets, as complete records (each including its record `version`). */ datasets: Dataset[]; /** All custom chart themes, as complete records (each including its record `version`). */ themes: CustomTheme[]; /** * All uploaded font faces, base64-encoded so a referenced face survives the * round-trip (without this the family name imports but renders as fallback — * spec §08, scope doc §4). The whole library travels, like datasets/themes: a * workspace export is a backup, not a minimal bundle of what's referenced. */ fonts: SerializedFontAsset[]; } /** * Build the export envelope (spec §08 → Export). Stamps the format version, the * export timestamp (`now.toISOString()`), and the fixed exporter tag, and copies * the records into fresh arrays so the envelope does not alias the caller's arrays * (the records themselves are referenced as-is — they are the complete records to * serialize, each keeping its own `version`). */ export function buildExportEnvelope( snippets: ReadonlyArray, datasets: ReadonlyArray, themes: ReadonlyArray, fonts: ReadonlyArray, opts: { now: Date }, ): ExportEnvelope { return { version: EXPORT_ENVELOPE_VERSION, exportedAt: opts.now.toISOString(), exportedBy: 'Astrolabe', snippets: [...snippets], datasets: [...datasets], themes: [...themes], // Encode each face's bytes (ArrayBuffer → base64) so the array is JSON-safe. fonts: fonts.map(serializeFontAsset), }; } /** Two-digit zero-pad for the filename date (mirrors snippet.ts → generateSnippetName). */ function pad(n: number): string { return String(n).padStart(2, '0'); } /** * Download filename for an export (spec §08 → Filename): * `astrolabe-project-YYYY-MM-DD.json`, where the date is today's date (export day). * Uses local date parts, like `generateSnippetName`. */ export function exportFilename(now: Date): string { const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; return `astrolabe-project-${date}.json`; } /** Pluralize a count's noun: "1 snippet" / "4 snippets". */ function countClause(count: number, noun: string): string { return `${count} ${noun}${count === 1 ? '' : 's'}`; } /** * Success-toast message reporting transfer counts (spec §08 → Feedback) for both * directions, e.g. "Exported 4 snippets, 2 datasets and 1 theme" / "Imported 1 * snippet". The dataset, theme, and font clauses are omitted entirely when their * counts are zero; singular/plural wording adapts. One builder for export and * import so a new record kind or wording change lands in both messages at once. */ export function transferSummaryMessage( verb: 'Exported' | 'Imported', snippetCount: number, datasetCount: number, themeCount: number, fontCount: number, ): string { const clauses = [countClause(snippetCount, 'snippet')]; if (datasetCount > 0) clauses.push(countClause(datasetCount, 'dataset')); if (themeCount > 0) clauses.push(countClause(themeCount, 'theme')); if (fontCount > 0) clauses.push(countClause(fontCount, 'font')); const last = clauses.pop()!; return clauses.length === 0 ? `${verb} ${last}` : `${verb} ${clauses.join(', ')} and ${last}`; }