/** * Native JSON format -- full-fidelity, lossless round-trip. * Contains every sheet, every table/chart object with position & size, raw * formulas, column widths, tab order, the active object and the cell selection. * No library: `JSON.stringify` / `JSON.parse` plus schema validation. * * Version 1 documents (one grid per sheet) are migrated to version 2 * (canvas + objects) on import. */ import type { CellCoord, CellFormat, ChartObject, CustomFunction, DocumentState, HAlign, NamedRange, Sheet, SheetObject, TableObject, TableView, TextObject, } from '../store/types'; import { defaultTableView } from '../store/types'; import { DEFAULT_LOCALE, isCellNumFmt, isNumberLocale, type NumberLocale, } from '../store/format'; /** Keep a valid locale override, drop anything else (undefined = inherit). */ function localeOrUndef(x: unknown): NumberLocale | undefined { return isNumberLocale(x) ? x : undefined; } /** Defaults to `[]` -- absent in every document saved before this feature * existed (and in v1 documents entirely) -- and drops any malformed entry * rather than rejecting the whole import over one bad function. */ function normaliseCustomFunctions(x: unknown): CustomFunction[] { if (!Array.isArray(x)) return []; const out: CustomFunction[] = []; for (const f of x) { if ( !f || typeof f !== 'object' || typeof (f as { id?: unknown }).id !== 'string' || typeof (f as { name?: unknown }).name !== 'string' || typeof (f as { body?: unknown }).body !== 'string' || !Array.isArray((f as { params?: unknown }).params) || !(f as { params: unknown[] }).params.every((p) => typeof p === 'string') ) { continue; } const rec = f as CustomFunction; const kind = rec.kind === 'block' ? 'block' : 'expression'; // default for anything missing/invalid out.push({ id: rec.id, name: rec.name, params: [...rec.params], body: rec.body, kind }); } return out; } function isCellCoord(x: unknown): x is CellCoord { return ( !!x && typeof x === 'object' && typeof (x as CellCoord).col === 'number' && typeof (x as CellCoord).row === 'number' ); } /** Defaults to `[]` -- absent in every document saved before this feature * existed. Drops a malformed entry, and any entry whose `tableId` doesn't * match a real table *in this document* (a dangling reference -- e.g. a * hand-edited file, or a table that no longer exists) -- rather than * leaving it to resolve to `#NAME?` forever; the live "delete a table" * path (`document.ts`) does leave such entries in place, for consistency * with how a deleted table's ordinary formula references are never * auto-pruned either -- this is stricter only because *importing* JSON is * already a validating boundary for everything else in the document. */ function normaliseNamedRanges(x: unknown, tableIds: ReadonlySet): NamedRange[] { if (!Array.isArray(x)) return []; const out: NamedRange[] = []; for (const r of x) { if ( !r || typeof r !== 'object' || typeof (r as { id?: unknown }).id !== 'string' || typeof (r as { name?: unknown }).name !== 'string' || typeof (r as { tableId?: unknown }).tableId !== 'string' || !isCellCoord((r as { start?: unknown }).start) || !isCellCoord((r as { end?: unknown }).end) ) { continue; } const rec = r as NamedRange; if (!tableIds.has(rec.tableId)) continue; out.push({ id: rec.id, name: rec.name, tableId: rec.tableId, start: { ...rec.start }, end: { ...rec.end } }); } return out; } const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; function colorOrUndef(x: unknown): string | undefined { return typeof x === 'string' && HEX_COLOR.test(x) ? x : undefined; } function boolOrUndef(x: unknown): boolean | undefined { return typeof x === 'boolean' ? x : undefined; } function zoomOrUndef(x: unknown): number | undefined { return typeof x === 'number' && Number.isFinite(x) ? Math.min(2, Math.max(0.4, Math.round(x * 20) / 20)) : undefined; } function clampNum(x: unknown, lo: number, hi: number, fallback: number): number { return typeof x === 'number' && Number.isFinite(x) ? Math.min(hi, Math.max(lo, Math.round(x))) : fallback; } const H_ALIGNS = new Set(['left', 'center', 'right']); function alignOrUndef(x: unknown): HAlign | undefined { return H_ALIGNS.has(x as HAlign) ? (x as HAlign) : undefined; } /** Validate + trim one CellFormat; returns undefined if nothing valid remains. */ function normaliseCellFormat(x: unknown): CellFormat | undefined { if (!x || typeof x !== 'object') return undefined; const f = x as CellFormat; const out: CellFormat = {}; if (alignOrUndef(f.align)) out.align = f.align; if (f.valign === 'top' || f.valign === 'middle' || f.valign === 'bottom') out.valign = f.valign; if (isCellNumFmt(f.numFmt) && f.numFmt !== 'auto') out.numFmt = f.numFmt; if (colorOrUndef(f.color)) out.color = f.color; if (colorOrUndef(f.bg)) out.bg = f.bg; if (f.bold === true) out.bold = true; if (f.italic === true) out.italic = true; return Object.keys(out).length ? out : undefined; } function normaliseFormats(x: unknown): Record | undefined { if (!x || typeof x !== 'object') return undefined; const out: Record = {}; for (const [k, v] of Object.entries(x as Record)) { if (!/^\d+,\d+$/.test(k)) continue; const f = normaliseCellFormat(v); if (f) out[k] = f; } return Object.keys(out).length ? out : undefined; } function normaliseRowHeights(x: unknown): Record | undefined { if (!x || typeof x !== 'object') return undefined; const out: Record = {}; for (const [k, v] of Object.entries(x as Record)) { if (!/^\d+$/.test(k)) continue; if (typeof v === 'number' && Number.isFinite(v)) { out[Number(k)] = Math.min(240, Math.max(14, Math.round(v))); } } return Object.keys(out).length ? out : undefined; } function coord(x: unknown): { col: number; row: number } | null { const c = x as { col?: unknown; row?: unknown }; if (!c || typeof c !== 'object') return null; if (typeof c.col !== 'number' || typeof c.row !== 'number') return null; return { col: Math.max(0, Math.floor(c.col)), row: Math.max(0, Math.floor(c.row)) }; } function normaliseSelection(x: unknown): DocumentState['selection'] { const s = x as { tableId?: unknown; anchor?: unknown; focus?: unknown; ranges?: unknown }; const anchor = coord(s.anchor); const focus = coord(s.focus); if (typeof s.tableId !== 'string' || !anchor || !focus) return null; const ranges = Array.isArray(s.ranges) ? s.ranges .map((r) => { const a = coord((r as { anchor?: unknown }).anchor); const f = coord((r as { focus?: unknown }).focus); return a && f ? { anchor: a, focus: f } : null; }) .filter((r): r is { anchor: { col: number; row: number }; focus: { col: number; row: number } } => !!r) : []; return { tableId: s.tableId, anchor, focus, ranges: ranges.length ? ranges : undefined }; } function numMapOrUndef(x: unknown): Record | undefined { if (!x || typeof x !== 'object') return undefined; const out: Record = {}; for (const [k, v] of Object.entries(x as Record)) { if (/^\d+$/.test(k) && typeof v === 'number' && Number.isFinite(v)) { out[Number(k)] = Math.max(20, Math.round(v)); } } return Object.keys(out).length ? out : undefined; } export class JsonImportError extends Error {} // ---- v2 validation ---------------------------------------------------- function isRecordOfString(x: unknown): x is Record { return ( !!x && typeof x === 'object' && Object.values(x as Record).every((v) => typeof v === 'string') ); } function isTableObject(x: unknown): x is TableObject { const o = x as TableObject; return ( !!o && o.kind === 'table' && typeof o.id === 'string' && typeof o.name === 'string' && Number.isFinite(o.x) && Number.isFinite(o.y) && Number.isFinite(o.rows) && Number.isFinite(o.cols) && !!o.colWidths && typeof o.colWidths === 'object' && isRecordOfString(o.cells) && Object.keys(o.cells).every((k) => /^\d+,\d+$/.test(k)) ); } function isChartObject(x: unknown): x is ChartObject { const o = x as ChartObject; return ( !!o && o.kind === 'chart' && typeof o.id === 'string' && typeof o.name === 'string' && Number.isFinite(o.x) && Number.isFinite(o.y) && Number.isFinite(o.width) && Number.isFinite(o.height) && (o.view === 'bar' || o.view === 'line') ); } function isTextObject(x: unknown): x is TextObject { const o = x as TextObject; return ( !!o && o.kind === 'text' && typeof o.id === 'string' && typeof o.name === 'string' && Number.isFinite(o.x) && Number.isFinite(o.y) && Number.isFinite(o.width) && Number.isFinite(o.height) && typeof o.text === 'string' ); } function normaliseTableView(v: Partial | undefined, rows: number, cols: number): TableView { const d = defaultTableView(); const mode = v?.mode === 'fixed' ? 'fixed' : 'auto'; return { mode, width: Number.isFinite(v?.width) ? Math.max(200, Math.round(v!.width!)) : d.width, height: Number.isFinite(v?.height) ? Math.max(120, Math.round(v!.height!)) : d.height, frozenRows: Math.min(Math.max(0, Math.floor(v?.frozenRows ?? 0)), Math.max(0, rows - 1)), frozenCols: Math.min(Math.max(0, Math.floor(v?.frozenCols ?? 0)), Math.max(0, cols - 1)), }; } function normaliseObject(o: SheetObject): SheetObject { if (o.kind === 'table') { const rows = Math.max(1, Math.floor(o.rows)); const cols = Math.max(1, Math.floor(o.cols)); return { kind: 'table', id: o.id, name: o.name, x: Math.max(0, Math.round(o.x)), y: Math.max(0, Math.round(o.y)), rows, cols, colWidths: numMapOrUndef(o.colWidths) ?? {}, rowHeight: typeof o.rowHeight === 'number' && Number.isFinite(o.rowHeight) ? Math.min(240, Math.max(14, Math.round(o.rowHeight))) : undefined, rowHeights: normaliseRowHeights(o.rowHeights), cells: { ...o.cells }, formats: normaliseFormats(o.formats), view: normaliseTableView(o.view, rows, cols), locale: localeOrUndef(o.locale), bg: colorOrUndef(o.bg), fg: colorOrUndef(o.fg), }; } if (o.kind === 'text') { return { kind: 'text', id: o.id, name: o.name, x: Math.max(0, Math.round(o.x)), y: Math.max(0, Math.round(o.y)), width: Math.max(80, Math.round(o.width)), height: Math.max(32, Math.round(o.height)), text: String(o.text ?? ''), fontSize: clampNum(o.fontSize, 9, 96, 15), align: alignOrUndef(o.align) ?? 'left', bold: o.bold === true || undefined, italic: o.italic === true || undefined, color: colorOrUndef(o.color), bg: colorOrUndef(o.bg), }; } return { kind: 'chart', id: o.id, name: o.name, x: Math.max(0, Math.round(o.x)), y: Math.max(0, Math.round(o.y)), width: Math.max(200, Math.round(o.width)), height: Math.max(140, Math.round(o.height)), view: o.view, sourceTableId: o.sourceTableId ?? null, locale: localeOrUndef(o.locale), bg: colorOrUndef(o.bg), fg: colorOrUndef(o.fg), }; } export function validateDocument(x: unknown): DocumentState { if (!x || typeof x !== 'object') throw new JsonImportError('Keine gültige JSON-Struktur.'); const d = x as { version?: unknown }; if (d.version === 1) return migrateToV2(x); if (d.version !== 2) throw new JsonImportError(`Nicht unterstützte Version: ${String(d.version)}`); const doc = x as DocumentState; if (!Array.isArray(doc.sheets) || doc.sheets.length === 0) throw new JsonImportError('Dokument enthält keine Blätter.'); const sheetIds = new Set(); const objectIds = new Set(); const tableIds = new Set(); for (const s of doc.sheets) { if (!s || typeof s.id !== 'string' || typeof s.name !== 'string' || !Array.isArray(s.objects)) throw new JsonImportError('Mindestens ein Blatt ist fehlerhaft.'); if (sheetIds.has(s.id)) throw new JsonImportError('Doppelte Blatt-ID.'); sheetIds.add(s.id); for (const o of s.objects) { if (!isTableObject(o) && !isChartObject(o) && !isTextObject(o)) throw new JsonImportError('Mindestens ein Objekt ist fehlerhaft.'); if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.'); objectIds.add(o.id); if (isTableObject(o)) tableIds.add(o.id); } } if (typeof doc.activeSheetId !== 'string' || !sheetIds.has(doc.activeSheetId)) throw new JsonImportError('activeSheetId zeigt auf kein vorhandenes Blatt.'); return { version: 2, sheets: doc.sheets.map((s) => ({ id: s.id, name: s.name, objects: s.objects.map(normaliseObject), locale: localeOrUndef(s.locale), background: colorOrUndef(s.background), foreground: colorOrUndef(s.foreground), showGrid: boolOrUndef(s.showGrid), zoom: zoomOrUndef(s.zoom), })), activeSheetId: doc.activeSheetId, activeObjectId: doc.activeObjectId && objectIds.has(doc.activeObjectId) ? doc.activeObjectId : null, selection: doc.selection && objectIds.has(doc.selection.tableId) ? normaliseSelection(doc.selection) : null, locale: isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE, csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',', customFunctions: normaliseCustomFunctions(doc.customFunctions), namedRanges: normaliseNamedRanges(doc.namedRanges, tableIds), }; } // ---- v1 -> v2 migration -------------------------------------------- interface V1Sheet { id: string; name: string; rows: number; cols: number; colWidths?: Record; cells: Record; } interface V1Doc { version: 1; sheets: V1Sheet[]; activeSheetId: string; selection?: { sheetId: string; anchor: { col: number; row: number }; focus: { col: number; row: number } } | null; } let migSeq = 0; const migId = (p: string) => `${p}-mig-${Date.now().toString(36)}-${migSeq++}`; export function migrateToV2(x: unknown): DocumentState { const v1 = x as V1Doc; if (!Array.isArray(v1.sheets) || v1.sheets.length === 0) throw new JsonImportError('v1-Dokument enthält keine Tabellen.'); const sheetIdMap = new Map(); const sheets: Sheet[] = v1.sheets.map((s) => { const sheetId = migId('sheet'); const tableId = migId('tbl'); sheetIdMap.set(s.id, { sheetId, tableId }); const table: TableObject = { kind: 'table', id: tableId, name: s.name, // keep the name -> cross-sheet formulas still resolve x: 40, y: 40, rows: Math.max(1, Math.floor(s.rows ?? 20)), cols: Math.max(1, Math.floor(s.cols ?? 8)), colWidths: { ...(s.colWidths ?? {}) }, cells: { ...(s.cells ?? {}) }, view: defaultTableView(), }; return { id: sheetId, name: s.name, objects: [table] }; }); const active = sheetIdMap.get(v1.activeSheetId) ?? [...sheetIdMap.values()][0]; const selSheet = v1.selection ? sheetIdMap.get(v1.selection.sheetId) : undefined; return { version: 2, sheets, activeSheetId: active.sheetId, activeObjectId: active.tableId, selection: v1.selection && selSheet ? { tableId: selSheet.tableId, anchor: v1.selection.anchor, focus: v1.selection.focus } : null, locale: DEFAULT_LOCALE, csvDelimiter: ',', customFunctions: [], namedRanges: [], }; } // ---- (de)serialise ------------------------------------------------ export function serializeDocument(doc: DocumentState): string { return JSON.stringify(doc, null, 2); } export function parseDocument(text: string): DocumentState { let data: unknown; try { data = JSON.parse(text); } catch { throw new JsonImportError('Datei ist kein gültiges JSON.'); } return validateDocument(data); } /** Give imported sheets & their objects fresh ids so they can be appended. */ export function reidSheets(doc: DocumentState): Sheet[] { return doc.sheets.map((s, i) => { const idMap = new Map(); const objects = s.objects.map((o, j) => { const nid = `imp-${Date.now().toString(36)}-${i}-${j}`; idMap.set(o.id, nid); return { ...o, id: nid } as SheetObject; }); // repoint chart sources that referenced a sibling table in the same import for (const o of objects) { if (o.kind === 'chart' && o.sourceTableId && idMap.has(o.sourceTableId)) { o.sourceTableId = idMap.get(o.sourceTableId)!; } } return { id: `imp-sheet-${Date.now().toString(36)}-${i}`, name: s.name, objects }; }); }