The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
5.9 KiB
TypeScript
184 lines
5.9 KiB
TypeScript
/**
|
|
* .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] };
|
|
}
|