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:
@@ -11,6 +11,7 @@
|
||||
import type {
|
||||
CellFormat,
|
||||
ChartObject,
|
||||
CustomFunction,
|
||||
DocumentState,
|
||||
HAlign,
|
||||
Sheet,
|
||||
@@ -32,6 +33,30 @@ function localeOrUndef(x: unknown): NumberLocale | undefined {
|
||||
return isNumberLocale(x) ? x : undefined;
|
||||
}
|
||||
|
||||
/** Defaults to `[]` -- absent in every document saved before this feature
|
||||
* existed (and in v1 documents entirely) -- and drops any malformed entry
|
||||
* rather than rejecting the whole import over one bad function. */
|
||||
function normaliseCustomFunctions(x: unknown): CustomFunction[] {
|
||||
if (!Array.isArray(x)) return [];
|
||||
const out: CustomFunction[] = [];
|
||||
for (const f of x) {
|
||||
if (
|
||||
!f ||
|
||||
typeof f !== 'object' ||
|
||||
typeof (f as { id?: unknown }).id !== 'string' ||
|
||||
typeof (f as { name?: unknown }).name !== 'string' ||
|
||||
typeof (f as { body?: unknown }).body !== 'string' ||
|
||||
!Array.isArray((f as { params?: unknown }).params) ||
|
||||
!(f as { params: unknown[] }).params.every((p) => typeof p === 'string')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const rec = f as CustomFunction;
|
||||
out.push({ id: rec.id, name: rec.name, params: [...rec.params], body: rec.body });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
function colorOrUndef(x: unknown): string | undefined {
|
||||
return typeof x === 'string' && HEX_COLOR.test(x) ? x : undefined;
|
||||
@@ -303,6 +328,7 @@ export function validateDocument(x: unknown): DocumentState {
|
||||
: null,
|
||||
locale: isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE,
|
||||
csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',',
|
||||
customFunctions: normaliseCustomFunctions(doc.customFunctions),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -365,6 +391,7 @@ export function migrateToV2(x: unknown): DocumentState {
|
||||
: null,
|
||||
locale: DEFAULT_LOCALE,
|
||||
csvDelimiter: ',',
|
||||
customFunctions: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user