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:
+159
-3
@@ -25,9 +25,13 @@ import {
|
||||
tokenize,
|
||||
looksLikeCell,
|
||||
parseA1,
|
||||
parseFormula,
|
||||
hasFunction,
|
||||
collectCalledFunctionNames,
|
||||
type CellValue,
|
||||
type TableModel,
|
||||
type Token,
|
||||
type FunctionDef,
|
||||
} from '../engine';
|
||||
import {
|
||||
formatValue,
|
||||
@@ -40,6 +44,7 @@ import type {
|
||||
CellCoord,
|
||||
CellFormat,
|
||||
ChartObject,
|
||||
CustomFunction,
|
||||
DocumentState,
|
||||
Sheet,
|
||||
SelectionState,
|
||||
@@ -76,6 +81,10 @@ function toTableModels(doc: DocumentState): TableModel[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function toFunctionDefs(doc: DocumentState): FunctionDef[] {
|
||||
return doc.customFunctions.map((f) => ({ id: f.id, name: f.name, params: f.params, body: f.body }));
|
||||
}
|
||||
|
||||
/** A normalised rectangle (corners in either order) used by selection + clipboard. */
|
||||
type Rect = { c0: number; c1: number; r0: number; r1: number };
|
||||
|
||||
@@ -182,7 +191,7 @@ function loadInitial(): DocumentState {
|
||||
|
||||
function createStore() {
|
||||
const doc = reactive<DocumentState>(loadInitial());
|
||||
let wb = new Workbook(toTableModels(doc));
|
||||
let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
|
||||
const recalcTick = ref(0);
|
||||
const bump = () => (recalcTick.value += 1);
|
||||
|
||||
@@ -202,7 +211,7 @@ function createStore() {
|
||||
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
|
||||
|
||||
function syncWorkbook() {
|
||||
wb = new Workbook(toTableModels(doc));
|
||||
wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
|
||||
bump();
|
||||
}
|
||||
|
||||
@@ -247,7 +256,12 @@ function createStore() {
|
||||
const undoStack: DocumentState[] = [];
|
||||
const redoStack: DocumentState[] = [];
|
||||
const contentFingerprint = (d: DocumentState) =>
|
||||
JSON.stringify({ sheets: d.sheets, locale: d.locale, csvDelimiter: d.csvDelimiter });
|
||||
JSON.stringify({
|
||||
sheets: d.sheets,
|
||||
locale: d.locale,
|
||||
csvDelimiter: d.csvDelimiter,
|
||||
customFunctions: d.customFunctions,
|
||||
});
|
||||
let historySnapshot = snapshot();
|
||||
let historyFingerprint = contentFingerprint(historySnapshot);
|
||||
let historyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -618,6 +632,142 @@ function createStore() {
|
||||
else bump();
|
||||
}
|
||||
|
||||
// ---- custom functions (document-level formula library) ----------------
|
||||
// Created/edited/removed exclusively through the "𝑓 Funktionen" UI. A call
|
||||
// is inlined into the caller's AST at parse time (engine/userFunctions.ts)
|
||||
// -- renaming/deleting one behaves exactly like renaming/deleting a table:
|
||||
// existing formula text is left as-is and becomes #NAME? / #REF on the next
|
||||
// syncWorkbook(), there is no find-and-replace across stored formulas.
|
||||
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
const customFunctionNames = () => new Set(doc.customFunctions.map((f) => f.name.toLowerCase()));
|
||||
|
||||
/** Every (upper-cased) function name a stored custom function's body calls,
|
||||
* tolerant of a body that -- in theory -- no longer parses (shouldn't
|
||||
* happen; a save always validates first, but this is only used for a
|
||||
* best-effort cycle check, so fail open rather than block unrelated saves). */
|
||||
function bodyCallSet(params: string[], body: string): Set<string> {
|
||||
try {
|
||||
return collectCalledFunctionNames(parseFormula(body, params));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/** Does the call graph across every custom function (built-ins are never
|
||||
* nodes -- they can't call back into user functions) contain a cycle? */
|
||||
function callGraphHasCycle(graph: Map<string, Set<string>>): boolean {
|
||||
const state = new Map<string, 1 | 2>(); // 1 = on the current DFS path, 2 = fully explored
|
||||
function dfs(node: string): boolean {
|
||||
const st = state.get(node);
|
||||
if (st === 1) return true; // back-edge -> cycle
|
||||
if (st === 2) return false;
|
||||
state.set(node, 1);
|
||||
for (const callee of graph.get(node) ?? []) {
|
||||
if (graph.has(callee) && dfs(callee)) return true;
|
||||
}
|
||||
state.set(node, 2);
|
||||
return false;
|
||||
}
|
||||
for (const node of graph.keys()) {
|
||||
if (dfs(node)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full validation for add/update: name & parameter shape, no collision with
|
||||
* a built-in or another custom function, the body parses, and -- simulating
|
||||
* the *whole* call graph with this function's (possibly new) definition in
|
||||
* place -- no cycle. Returns a user-facing (German) error string, or null
|
||||
* when the definition is safe to save. `ignoreId` excludes the function
|
||||
* being edited from the collision/graph checks against its own old state.
|
||||
*/
|
||||
function validateCustomFunction(
|
||||
name: string,
|
||||
params: string[],
|
||||
body: string,
|
||||
ignoreId: string | null,
|
||||
): string | null {
|
||||
const cleanName = name.trim();
|
||||
if (!IDENT_RE.test(cleanName)) {
|
||||
return `"${name}" ist kein gültiger Funktionsname.`;
|
||||
}
|
||||
if (looksLikeCell(cleanName)) {
|
||||
return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Funktionsname nicht erlaubt.`;
|
||||
}
|
||||
if (hasFunction(cleanName)) {
|
||||
return `"${cleanName}" ist bereits eine eingebaute Funktion und kann nicht überschrieben werden.`;
|
||||
}
|
||||
const collision = doc.customFunctions.find(
|
||||
(f) => f.id !== ignoreId && f.name.toLowerCase() === cleanName.toLowerCase(),
|
||||
);
|
||||
if (collision) return `Es gibt bereits eine Funktion namens "${collision.name}".`;
|
||||
|
||||
const cleanParams = params.map((p) => p.trim());
|
||||
for (const p of cleanParams) {
|
||||
if (!IDENT_RE.test(p)) return `"${p}" ist kein gültiger Parametername.`;
|
||||
if (looksLikeCell(p)) return `Parameter "${p}" sieht wie ein Zellbezug aus.`;
|
||||
}
|
||||
if (new Set(cleanParams.map((p) => p.toLowerCase())).size !== cleanParams.length) {
|
||||
return 'Parameternamen müssen eindeutig sein.';
|
||||
}
|
||||
|
||||
let ast;
|
||||
try {
|
||||
ast = parseFormula(body, cleanParams);
|
||||
} catch (e) {
|
||||
return `Formel ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
||||
}
|
||||
|
||||
const graph = new Map<string, Set<string>>();
|
||||
for (const f of doc.customFunctions) {
|
||||
if (f.id === ignoreId) continue;
|
||||
graph.set(f.name.toUpperCase(), bodyCallSet(f.params, f.body));
|
||||
}
|
||||
graph.set(cleanName.toUpperCase(), collectCalledFunctionNames(ast));
|
||||
if (callGraphHasCycle(graph)) {
|
||||
return `"${cleanName}" führt (direkt oder über andere Funktionen) zu einem Zyklus.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addCustomFunction(name: string, params: string[], body: string): string | { error: string } {
|
||||
const error = validateCustomFunction(name, params, body, null);
|
||||
if (error) return { error };
|
||||
const id = uid('fn');
|
||||
const custom: CustomFunction = {
|
||||
id,
|
||||
name: name.trim(),
|
||||
params: params.map((p) => p.trim()),
|
||||
body: body.startsWith('=') ? body.slice(1) : body,
|
||||
};
|
||||
doc.customFunctions.push(custom);
|
||||
syncWorkbook();
|
||||
return id;
|
||||
}
|
||||
|
||||
function updateCustomFunction(
|
||||
id: string,
|
||||
name: string,
|
||||
params: string[],
|
||||
body: string,
|
||||
): void | { error: string } {
|
||||
const f = doc.customFunctions.find((x) => x.id === id);
|
||||
if (!f) return { error: 'Funktion nicht gefunden.' };
|
||||
const error = validateCustomFunction(name, params, body, id);
|
||||
if (error) return { error };
|
||||
f.name = name.trim();
|
||||
f.params = params.map((p) => p.trim());
|
||||
f.body = body.startsWith('=') ? body.slice(1) : body;
|
||||
syncWorkbook();
|
||||
}
|
||||
|
||||
function deleteCustomFunction(id: string) {
|
||||
doc.customFunctions = doc.customFunctions.filter((f) => f.id !== id);
|
||||
syncWorkbook(); // affected calls become #NAME? on rebuild, same as a deleted table
|
||||
}
|
||||
|
||||
function moveObject(objId: string, x: number, y: number) {
|
||||
const o = objectById(objId);
|
||||
if (!o) return;
|
||||
@@ -1299,6 +1449,7 @@ function createStore() {
|
||||
doc.selection = next.selection ?? null;
|
||||
doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE;
|
||||
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
|
||||
doc.customFunctions = next.customFunctions ?? [];
|
||||
syncWorkbook();
|
||||
}
|
||||
/** Loading a different document outright (JSON import "replace", "reset to
|
||||
@@ -1388,6 +1539,11 @@ function createStore() {
|
||||
renameObject,
|
||||
deleteObject,
|
||||
moveObject,
|
||||
// custom functions
|
||||
customFunctionNames,
|
||||
addCustomFunction,
|
||||
updateCustomFunction,
|
||||
deleteCustomFunction,
|
||||
resizeChart,
|
||||
bringToFront,
|
||||
setChartView,
|
||||
|
||||
Reference in New Issue
Block a user