/// /** * Record Backup Pattern - Import/Export for Records * * Exports all Records in a space to a single JSON file and imports them back. * Designed for data survival after server wipes. * * Features: * - Discovers all Records using wish("/") * - Extracts module data using registry's fieldMapping * - Preserves wiki-links in notes as-is * - Includes trashed modules in export * - Per-module error handling on import */ import { Cell, computed, type Default, handler, ifElse, lift, NAME, navigateTo, pattern, UI, wish, } from "commontools"; import { createSubCharm, getDefinition } from "./record/registry.ts"; import type { SubCharmEntry, TrashedSubCharmEntry } from "./record/types.ts"; import Record from "./record.tsx"; import Note from "./notes/note.tsx"; // ===== Export Format Types ===== interface ExportedModule { type: string; pinned: boolean; data: Record; } interface ExportedTrashedModule extends ExportedModule { trashedAt: string; } interface ExportedRecord { localId: string; title: string; modules: ExportedModule[]; trashedModules: ExportedTrashedModule[]; } interface ExportData { version: string; exportDate: string; records: ExportedRecord[]; } // ===== Import Result Types ===== interface ImportError { record: string; module: string; error: string; } interface ImportResult { success: boolean; imported: number; failed: number; errors: ImportError[]; } // ===== Pattern Input/Output ===== interface Input { importJson: Default; } interface Output { exportedJson: string; importJson: string; recordCount: number; importResult: ImportResult | null; } // ===== Type for Record charm ===== interface RecordCharm { "#record"?: boolean; title?: string; subCharms?: SubCharmEntry[]; trashedSubCharms?: TrashedSubCharmEntry[]; } // ===== Data Extraction ===== /** * Coerce data types to match schema expectations * Handles common type mismatches (e.g., "1986" string → 1986 number) * Used by both export (to fix ct-input storing numbers as strings) * and import (to handle JSON that may have wrong types) */ function coerceDataTypes( type: string, data: Record, ): Record { const def = getDefinition(type); if (!def?.schema) return data; const coerced = { ...data }; for (const [field, schema] of Object.entries(def.schema)) { const value = coerced[field]; // deno-lint-ignore no-explicit-any const fieldSchema = schema as any; if (value === undefined || value === null || value === "") continue; // Coerce string to number if schema expects number if (fieldSchema.type === "number" && typeof value === "string") { const parsed = Number(value); if (!isNaN(parsed)) { coerced[field] = parsed; } } } return coerced; } /** * Extract data from a module using the registry's fieldMapping * Also coerces types to match schema (e.g., string "1986" → number 1986) */ function extractModuleData( charm: unknown, type: string, ): Record { const def = getDefinition(type); if (!def?.fieldMapping) return {}; // Validate charm is an object if (charm == null || typeof charm !== "object") { console.warn(`Invalid charm for type "${type}": expected object`); return {}; } const data: Record = {}; for (const field of def.fieldMapping) { try { // deno-lint-ignore no-explicit-any const value = (charm as any)?.[field]; if (value !== undefined) { // Unwrap Cell values if needed, with error handling // deno-lint-ignore no-explicit-any data[field] = (value as any)?.get ? (value as any).get() : value; } } catch (e) { // Cell.get() or other operations may throw console.warn(`Failed to extract field "${field}" from ${type}:`, e); data[field] = null; } } // Coerce types to match schema (fixes ct-input storing numbers as strings) return coerceDataTypes(type, data); } /** * Build export data from all Records in the space */ const buildExportData = lift( ({ allCharms }: { allCharms: RecordCharm[] }): ExportData => { // Filter to only Record patterns const records = (allCharms || []).filter( (charm) => charm?.["#record"] === true, ); const exportedRecords: ExportedRecord[] = records.map((record, index) => { const localId = `record-${String(index + 1).padStart(3, "0")}`; const title = record?.title || "(Untitled Record)"; // Extract active modules const subCharms = record?.subCharms || []; const modules: ExportedModule[] = subCharms .filter((entry: SubCharmEntry) => { // Guard against undefined entries or missing charm if (!entry || !entry.type || entry.charm == null) return false; const def = getDefinition(entry.type); // Skip internal modules (like type-picker) return def && !def.internal; }) .map((entry: SubCharmEntry) => ({ type: entry.type, pinned: entry.pinned, data: extractModuleData(entry.charm, entry.type), })); // Extract trashed modules const trashedSubCharms = record?.trashedSubCharms || []; const trashedModules: ExportedTrashedModule[] = trashedSubCharms .filter((entry: TrashedSubCharmEntry) => { // Guard against undefined entries or missing charm if (!entry || !entry.type || entry.charm == null) return false; const def = getDefinition(entry.type); return def && !def.internal; }) .map((entry: TrashedSubCharmEntry) => ({ type: entry.type, pinned: entry.pinned, data: extractModuleData(entry.charm, entry.type), trashedAt: entry.trashedAt, })); return { localId, title, modules, trashedModules, }; }); return { version: "1.0", exportDate: new Date().toISOString(), records: exportedRecords, }; }, ); /** * Format export data as pretty-printed JSON */ const formatExportJson = lift( ({ exportData }: { exportData: ExportData }): string => { return JSON.stringify(exportData, null, 2); }, ); /** * Count records in export */ const countRecords = lift( ({ exportData }: { exportData: ExportData }): number => { return exportData?.records?.length || 0; }, ); // ===== Import Logic ===== /** * Parse and validate import JSON with comprehensive structure checking */ function parseImportJson(jsonText: string): { valid: boolean; data?: ExportData; error?: string; } { if (!jsonText || jsonText.trim() === "") { return { valid: false, error: "No JSON provided" }; } try { const parsed = JSON.parse(jsonText); // Validate top-level structure if (!parsed || typeof parsed !== "object") { return { valid: false, error: "Invalid JSON: expected an object" }; } if (parsed.version !== "1.0") { return { valid: false, error: `Unsupported version: ${parsed.version || "missing"}`, }; } if (!Array.isArray(parsed.records)) { return { valid: false, error: 'Missing or invalid "records" array' }; } // Validate each record structure for (let i = 0; i < parsed.records.length; i++) { const record = parsed.records[i]; if (!record || typeof record !== "object") { return { valid: false, error: `Record ${i}: not an object` }; } if (typeof record.title !== "string") { return { valid: false, error: `Record ${i}: missing or invalid title` }; } if (!Array.isArray(record.modules)) { return { valid: false, error: `Record ${i}: modules must be an array` }; } if (!Array.isArray(record.trashedModules)) { return { valid: false, error: `Record ${i}: trashedModules must be an array`, }; } // Validate module structure for (let j = 0; j < record.modules.length; j++) { const mod = record.modules[j]; if (!mod || typeof mod !== "object") { return { valid: false, error: `Record ${i}, module ${j}: invalid` }; } if (typeof mod.type !== "string") { return { valid: false, error: `Record ${i}, module ${j}: missing type`, }; } if (typeof mod.pinned !== "boolean") { return { valid: false, error: `Record ${i}, module ${j}: pinned must be boolean`, }; } if (!mod.data || typeof mod.data !== "object") { return { valid: false, error: `Record ${i}, module ${j}: data must be an object`, }; } } // Validate trashed module structure for (let j = 0; j < record.trashedModules.length; j++) { const mod = record.trashedModules[j]; if (!mod || typeof mod !== "object") { return { valid: false, error: `Record ${i}, trashed ${j}: invalid`, }; } if (typeof mod.type !== "string") { return { valid: false, error: `Record ${i}, trashed ${j}: missing type`, }; } if (typeof mod.trashedAt !== "string") { return { valid: false, error: `Record ${i}, trashed ${j}: missing trashedAt`, }; } } } return { valid: true, data: parsed as ExportData }; } catch (e) { return { valid: false, error: `JSON parse error: ${e}` }; } } /** * Create a module from imported data * Returns the charm instance or null if type is unknown * Throws if module creation fails */ function createModuleFromData( type: string, data: Record, recordPatternJson: string, ): unknown | null { // Special handling for notes - needs embedded flag and linkPattern if (type === "notes") { // Type-safe content extraction let content = ""; if (typeof data.content === "string") { content = data.content; } else if (typeof data.notes === "string") { // Fallback for legacy field name content = data.notes; } // Silently use empty string for non-string values const note = Note({ content, embedded: true, linkPattern: recordPatternJson, // deno-lint-ignore no-explicit-any } as any); if (!note) { throw new Error("Note constructor returned null/undefined"); } return note; } // Check if type is known const def = getDefinition(type); if (!def) { return null; // Unknown type - handled by caller } // Coerce data types to match schema const coercedData = coerceDataTypes(type, data); // Create module with imported data const charm = createSubCharm(type, coercedData); if (!charm) { throw new Error(`createSubCharm for "${type}" returned null/undefined`); } return charm; } /** * Handler to import records from JSON */ const importRecords = handler< Record, { importJson: Cell; allCharms: Cell; importResult: Cell; } >((_, { importJson, allCharms, importResult }) => { const jsonText = importJson.get(); const parseResult = parseImportJson(jsonText); if (!parseResult.valid || !parseResult.data) { importResult.set({ success: false, imported: 0, failed: 0, errors: [{ record: "", module: "", error: parseResult.error || "Unknown error", }], }); return; } const exportData = parseResult.data; const result: ImportResult = { success: true, imported: 0, failed: 0, errors: [], }; // Get Record pattern JSON for wiki-links in Notes const recordPatternJson = JSON.stringify(Record); // Create all records const createdRecords: unknown[] = []; for (const recordData of exportData.records) { try { // Create modules for this record const subCharms: SubCharmEntry[] = []; for (const moduleData of recordData.modules) { try { const charm = createModuleFromData( moduleData.type, moduleData.data, recordPatternJson, ); if (charm === null) { // Unknown module type - skip with warning result.errors.push({ record: recordData.title, module: moduleData.type, error: `Unknown module type: ${moduleData.type}`, }); result.failed++; continue; } subCharms.push({ type: moduleData.type, pinned: moduleData.pinned, charm, }); } catch (e) { result.errors.push({ record: recordData.title, module: moduleData.type, error: String(e), }); result.failed++; } } // Create trashed modules const trashedSubCharms: TrashedSubCharmEntry[] = []; for (const moduleData of recordData.trashedModules) { try { const charm = createModuleFromData( moduleData.type, moduleData.data, recordPatternJson, ); if (charm === null) { result.errors.push({ record: recordData.title, module: `${moduleData.type} (trashed)`, error: `Unknown module type: ${moduleData.type}`, }); result.failed++; continue; } trashedSubCharms.push({ type: moduleData.type, pinned: moduleData.pinned, charm, trashedAt: moduleData.trashedAt, }); } catch (e) { result.errors.push({ record: recordData.title, module: `${moduleData.type} (trashed)`, error: String(e), }); result.failed++; } } // Create the Record with all its modules // deno-lint-ignore no-explicit-any const record = (Record as any)({ title: recordData.title, subCharms: subCharms, trashedSubCharms: trashedSubCharms, }); // Push to allCharms to persist allCharms.push(record as RecordCharm); createdRecords.push(record); result.imported++; } catch (e) { result.errors.push({ record: recordData.title, module: "", error: String(e), }); result.success = false; } } // Update result if (result.failed > 0) { result.success = result.imported > 0; // Partial success } importResult.set(result); // Clear import field on success if (result.imported > 0) { importJson.set(""); } // Navigate to first imported record if (createdRecords.length > 0) { return navigateTo(createdRecords[0]); } }); /** * Handler to clear import result */ const clearImportResult = handler< Record, { importResult: Cell } >((_, { importResult }) => { importResult.set(null); }); /** * Handler to process uploaded file */ const handleFileUpload = handler< { detail: { files: Array<{ data: string; name: string }> } }, { importJson: Cell } >(({ detail }, { importJson }) => { const files = detail?.files; if (!files || files.length === 0) return; const file = files[0]; // data is a data URL, need to extract the JSON content const dataUrl = file.data; const base64Match = dataUrl.match(/base64,(.+)/); if (base64Match) { try { const jsonContent = atob(base64Match[1]); importJson.set(jsonContent); } catch (e) { console.error("Failed to decode file:", e); } } }); // ===== The Pattern ===== export default pattern(({ importJson }) => { // Get all charms in the space const { allCharms } = wish<{ allCharms: RecordCharm[] }>("/"); // Build export data const exportData = buildExportData({ allCharms }); const exportedJson = formatExportJson({ exportData }); const recordCount = countRecords({ exportData }); // Import result state const importResult = Cell.of(null); // Computed values for import result display using lift const hasImportResult = lift( ({ r }: { r: ImportResult | null }) => r !== null, )({ r: importResult }); const importResultBg = lift(({ r }: { r: ImportResult | null }) => r?.success ? "#f0fdf4" : "#fef2f2" )({ r: importResult }); const importResultBorder = lift(({ r }: { r: ImportResult | null }) => `1px solid ${r?.success ? "#86efac" : "#fca5a5"}` )({ r: importResult }); const importResultTitle = lift(({ r }: { r: ImportResult | null }) => r?.success ? "Import Complete" : "Import Issues" )({ r: importResult }); const importResultMessage = lift(({ r }: { r: ImportResult | null }) => { if (!r) return ""; const msg = `Imported ${r.imported || 0} record(s)`; if (r.failed > 0) { return `${msg}, ${r.failed} module(s) failed`; } return msg; })({ r: importResult }); return { [NAME]: computed(() => `Record Backup (${recordCount} records)`), [UI]: (
Record Backup
{/* Export Section */}

Export Records

Found {recordCount}{" "} records in this space. Copy the JSON below to save your data.

Download Backup
{/* Import Section */}

Import Records

Upload a backup file or paste JSON to restore your records.

Import Records {/* Import Result Display */} {ifElse( hasImportResult,
{importResultTitle}

{importResultMessage}

Dismiss
, null, )}
), exportedJson, importJson, recordCount, importResult, }; });