- Help dialog (?, F1, Datei > Hilfe): user topics, all shortcuts and the formula functions with one search over everything. Topics and shortcuts are plain data in data/help.ts; the function list comes from codeReference.ts so new built-ins appear by themselves. - While a formula is edited, the grid outlines each referenced cell/range in the colour of its position in the text, and the outline can be dragged to move or resize the reference, as in Excel. Point-mode picks (click, drag, arrow keys) are outlined too, so keyboard picking is no longer blind. - Underline as a cell format, plus Mod+Shift+B/I/U toggles that switch on unless every selected cell already has the format. - A formula committed with open parentheses or an open text literal gets the missing closers appended; anything that still doesn't parse is left alone so the usual error shows instead of a guess. - Cells and tables with a fill but no text colour get dark or light text by WCAG contrast instead of the theme colour (no black on black).
560 lines
20 KiB
TypeScript
560 lines
20 KiB
TypeScript
/**
|
|
* 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 {
|
|
ButtonObject,
|
|
CellCoord,
|
|
CellFormat,
|
|
ChartObject,
|
|
CustomFunction,
|
|
DocumentState,
|
|
HAlign,
|
|
Macro,
|
|
NamedRange,
|
|
Sheet,
|
|
SheetObject,
|
|
TableObject,
|
|
TableView,
|
|
TextObject,
|
|
} from '../store/types';
|
|
import { defaultTableView, MIN_GRID_SIZE, MAX_GRID_SIZE } 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<string>): 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;
|
|
}
|
|
|
|
/** Defaults to `[]` -- absent in every document saved before this feature
|
|
* existed. Unlike a named range, a macro carries no `tableId`/other
|
|
* document-internal reference to validate against -- its body is a plain
|
|
* string, parsed and validated lazily (store/document.ts's `validateMacro`)
|
|
* only when actually edited/run, same lenient convention as a custom
|
|
* function's `body` (an import doesn't re-run every save-time check, only
|
|
* shape validation). */
|
|
function normaliseMacros(x: unknown): Macro[] {
|
|
if (!Array.isArray(x)) return [];
|
|
const out: Macro[] = [];
|
|
for (const m of x) {
|
|
if (
|
|
!m ||
|
|
typeof m !== 'object' ||
|
|
typeof (m as { id?: unknown }).id !== 'string' ||
|
|
typeof (m as { name?: unknown }).name !== 'string' ||
|
|
typeof (m as { body?: unknown }).body !== 'string'
|
|
) {
|
|
continue;
|
|
}
|
|
const rec = m as Macro;
|
|
const approvedHosts =
|
|
Array.isArray(rec.approvedHosts) && rec.approvedHosts.every((h) => typeof h === 'string')
|
|
? rec.approvedHosts
|
|
: undefined;
|
|
out.push({ id: rec.id, name: rec.name, body: rec.body, ...(approvedHosts ? { approvedHosts } : {}) });
|
|
}
|
|
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 gridSizeOrUndef(x: unknown): number | undefined {
|
|
return typeof x === 'number' && Number.isFinite(x)
|
|
? Math.round(Math.min(MAX_GRID_SIZE, Math.max(MIN_GRID_SIZE, x)))
|
|
: 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;
|
|
if (f.underline === true) out.underline = 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 isButtonObject(x: unknown): x is ButtonObject {
|
|
const o = x as ButtonObject;
|
|
return (
|
|
!!o &&
|
|
o.kind === 'button' &&
|
|
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.label === 'string'
|
|
// macroId is intentionally NOT checked against a real macro here --
|
|
// same lenient "dangling id renders as unassigned" convention as a
|
|
// chart's sourceTableId, see ButtonObject's doc comment.
|
|
);
|
|
}
|
|
|
|
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),
|
|
hideChromeUnfocused: boolOrUndef(o.hideChromeUnfocused),
|
|
};
|
|
}
|
|
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),
|
|
};
|
|
}
|
|
if (o.kind === 'button') {
|
|
return {
|
|
kind: 'button',
|
|
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(60, Math.round(o.width)),
|
|
height: Math.max(28, Math.round(o.height)),
|
|
label: String(o.label ?? ''),
|
|
macroId: typeof o.macroId === 'string' ? o.macroId : null,
|
|
bg: colorOrUndef(o.bg),
|
|
fg: colorOrUndef(o.fg),
|
|
};
|
|
}
|
|
if (o.kind === 'chart') {
|
|
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),
|
|
};
|
|
}
|
|
// Every SheetObject kind is handled above -- reachable only if a new kind
|
|
// is added to the union without a branch here (a compile-time error at
|
|
// the call site would already catch that; this is the runtime backstop).
|
|
throw new JsonImportError('Unbekannte Objektart.');
|
|
}
|
|
|
|
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>();
|
|
const tableIds = 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) && !isButtonObject(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),
|
|
gridSize: gridSizeOrUndef(s.gridSize),
|
|
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),
|
|
macros: normaliseMacros(doc.macros),
|
|
};
|
|
}
|
|
|
|
// ---- 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: ',',
|
|
customFunctions: [],
|
|
namedRanges: [],
|
|
macros: [],
|
|
};
|
|
}
|
|
|
|
// ---- (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 };
|
|
});
|
|
}
|