Add Rails backend: accounts via magic link, server documents, sharing
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>
This commit is contained in:
@@ -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] };
|
||||
}
|
||||
Reference in New Issue
Block a user