- init
This commit is contained in:
+404
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* 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 {
|
||||
CellFormat,
|
||||
ChartObject,
|
||||
DocumentState,
|
||||
HAlign,
|
||||
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;
|
||||
}
|
||||
|
||||
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<HAlign>(['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<string, CellFormat> | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const out: Record<string, CellFormat> = {};
|
||||
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
|
||||
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<number, number> | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const out: Record<number, number> = {};
|
||||
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
|
||||
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<number, number> | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const out: Record<number, number> = {};
|
||||
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
|
||||
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<string, string> {
|
||||
return (
|
||||
!!x &&
|
||||
typeof x === 'object' &&
|
||||
Object.values(x as Record<string, unknown>).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<TableView> | 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<string>();
|
||||
const objectIds = new Set<string>();
|
||||
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 (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 === ';' ? ';' : ',',
|
||||
};
|
||||
}
|
||||
|
||||
// ---- v1 -> v2 migration --------------------------------------------
|
||||
|
||||
interface V1Sheet {
|
||||
id: string;
|
||||
name: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
colWidths?: Record<number, number>;
|
||||
cells: Record<string, string>;
|
||||
}
|
||||
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<string, { sheetId: string; tableId: string }>();
|
||||
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: ',',
|
||||
};
|
||||
}
|
||||
|
||||
// ---- (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<string, string>();
|
||||
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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user