Add user-defined functions, editable exclusively via a new UI

Document-wide, named formula functions (e.g. MARGE(revenue; cost) =
(revenue - cost) / revenue) callable from any cell like a built-in
(=MARGE(Umsatz!B2; Umsatz!C2)). Created, edited and deleted only through
a new "𝑓 Funktionen" toolbar button -> modal (list + shared edit form),
never via JSON handedit.

Engine: a call is inlined into the caller's AST at parse time
(src/engine/userFunctions.ts's expandUserFunctions), so evaluator.ts and
graph.ts need no changes -- dependencies on cells referenced only inside
a function body (not in the call) are picked up for free. parser.ts
gains an optional paramNames argument to parseFormula() producing a new
ParamNode leaf, used only when parsing a function body.

Store: addCustomFunction/updateCustomFunction/deleteCustomFunction with
inline (not toast) validation -- name/parameter shape, no collision with
a built-in or another function, the body parses, and a definition-time
cycle check across the whole call graph. Renaming/deleting a function
mirrors table rename/delete: existing calls are not rewritten, they
become #NAME? on the next rebuild. Fully undo/redo-able and part of the
JSON document (customFunctions: [], migrated for v1 and older v2 docs).

178 tests passing (32 new), vue-tsc and eslint clean. Manually verified
end-to-end in the running app via Playwright: define/call/recalculate,
rename -> #NAME?, a closing cycle rejected inline at save, undo restores
the function library.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 15:37:04 +02:00
co-authored by Claude Sonnet 5
parent fb5915d5be
commit c3fde283c8
16 changed files with 1196 additions and 24 deletions
+6 -1
View File
@@ -22,6 +22,7 @@
import { DependencyGraph } from './graph';
import { collectRefs, parseFormula, type Node } from './parser';
import { expandRange } from './refs';
import { buildFunctionIndex, expandUserFunctions, type FunctionDef } from './userFunctions';
import {
FormulaError,
isError,
@@ -66,9 +67,12 @@ export class Workbook implements EngineHost {
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>;
constructor(tables: TableModel[]) {
constructor(tables: TableModel[], functionDefs: FunctionDef[] = []) {
this.tables = tables;
this.functionIndex = buildFunctionIndex(functionDefs);
this.rebuild();
}
@@ -150,6 +154,7 @@ export class Workbook implements EngineHost {
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));