- Vergleichsoperatoren (= <> > < >= <=) unterscheiden jetzt Text- von Zahlenvergleich statt jede Seite zwangsweise durch toNumber() zu schicken (=A1="Nord" gab vorher #VALUE! statt 1); ermöglicht Text-Kriterien in selbstgebauten COUNTIF/SUMIF/AVERAGEIF-Funktionen (neue README-Sektion "SUMIF/COUNTIF/AVERAGEIF nachbauen"). - Neuer &-Operator (Excel-Präzedenz zwischen Vergleich und +/-) plus CONCAT/CONCATENATE als Funktions-Äquivalent. - Benannte Bereiche: neuer Toolbar-Button "🏷" verwaltet dokumentweite Namen für eine feste Zelle/einen festen Bereich (z. B. Umsatz_Q1statt Umsatz!B2:B6), unqualifiziert in jeder Top-Level-Formel nutzbar. Live gegen die Tabellen-ID aufgelöst (übersteht Tabellen-Umbenennung), bewusst nicht innerhalb einer benutzerdefinierten Funktion selbst nutzbar (Aufrufer können sie aber als Argument übergeben). - Nebenbei: COUNT2s Autovervollständigungs-Hint korrigiert (zeigte "COUNT(...)" statt "COUNT2(...)"). 374 Tests, vue-tsc/eslint/build sauber, Playwright-live-verifiziert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
479 lines
17 KiB
TypeScript
479 lines
17 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 {
|
|
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<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;
|
|
}
|
|
|
|
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>();
|
|
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))
|
|
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<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: [],
|
|
};
|
|
}
|
|
|
|
// ---- (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 };
|
|
});
|
|
}
|