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,297 @@
|
||||
/**
|
||||
* Framework-agnostic spreadsheet model that ties the engine modules together.
|
||||
*
|
||||
* The addressable unit is a **table** (a table object on a Numbers-style
|
||||
* canvas). A `Workbook` owns a flat list of tables and, across all of them:
|
||||
* - the raw text of every non-empty cell,
|
||||
* - the parsed AST of every formula cell (or an `#ERR` marker),
|
||||
* - the computed value of every cell,
|
||||
* - the document-wide {@link DependencyGraph}.
|
||||
*
|
||||
* It implements {@link EngineHost} so the evaluator can read neighbouring cell
|
||||
* values through it. The Vue store wraps an instance of this class in `reactive`
|
||||
* and mirrors its computed values into a reactive map, which makes every formula
|
||||
* cell behave like a `computed` value in the UI.
|
||||
*
|
||||
* Recalculation is incremental: `setCell` reparses one cell, updates its graph
|
||||
* edges and recomputes only the affected sub-graph in topological order.
|
||||
* Structural edits (rename / delete table, add rows, ...) call `rebuild()` for a
|
||||
* full, still-cheap pass.
|
||||
*/
|
||||
|
||||
import { DependencyGraph } from './graph';
|
||||
import { collectRefs, parseFormula, type Node } from './parser';
|
||||
import { rangeCells } from './refs';
|
||||
import { parseDateTimeLiteral } from './dates';
|
||||
import { buildFunctionIndex, expandUserFunctions, type FunctionDef } from './userFunctions';
|
||||
import {
|
||||
FormulaError,
|
||||
isError,
|
||||
keyOf,
|
||||
parseKey,
|
||||
type CellRef,
|
||||
type CellValue,
|
||||
type EngineHost,
|
||||
type NamedRangeDef,
|
||||
} from './types';
|
||||
import { evaluate } from './evaluator';
|
||||
|
||||
export interface TableModel {
|
||||
id: string;
|
||||
/** table name, used as the qualifier in formulas -- unique *per sheet*
|
||||
* only; the same name may occur on more than one sheet (see
|
||||
* `sheetName` and {@link EngineHost.resolveTableName}). */
|
||||
name: string;
|
||||
/** name of the sheet this table lives on. */
|
||||
sheetName: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
/** key `${col},${row}` -> raw user input. Absent = empty cell. */
|
||||
inputs: Map<string, string>;
|
||||
}
|
||||
|
||||
const NUMBER_RE = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
|
||||
|
||||
function localKey(col: number, row: number): string {
|
||||
return `${col},${row}`;
|
||||
}
|
||||
|
||||
/** Interpret a raw literal (non-formula) cell input. A recognized date/time
|
||||
* (see dates.ts's parseDateTimeLiteral) becomes its serial number -- dates
|
||||
* are just numbers in this engine, so `+`/`-`/comparisons work on them for
|
||||
* free. Checked before NUMBER_RE; the two never collide (dates use `-`/`.`
|
||||
* multi-part separators or `:`, which NUMBER_RE's single-decimal-point
|
||||
* shape never matches). */
|
||||
export function literalValue(raw: string): CellValue {
|
||||
const t = raw.trim();
|
||||
if (t === '') return null;
|
||||
const dt = parseDateTimeLiteral(t);
|
||||
if (dt) return dt.serial;
|
||||
if (NUMBER_RE.test(t)) return Number(t);
|
||||
return raw;
|
||||
}
|
||||
|
||||
export class Workbook implements EngineHost {
|
||||
tables: TableModel[];
|
||||
/** transiently set to the home table while a formula is being evaluated */
|
||||
homeTableId = '';
|
||||
|
||||
private readonly graph = new DependencyGraph();
|
||||
/** cellKey -> parsed formula AST, or an error marker for un-parsable input */
|
||||
private readonly asts = new Map<string, Node | FormulaError>();
|
||||
/** cellKey -> computed value */
|
||||
private readonly values = new Map<string, CellValue>();
|
||||
/** user-defined function library for this document, name.toUpperCase() -> def. */
|
||||
private readonly functionIndex: Map<string, FunctionDef>;
|
||||
/** document-wide named ranges (see NamedRangeDef's doc comment). */
|
||||
private readonly namedRanges: NamedRangeDef[];
|
||||
|
||||
constructor(
|
||||
tables: TableModel[],
|
||||
functionDefs: FunctionDef[] = [],
|
||||
namedRanges: NamedRangeDef[] = [],
|
||||
) {
|
||||
this.tables = tables;
|
||||
this.functionIndex = buildFunctionIndex(functionDefs);
|
||||
this.namedRanges = namedRanges;
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
// ---- EngineHost ---------------------------------------------------------
|
||||
|
||||
resolveTableName(name: string, sheetName?: string | null): string | null {
|
||||
const needle = name.trim().toLowerCase();
|
||||
const candidates = this.tables.filter((t) => t.name.trim().toLowerCase() === needle);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
if (sheetName != null) {
|
||||
const sheetNeedle = sheetName.trim().toLowerCase();
|
||||
const hit = candidates.find((t) => t.sheetName.trim().toLowerCase() === sheetNeedle);
|
||||
return hit ? hit.id : null;
|
||||
}
|
||||
if (candidates.length === 1) return candidates[0].id;
|
||||
|
||||
// ambiguous (same name on more than one sheet) -- prefer a table on the
|
||||
// same sheet as the formula doing the referencing, if there is one
|
||||
const home = this.tableById(this.homeTableId);
|
||||
if (home) {
|
||||
const sameSheet = candidates.find((t) => t.sheetName === home.sheetName);
|
||||
if (sameSheet) return sameSheet.id;
|
||||
}
|
||||
return null; // still ambiguous -> #REF, needs an explicit sheet qualifier
|
||||
}
|
||||
|
||||
resolveNamedRange(
|
||||
name: string,
|
||||
): { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } } | null {
|
||||
const needle = name.trim().toLowerCase();
|
||||
const def = this.namedRanges.find((d) => d.name.trim().toLowerCase() === needle);
|
||||
if (!def) return null;
|
||||
const table = this.tableById(def.tableId);
|
||||
if (!table) return null; // target table was since deleted -- #NAME?, same as a stale table rename
|
||||
return { qualifier: table.name, sheetQualifier: table.sheetName, start: def.start, end: def.end };
|
||||
}
|
||||
|
||||
tableSize(tableId: string): { cols: number; rows: number } | null {
|
||||
const t = this.tableById(tableId);
|
||||
return t ? { cols: t.cols, rows: t.rows } : null;
|
||||
}
|
||||
|
||||
getComputed(tableId: string, col: number, row: number): CellValue {
|
||||
if (col < 0 || row < 0) return new FormulaError('#REF');
|
||||
const v = this.values.get(keyOf(tableId, col, row));
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
// ---- reads ------------------------------------------------------------
|
||||
|
||||
tableById(id: string): TableModel | undefined {
|
||||
return this.tables.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
getInput(tableId: string, col: number, row: number): string {
|
||||
return this.tableById(tableId)?.inputs.get(localKey(col, row)) ?? '';
|
||||
}
|
||||
|
||||
/** Public computed-value read used by the store / UI. */
|
||||
cellValue(tableId: string, col: number, row: number): CellValue {
|
||||
const v = this.values.get(keyOf(tableId, col, row));
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
/** Direct precedents of a cell -- the cells its own formula reads (a
|
||||
* formula's `#REF`/named-range/etc. resolution already baked into the
|
||||
* graph edges, see `precedentsOf()` above). Public wrapper around the
|
||||
* internal `DependencyGraph`, for UI features (the dependency-graph
|
||||
* view) that need to *query* the graph -- evaluation itself never calls
|
||||
* this, it only calls `setPrecedents`/`affected`/`topoOrder`. */
|
||||
graphPrecedents(tableId: string, col: number, row: number): CellRef[] {
|
||||
return [...this.graph.getPrecedents(keyOf(tableId, col, row))].map(parseKey);
|
||||
}
|
||||
/** Direct dependents of a cell -- every cell whose formula reads *this*
|
||||
* one. See graphPrecedents() above. */
|
||||
graphDependents(tableId: string, col: number, row: number): CellRef[] {
|
||||
return [...this.graph.getDependents(keyOf(tableId, col, row))].map(parseKey);
|
||||
}
|
||||
|
||||
// ---- writes ---------------------------------------------------------
|
||||
|
||||
/** Set (or clear, with `''`) one cell and recalculate the affected cells. */
|
||||
setCell(tableId: string, col: number, row: number, raw: string): void {
|
||||
const table = this.tableById(tableId);
|
||||
if (!table) return;
|
||||
const lk = localKey(col, row);
|
||||
if (raw === '') table.inputs.delete(lk);
|
||||
else table.inputs.set(lk, raw);
|
||||
|
||||
const key = keyOf(tableId, col, row);
|
||||
this.parseCell(key, tableId, raw);
|
||||
this.recalc([key]);
|
||||
}
|
||||
|
||||
/** Reparse every formula, rebuild the graph, recompute everything. */
|
||||
rebuild(): void {
|
||||
this.graph.reset();
|
||||
this.asts.clear();
|
||||
const all: string[] = [];
|
||||
for (const table of this.tables) {
|
||||
for (const [lk, raw] of table.inputs) {
|
||||
const [c, r] = lk.split(',').map(Number);
|
||||
const key = keyOf(table.id, c, r);
|
||||
all.push(key);
|
||||
this.parseCell(key, table.id, raw);
|
||||
}
|
||||
}
|
||||
this.recalcAll(all);
|
||||
}
|
||||
|
||||
// ---- internals ------------------------------------------------------
|
||||
|
||||
/** Parse one cell's raw input and (re)wire its graph edges. */
|
||||
private parseCell(key: string, tableId: string, raw: string): void {
|
||||
if (!raw.startsWith('=')) {
|
||||
this.asts.delete(key);
|
||||
this.graph.clear(key);
|
||||
return;
|
||||
}
|
||||
let ast: Node;
|
||||
try {
|
||||
ast = parseFormula(raw);
|
||||
ast = expandUserFunctions(ast, this.functionIndex);
|
||||
} catch (e) {
|
||||
// a bad formula is a cell-level error, never a crash
|
||||
this.asts.set(key, new FormulaError('#ERR', (e as Error).message));
|
||||
this.graph.setPrecedents(key, []);
|
||||
return;
|
||||
}
|
||||
this.asts.set(key, ast);
|
||||
this.graph.setPrecedents(key, this.precedentsOf(ast, tableId));
|
||||
}
|
||||
|
||||
/** Resolve every reference in `ast` to concrete cell keys (home = tableId). */
|
||||
private precedentsOf(ast: Node, tableId: string): string[] {
|
||||
// resolveTableName()'s "prefer the home sheet" ambiguity fallback reads
|
||||
// this.homeTableId -- set it here too (not just in evaluateCell) so graph
|
||||
// edges are wired using the *same* resolution an evaluation would use.
|
||||
this.homeTableId = tableId;
|
||||
const out: string[] = [];
|
||||
for (const ref of collectRefs(ast)) {
|
||||
// a named range isn't itself a qualifier/coords pair -- resolve it to
|
||||
// one first (same shape as a 'cell'/'range' ref), then fall through to
|
||||
// the identical edge-wiring below.
|
||||
const resolved = ref.kind === 'namedref' ? this.resolveNamedRange(ref.name) : ref;
|
||||
if (!resolved) continue; // unknown name -> no edge; evaluator emits #NAME?
|
||||
const target =
|
||||
resolved.qualifier == null
|
||||
? tableId
|
||||
: this.resolveTableName(resolved.qualifier, resolved.sheetQualifier ?? null);
|
||||
if (!target) continue; // unknown table -> no edge; evaluator emits #REF
|
||||
if ('col' in resolved) {
|
||||
out.push(keyOf(target, resolved.col, resolved.row));
|
||||
} else {
|
||||
for (const c of rangeCells(resolved, this.tableSize(target))) {
|
||||
out.push(keyOf(target, c.col, c.row));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Incremental recalculation starting from `dirty`. */
|
||||
private recalc(dirty: string[]): void {
|
||||
this.evaluateSet(this.graph.affected(dirty));
|
||||
}
|
||||
|
||||
/** Full recalculation of the given (complete) cell set. */
|
||||
private recalcAll(all: string[]): void {
|
||||
this.values.clear();
|
||||
this.evaluateSet(new Set(all));
|
||||
}
|
||||
|
||||
private evaluateSet(affected: Set<string>): void {
|
||||
const { order, circular } = this.graph.topoOrder(affected);
|
||||
for (const key of circular) this.values.set(key, new FormulaError('#CIRC'));
|
||||
for (const key of order) this.values.set(key, this.evaluateCell(key));
|
||||
}
|
||||
|
||||
private evaluateCell(key: string): CellValue {
|
||||
const ast = this.asts.get(key);
|
||||
if (ast === undefined) {
|
||||
const { tableId, col, row } = parseKey(key);
|
||||
return literalValue(this.getInput(tableId, col, row));
|
||||
}
|
||||
if (isError(ast)) return ast;
|
||||
this.homeTableId = parseKey(key).tableId;
|
||||
try {
|
||||
return evaluate(ast, this);
|
||||
} catch {
|
||||
return new FormulaError('#ERR', 'evaluation failed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Recompute order for a hand-picked dirty set -- exposed for tests. */
|
||||
debugOrder(dirty: string[]): { order: string[]; circular: string[] } {
|
||||
const { order, circular } = this.graph.topoOrder(this.graph.affected(dirty));
|
||||
return { order, circular: [...circular] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user