This commit is contained in:
wkh
2026-08-29 17:47:12 +02:00
commit c7885067e7
53 changed files with 13458 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
/**
* Import/export acceptance checks at the pure (de)serialisation layer -- the
* `<a download>` glue is trivial and browser-only, so it is not exercised here.
*/
import { describe, expect, it } from 'vitest';
import { useDocument, type DocumentStore } from '../../store/document';
import { isError } from '../../engine';
import { serializeDocument, parseDocument, JsonImportError } from '../json';
import { tableToCsv, parseCsv, matrixToCells } from '../csv';
import { importJsonReplace } from '../operations';
function fresh(): DocumentStore {
const s = useDocument();
s.resetToSeed();
return s;
}
function tableId(s: DocumentStore, name: string): string {
for (const sh of s.doc.sheets) {
const o = sh.objects.find((x) => x.kind === 'table' && x.name === name);
if (o) return o.id;
}
throw new Error(name);
}
const coords = (a1: string) => {
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
return {
col: m[1].split('').reduce((n, c) => n * 26 + (c.charCodeAt(0) - 64), 0) - 1,
row: Number(m[2]) - 1,
};
};
describe('JSON native format', () => {
it('round-trips a document losslessly (export → reset → import → identical)', () => {
const s = fresh();
const before = s.snapshot();
const json = serializeDocument(before);
s.addSheet('scratch');
s.setCellInput(tableId(s, 'Umsatz'), 1, 1, '999999');
s.replaceDocument(parseDocument(json));
expect(s.snapshot()).toEqual(before);
});
it('rejects corrupt / empty input and leaves the document untouched', () => {
const s = fresh();
const before = s.snapshot();
expect(() => importJsonReplace(s, '{ not json')).toThrow(JsonImportError);
expect(() => importJsonReplace(s, JSON.stringify({ version: 2, sheets: [] }))).toThrow(
JsonImportError,
);
expect(() => importJsonReplace(s, JSON.stringify({ version: 7 }))).toThrow(JsonImportError);
expect(s.snapshot()).toEqual(before);
});
it('an unknown table name in an imported formula yields #REF, no crash', () => {
const s = fresh();
const doc = {
version: 2,
sheets: [
{
id: 's1',
name: 'S1',
objects: [
{
kind: 'table',
id: 't1',
name: 'T1',
x: 0,
y: 0,
rows: 4,
cols: 4,
colWidths: {},
cells: { '0,0': '=Nirgends!A1', '1,0': '=A1+1' },
},
],
},
],
activeSheetId: 's1',
activeObjectId: null,
selection: null,
};
importJsonReplace(s, JSON.stringify(doc));
const a1 = s.cellValue('t1', 0, 0);
const b1 = s.cellValue('t1', 1, 0);
expect(isError(a1) && a1.code).toBe('#REF');
expect(isError(b1) && b1.code).toBe('#REF'); // error propagates
});
it('normalises a v2 doc saved by an older build (missing table.view)', () => {
const stale = {
version: 2,
sheets: [
{
id: 's1',
name: 'S1',
objects: [
// no `view` key -- as written before the frozen-panes feature
{ kind: 'table', id: 't1', name: 'T1', x: 10, y: 10, rows: 8, cols: 5, colWidths: {}, cells: {} },
],
},
],
activeSheetId: 's1',
activeObjectId: null,
selection: null,
};
const doc = parseDocument(JSON.stringify(stale));
const t = doc.sheets[0].objects[0];
expect(t.kind).toBe('table');
if (t.kind === 'table') {
expect(t.view).toEqual({ mode: 'auto', width: 640, height: 320, frozenRows: 0, frozenCols: 0 });
}
});
it('migrates a v1 document on import', () => {
const s = fresh();
const v1 = JSON.stringify({
version: 1,
sheets: [
{ id: 'a', name: 'Zahlen', rows: 5, cols: 3, colWidths: {}, cells: { '0,0': '10', '0,1': '=A1*3' } },
],
activeSheetId: 'a',
selection: null,
});
importJsonReplace(s, v1);
expect(s.doc.version).toBe(2);
const id = tableId(s, 'Zahlen');
expect(s.cellValue(id, 0, 1)).toBe(30);
});
});
describe('CSV', () => {
it('exports computed values of "Übersicht" and re-imports to the same numbers', () => {
const s = fresh();
const src = tableId(s, 'Übersicht');
const csv = tableToCsv(s, src, { delimiter: ',', bom: false, crlf: true });
expect(csv.includes('\r\n')).toBe(true);
// header row is text, body carries the computed numbers
expect(csv.split('\r\n')[0]).toBe('Region,Umsatz Jahr,Kosten Jahr,Deckungsbeitrag,Marge %');
const { cells, rows, cols } = matrixToCells(parseCsv(csv, ','), true);
const copyId = s.addTableFromCells(s.doc.sheets[1].id, 'Kopie', cells, rows, cols);
for (const a1 of ['B2', 'C2', 'D2', 'B8', 'C8', 'D8']) {
const { col, row } = coords(a1);
expect(s.cellValue(copyId, col, row)).toBe(s.cellValue(src, col, row));
}
});
it('RFC-4180 quoting: a field with a comma is wrapped and survives the round-trip', () => {
const s = fresh();
const id = s.addTable(s.doc.sheets[1].id);
s.setCellInput(id, 0, 0, 'Müller, Hans');
s.setCellInput(id, 1, 0, 'x"y');
const csv = tableToCsv(s, id, { delimiter: ',', bom: false, crlf: true });
expect(csv).toContain('"Müller, Hans"');
expect(csv).toContain('"x""y"');
const [line] = parseCsv(csv, ',');
expect(line).toEqual(['Müller, Hans', 'x"y']);
});
it('a CSV cell "=1+2" becomes a formula that evaluates to 3', () => {
const s = fresh();
const { cells, rows, cols } = matrixToCells(parseCsv('=1+2\n', ','), true);
const id = s.addTableFromCells(s.doc.sheets[1].id, 'Rechnen', cells, rows, cols);
expect(s.cellValue(id, 0, 0)).toBe(3);
});
it('locale number detection (de vs en)', () => {
const { cells } = matrixToCells(parseCsv('1.234,56;7,89;1,234.50\n', ';'), true);
expect(cells['0,0']).toBe('1234.56');
expect(cells['1,0']).toBe('7.89');
expect(cells['2,0']).toBe('1234.50');
});
});
+193
View File
@@ -0,0 +1,193 @@
/**
* CSV import/export -- hand-written, no library.
*
* Export follows RFC 4180: fields containing the delimiter, a double quote, CR
* or LF are wrapped in double quotes and inner quotes are doubled. Line ending
* is CRLF, optional UTF-8 BOM for Excel. Delimiter is "," or ";".
*
* Import: a small state-machine parser that understands quoted fields with
* embedded delimiters / newlines, plus delimiter sniffing and de/en locale
* number normalisation. A leading "=" makes the cell a formula.
*/
import type { DocumentStore } from '../store/document';
import { rawString } from '../store/format';
export type Delimiter = ',' | ';' | '\t';
export interface CsvExportOptions {
delimiter: Delimiter;
bom: boolean;
crlf: boolean;
}
// ---- export --------------------------------------------------------------
function needsQuoting(field: string, delimiter: string): boolean {
return (
field.includes(delimiter) ||
field.includes('"') ||
field.includes('\n') ||
field.includes('\r')
);
}
function quoteField(field: string, delimiter: string): string {
if (!needsQuoting(field, delimiter)) return field;
return `"${field.replace(/"/g, '""')}"`;
}
/** Bounding box of the used area of a sheet. */
function usedBounds(cells: Record<string, string>): { rows: number; cols: number } {
let rows = 0;
let cols = 0;
for (const key of Object.keys(cells)) {
const [c, r] = key.split(',').map(Number);
if (c + 1 > cols) cols = c + 1;
if (r + 1 > rows) rows = r + 1;
}
return { rows, cols };
}
export function tableToCsv(
store: DocumentStore,
tableId: string,
opts: CsvExportOptions,
): string {
const table = store.tableById(tableId);
if (!table) return '';
const bounds = usedBounds(table.cells);
const rows = Math.max(bounds.rows, 1);
const cols = Math.max(bounds.cols, 1);
const eol = opts.crlf ? '\r\n' : '\n';
const lines: string[] = [];
for (let r = 0; r < rows; r++) {
const fields: string[] = [];
for (let c = 0; c < cols; c++) {
const value = rawString(store.cellValue(tableId, c, r));
fields.push(quoteField(value, opts.delimiter));
}
lines.push(fields.join(opts.delimiter));
}
return (opts.bom ? '' : '') + lines.join(eol) + eol;
}
// ---- import --------------------------------------------------------------
export function sniffDelimiter(text: string): Delimiter {
const firstLine = text.slice(0, text.search(/\r?\n/) >>> 0 || text.length);
const counts: Record<Delimiter, number> = { ',': 0, ';': 0, '\t': 0 };
let inQuotes = false;
for (const ch of firstLine) {
if (ch === '"') inQuotes = !inQuotes;
else if (!inQuotes && (ch === ',' || ch === ';' || ch === '\t')) counts[ch]++;
}
const best = (Object.keys(counts) as Delimiter[]).sort(
(a, b) => counts[b] - counts[a],
)[0];
return counts[best] > 0 ? best : ',';
}
/** RFC-4180-ish parser. Returns a matrix of raw string cells. */
export function parseCsv(text: string, delimiter: Delimiter): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = '';
let inQuotes = false;
let i = 0;
const n = text.length;
// strip BOM
if (text.charCodeAt(0) === 0xfeff) i = 1;
const endField = () => {
row.push(field);
field = '';
};
const endRow = () => {
endField();
rows.push(row);
row = [];
};
while (i < n) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') {
field += '"';
i += 2;
continue;
}
inQuotes = false;
i++;
continue;
}
field += ch;
i++;
continue;
}
if (ch === '"') {
inQuotes = true;
i++;
continue;
}
if (ch === delimiter) {
endField();
i++;
continue;
}
if (ch === '\r') {
if (text[i + 1] === '\n') i++;
endRow();
i++;
continue;
}
if (ch === '\n') {
endRow();
i++;
continue;
}
field += ch;
i++;
}
// trailing field / row (unless the file ended exactly on a newline)
if (field !== '' || row.length > 0) endRow();
return rows;
}
const DE_NUMBER = /^-?\d{1,3}(\.\d{3})+(,\d+)?$|^-?\d+,\d+$/;
const EN_NUMBER = /^-?\d{1,3}(,\d{3})+(\.\d+)?$|^-?\d+\.\d+$/;
/** Normalise a locale-formatted number to a plain JS number literal string. */
export function normalizeNumber(value: string): string {
const v = value.trim();
if (v === '' || v.startsWith('=')) return value;
if (/^-?\d+$/.test(v)) return v;
if (DE_NUMBER.test(v)) return v.replace(/\./g, '').replace(',', '.');
if (EN_NUMBER.test(v)) return v.replace(/,/g, '');
return value;
}
export interface CsvToCellsResult {
cells: Record<string, string>;
rows: number;
cols: number;
}
export function matrixToCells(
matrix: string[][],
detectNumbers = true,
): CsvToCellsResult {
const cells: Record<string, string> = {};
let cols = 0;
matrix.forEach((line, r) => {
if (line.length > cols) cols = line.length;
line.forEach((cell, c) => {
let raw = cell;
if (detectNumbers) raw = normalizeNumber(raw);
if (raw !== '') cells[`${c},${r}`] = raw;
});
});
return { cells, rows: matrix.length, cols: Math.max(cols, 1) };
}
+29
View File
@@ -0,0 +1,29 @@
/** Trigger a client-side file download from a Blob. */
export function downloadBlob(filename: string, blob: Blob): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// revoke on the next tick so the download has started
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export function downloadText(
filename: string,
text: string,
mime = 'text/plain;charset=utf-8',
): void {
downloadBlob(filename, new Blob([text], { type: mime }));
}
export function downloadDataUrl(filename: string, dataUrl: string): void {
const a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
}
+404
View File
@@ -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 };
});
}
+112
View File
@@ -0,0 +1,112 @@
/**
* High-level import/export operations. Each one is atomic: on any failure it
* throws and leaves the document untouched (the UI catches and shows a toast).
*/
import type { DocumentStore } from '../store/document';
import { downloadBlob, downloadText } from './download';
import { serializeDocument, parseDocument, reidSheets } from './json';
import {
parseCsv,
sniffDelimiter,
matrixToCells,
tableToCsv,
type Delimiter,
type CsvExportOptions,
} from './csv';
import { documentToXlsx, xlsxToSheets, importedToSheet } from './xlsx';
export function readAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
fr.onload = () => resolve(String(fr.result));
fr.readAsText(file);
});
}
export function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
fr.onload = () => resolve(fr.result as ArrayBuffer);
fr.readAsArrayBuffer(file);
});
}
export function extensionOf(name: string): string {
const i = name.lastIndexOf('.');
return i >= 0 ? name.slice(i + 1).toLowerCase() : '';
}
/** Yield to the event loop so a large parse doesn't freeze the UI. */
const breathe = () => new Promise((r) => setTimeout(r, 0));
// ---- export ---------------------------------------------------------
export function exportJson(store: DocumentStore): void {
downloadText('dokument.json', serializeDocument(store.snapshot()), 'application/json');
}
export function exportActiveTableCsv(store: DocumentStore, opts: CsvExportOptions): void {
const table = store.activeTable.value;
if (!table) throw new Error('Keine Tabelle ausgewählt.');
downloadText(`${table.name}.csv`, tableToCsv(store, table.id, opts), 'text/csv;charset=utf-8');
}
export async function exportXlsx(store: DocumentStore): Promise<void> {
downloadBlob('arbeitsmappe.xlsx', await documentToXlsx(store));
}
// ---- import: JSON ------------------------------------------------
export function importJsonReplace(store: DocumentStore, text: string): void {
store.replaceDocument(parseDocument(text)); // throws JsonImportError on bad schema
}
export function importJsonAppend(store: DocumentStore, text: string): void {
store.appendSheets(reidSheets(parseDocument(text)));
}
// ---- import: CSV -----------------------------------------------
export interface CsvImportConfig {
mode: 'new-table' | 'replace-active';
delimiter: Delimiter | 'auto';
detectNumbers: boolean;
name?: string;
}
export async function importCsv(
store: DocumentStore,
text: string,
cfg: CsvImportConfig,
): Promise<void> {
const delimiter: Delimiter = cfg.delimiter === 'auto' ? sniffDelimiter(text) : cfg.delimiter;
const matrix = parseCsv(text, delimiter);
if (matrix.length > 2000) await breathe();
const { cells, rows, cols } = matrixToCells(matrix, cfg.detectNumbers);
if (cfg.mode === 'replace-active') {
const table = store.activeTable.value;
if (!table) throw new Error('Keine Zieltabelle ausgewählt.');
store.replaceTableCells(table.id, cells, Math.max(rows, 12), Math.max(cols, 6));
} else {
store.addTableFromCells(
store.doc.activeSheetId,
cfg.name?.trim() || 'Import',
cells,
Math.max(rows, 12),
Math.max(cols, 6),
);
}
}
// ---- import: XLSX --------------------------------------------
export async function importXlsxFile(store: DocumentStore, buffer: ArrayBuffer): Promise<void> {
const imported = await xlsxToSheets(buffer);
if (imported.length === 0) throw new Error('Die Arbeitsmappe enthält keine Tabellen.');
await breathe();
store.appendSheets(imported.map((s, i) => importedToSheet(s, i)));
}
+183
View File
@@ -0,0 +1,183 @@
/**
* .xlsx import/export via SheetJS (the *only* place a spreadsheet library is
* used, and never for formula evaluation -- that stays in our engine).
*
* export: one worksheet per table. Formula cells are written as real Excel
* formulas when they translate cleanly (`;` -> `,`, `AVG` ->
* `AVERAGE`), always accompanied by the cached value from our engine.
* import: one table per worksheet. `cell.f` becomes a formula when our parser
* accepts it, otherwise the cached value is taken.
*/
import type * as XLSXNS from 'xlsx';
import { parseFormula } from '../engine';
import type { DocumentStore } from '../store/document';
import type { Sheet, TableObject } from '../store/types';
import { defaultTableView } from '../store/types';
import { rawString } from '../store/format';
/** SheetJS is the only spreadsheet library and it is loaded on demand. */
let xlsxModule: typeof XLSXNS | null = null;
async function loadXLSX(): Promise<typeof XLSXNS> {
if (!xlsxModule) xlsxModule = await import('xlsx');
return xlsxModule;
}
/** Swap top-level separators while respecting "..." and '...' quoting. */
function swapOutsideQuotes(src: string, from: string, to: string): string {
let out = '';
let q: '"' | "'" | null = null;
for (const ch of src) {
if (q) {
out += ch;
if (ch === q) q = null;
} else if (ch === '"' || ch === "'") {
q = ch;
out += ch;
} else if (ch === from) {
out += to;
} else {
out += ch;
}
}
return out;
}
function toExcelFormula(input: string): string | null {
// input starts with '='
let body = input.slice(1);
body = swapOutsideQuotes(body, ';', ',');
body = body.replace(/\bAVG\s*\(/gi, 'AVERAGE(');
return body;
}
function fromExcelFormula(f: string): string | null {
const body = swapOutsideQuotes(f, ',', ';');
const candidate = '=' + body;
try {
parseFormula(candidate);
return candidate;
} catch {
return null;
}
}
function sanitizeSheetName(name: string, taken: Set<string>): string {
const clean = name.replace(/[[\]:*?/\\]/g, ' ').trim().slice(0, 31) || 'Tabelle';
let candidate = clean;
let i = 2;
while (taken.has(candidate.toLowerCase())) {
candidate = `${clean.slice(0, 28)} ${i++}`;
}
taken.add(candidate.toLowerCase());
return candidate;
}
// ---- export -----------------------------------------------------------
export async function documentToXlsx(store: DocumentStore): Promise<Blob> {
const XLSX = await loadXLSX();
const wb = XLSX.utils.book_new();
const taken = new Set<string>();
// one worksheet per table object, across all sheets
const tables = store.doc.sheets.flatMap((s) =>
s.objects.filter((o): o is TableObject => o.kind === 'table'),
);
for (const table of tables) {
const ws: XLSXNS.WorkSheet = {};
let maxC = 0;
let maxR = 0;
for (const key of Object.keys(table.cells)) {
const [c, r] = key.split(',').map(Number);
const raw = table.cells[key];
const addr = XLSX.utils.encode_cell({ c, r });
const value = store.cellValue(table.id, c, r);
if (raw.startsWith('=')) {
const f = toExcelFormula(raw);
const num = typeof value === 'number' ? value : undefined;
ws[addr] =
num !== undefined
? { t: 'n', v: num, f: f ?? undefined }
: { t: 's', v: rawString(value), f: f ?? undefined };
} else if (typeof value === 'number') {
ws[addr] = { t: 'n', v: value };
} else {
ws[addr] = { t: 's', v: raw };
}
if (c > maxC) maxC = c;
if (r > maxR) maxR = r;
}
ws['!ref'] = XLSX.utils.encode_range({ s: { c: 0, r: 0 }, e: { c: maxC, r: maxR } });
XLSX.utils.book_append_sheet(wb, ws, sanitizeSheetName(table.name, taken));
}
const out = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer;
return new Blob([out], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
}
// ---- import -----------------------------------------------------------
export interface ImportedSheet {
name: string;
rows: number;
cols: number;
cells: Record<string, string>;
}
export async function xlsxToSheets(buffer: ArrayBuffer): Promise<ImportedSheet[]> {
const XLSX = await loadXLSX();
const wb = XLSX.read(buffer, { type: 'array', cellFormula: true, cellNF: false });
const result: ImportedSheet[] = [];
for (const name of wb.SheetNames) {
const ws = wb.Sheets[name];
const cells: Record<string, string> = {};
let rows = 1;
let cols = 1;
if (ws && ws['!ref']) {
const range = XLSX.utils.decode_range(ws['!ref']);
for (let r = range.s.r; r <= range.e.r; r++) {
for (let c = range.s.c; c <= range.e.c; c++) {
const cell = ws[XLSX.utils.encode_cell({ r, c })] as XLSXNS.CellObject | undefined;
if (!cell) continue;
let raw: string | null = null;
if (cell.f) raw = fromExcelFormula(String(cell.f));
if (raw == null) {
if (cell.v == null) continue;
raw = cell.t === 'n' ? String(cell.v) : String(cell.w ?? cell.v);
}
if (raw === '') continue;
cells[`${c},${r}`] = raw;
rows = Math.max(rows, r + 1);
cols = Math.max(cols, c + 1);
}
}
}
result.push({ name, rows: Math.max(rows, 20), cols: Math.max(cols, 8), cells });
}
return result;
}
/** Wrap an imported worksheet as a canvas sheet holding one table object. */
export function importedToSheet(imported: ImportedSheet, idx: number): Sheet {
const stamp = Date.now().toString(36);
const table: TableObject = {
kind: 'table',
id: `xlsx-tbl-${stamp}-${idx}`,
name: imported.name,
x: 40,
y: 40,
rows: imported.rows,
cols: imported.cols,
colWidths: {},
cells: imported.cells,
view: defaultTableView(),
};
return { id: `xlsx-sheet-${stamp}-${idx}`, name: imported.name, objects: [table] };
}