From c3fde283c8f7455809ab4561946d184d3c15bb58 Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Wed, 2 Sep 2026 15:37:04 +0200 Subject: [PATCH] Add user-defined functions, editable exclusively via a new UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 82 +++++- src/components/AppToolbar.vue | 10 + src/components/CustomFunctionsModal.vue | 290 +++++++++++++++++++++ src/data/seed.ts | 1 + src/engine/README.md | 38 ++- src/engine/__tests__/parser.spec.ts | 28 ++ src/engine/__tests__/userFunctions.spec.ts | 212 +++++++++++++++ src/engine/evaluator.ts | 7 + src/engine/index.ts | 6 + src/engine/parser.ts | 42 ++- src/engine/userFunctions.ts | 159 +++++++++++ src/engine/workbook.ts | 7 +- src/io/json.ts | 27 ++ src/store/__tests__/document.spec.ts | 134 ++++++++++ src/store/document.ts | 162 +++++++++++- src/store/types.ts | 15 ++ 16 files changed, 1196 insertions(+), 24 deletions(-) create mode 100644 src/components/CustomFunctionsModal.vue create mode 100644 src/engine/__tests__/userFunctions.spec.ts create mode 100644 src/engine/userFunctions.ts diff --git a/README.md b/README.md index 42bec38..b6111e9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt: ```bash npm install npm run dev # Vite-Devserver -npm run test # Vitest-Suite (Engine + Store + IO), 146 Tests +npm run test # Vitest-Suite (Engine + Store + IO), 178 Tests npm run build # Typecheck + Produktionsbuild npm run lint # ESLint (flat config) ``` @@ -147,6 +147,40 @@ Argumenttrennzeichen ebenso wie jedes andere Zeichen) beendet dieses Fenster, der nächste Klick fügt dann einen neuen Bezug an. Ein Klick während des Point-Mode ändert nie die Tabellen-/Objektauswahl im Hintergrund. +### Benutzerdefinierte Funktionen + +Der Button **„𝑓 Funktionen"** in der Symbolleiste öffnet die einzige Stelle, an +der eigene, dokumentweite Funktionen angelegt, bearbeitet und gelöscht werden +— kein JSON-Handedit, keine Konsole. Danach in jeder Zelle aufrufbar wie ein +eingebauter Funktionsname: `=MARGE(Umsatz!B2; Umsatz!C2)`. + +- **Liste** zeigt jede Funktion als `NAME(param1; param2)` — exakt die + Aufruf-Syntax — mit **Bearb.** (öffnet dasselbe Formular, vorausgefüllt) + und **🗑** (löscht nach Bestätigung). +- **Formular** (Neu wie Bearbeiten): Name, eine dynamische Liste von + Parameter-Feldern (**+ Parameter**, `×` zum Entfernen; auch 0 Parameter + sind gültig — dann ist es eine benannte Konstante/Formel), und ein + Formel-Feld optisch identisch zur Formelleiste — **ohne** führendes `=`. + Ein Parametername verhält sich im Funktionskörper wie eine Zelle mit + eigenem Wert; er darf selbst nicht wie ein Zellbezug aussehen (`A1`, `$B2` + o. ä. sind ungültig). +- **Validierung beim Speichern**, mit **Inline-Fehlermeldung** direkt im + Formular (kein Toast, nichts geht verloren): Name-Kollision mit einer + eingebauten oder einer anderen benutzerdefinierten Funktion, ungültiger + Name/Parameter, ein nicht parsender Formelkörper, und ein **Funktions- + Zyklus** (A ruft B, B ruft A, auch über mehrere Stufen) — wird abgelehnt, + bevor je eine `#ERR`-Zelle entsteht. +- **Umbenennen/Löschen** aktualisiert **keine** bestehenden Formeltexte — + genau wie beim Umbenennen/Löschen einer Tabelle. Zellen, die die + betroffene Funktion aufrufen, zeigen danach `#NAME?`. +- Ein Aufruf wird **beim Parsen der aufrufenden Zelle in ihre AST eingesetzt** + (Inlining, keine Laufzeit-Closures) — siehe + [`src/engine/userFunctions.ts`](src/engine/userFunctions.ts) und die + Architektur-Tabelle unten. Dadurch braucht Evaluator/Graph keine Änderung, + und auch Zellen, die *nur* im Funktionskörper referenziert werden (nicht im + Aufruf), lösen bei Änderung korrekt eine Neuberechnung aus. +- Vollständig **undo-/redo-fähig** und Teil des JSON-Exports/-Imports. + ### Zell-Formatierung Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im @@ -250,15 +284,22 @@ darauf, `$` hin oder her. `F4` schaltet den Bezug unter dem Cursor durch die vier Kombinationen. **Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`), -`MIN`, `MAX`, `COUNT`. Argumente gemischt: Einzelzellen, Bereiche (lokal oder -`Name!…`), Zahl-Literale — z. B. `=SUM(Umsatz!B2:E2; 100; A1)`. +`MIN`, `MAX`, `COUNT` — plus alle über **„𝑓 Funktionen"** angelegten +benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben), +die sich für den Aufrufer identisch verhalten. Argumente gemischt: Einzelzellen, +Bereiche (lokal oder `Name!…`), Zahl-Literale — z. B. +`=SUM(Umsatz!B2:E2; 100; A1)`. **Fehlermodell** (propagiert an abhängige Zellen): `#REF` (ungültiger/gelöschter Bezug oder unbekannter Tabellenname), `#DIV/0!`, -`#NAME?` (unbekannte Funktion), `#ERR` (Parse-Fehler), `#CIRC` (Zirkelbezug, -auch tabellenübergreifend), `#VALUE!` (Typfehler in Arithmetik — *dokumentierte -Erweiterung*). Referenz auf leere Zelle = `0` in Arithmetik, von `COUNT` nicht -gezählt. +`#NAME?` (unbekannte Funktion, eingebaut oder benutzerdefiniert), `#ERR` +(Parse-Fehler; auch eine falsche Argumentanzahl bei einer benutzerdefinierten +Funktion), `#CIRC` (Zirkelbezug, auch tabellenübergreifend), `#VALUE!` +(Typfehler in Arithmetik — *dokumentierte Erweiterung*). Referenz auf leere +Zelle = `0` in Arithmetik, von `COUNT` nicht gezählt. Ein Zyklus zwischen +benutzerdefinierten Funktionen selbst wird nicht über `#CIRC` sichtbar — er +wird schon beim **Speichern** der Funktion im UI abgelehnt (kein neuer +Fehlercode nötig). --- @@ -277,7 +318,8 @@ Tabellen aller Blätter. | `graph.ts` | `DependencyGraph`: `affected()`, `topoOrder()`, Zirkel-DFS. | | `refs.ts` | A1 ↔ `(col,row)`, Bereichs-Expansion. | | `functions.ts` | `{ NAME → fn(values) }` für SUM/AVG/MIN/MAX/COUNT. | -| `workbook.ts` | Flache Tabellenliste + ASTs + Werte + Graph; inkrementelle Neuberechnung; implementiert `EngineHost`. | +| `userFunctions.ts` | Benutzerdefinierte Funktionen: `expandUserFunctions()` setzt einen Aufruf beim Parsen der aufrufenden Zelle als Kopie des (rekursiv expandierten, parametersubstituierten) Funktionskörpers in deren AST ein — kein Laufzeit-Closure, Evaluator/Graph sehen danach eine ganz normale AST. | +| `workbook.ts` | Flache Tabellenliste + ASTs + Werte + Graph; inkrementelle Neuberechnung; implementiert `EngineHost`; verdrahtet `userFunctions.ts` direkt nach `parseFormula()`. | **Neuberechnung.** `setCell` reparst eine Zelle → `graph.affected(dirty)` → `graph.topoOrder()` → Zyklus-Zellen `#CIRC`, Rest topologisch auswerten, nicht @@ -359,7 +401,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, ## Tests -`npm run test` — Vitest, 146 Tests: +`npm run test` — Vitest, 178 Tests: - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`, @@ -421,6 +463,17 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, hängt er einen neuen an; Ziehen fügt/erweitert einen Bereichsbezug; ein Klick in eine andere Tabelle qualifiziert mit deren Namen (Quoting bei Leerzeichen); ohne Formel im Puffer greift nichts. +- **Benutzerdefinierte Funktionen** — Engine-Ebene (`userFunctions.spec.ts`): + Parametersubstitution, verschachtelte Aufrufe (eingebaut + benutzerdefiniert, + auch bei kollidierenden Parameternamen zwischen den Scopes), Arity-Mismatch → + `#ERR`, unbekannter Name → `#NAME?`, Rekursionstiefen-Sicherheitsnetz, + Abhängigkeits-Edges für Zellen, die nur im Funktionskörper vorkommen (nicht + im Aufruf) — inkl. korrekter Neuberechnungs-Reihenfolge. Store-Ebene: Name-/ + Parameter-Validierung, Kollision mit eingebauten/anderen Funktionen, + Zyklus-Ablehnung beim Speichern (direkt und über mehrere Stufen), Umbenennen/ + Löschen lässt bestehende Aufrufe zu `#NAME?` werden, Undo/Redo einer + Funktionsänderung, voller JSON-Round-Trip, `customFunctions: []` bei + v1→v2-Migration. - **IO** — JSON verlustfreier Round-Trip (identisch), defekte/leere Datei → Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting, @@ -443,9 +496,14 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, reaktiv), **kein** echter Web Worker. - **xlsx-Formel-Übernahme** ist heuristisch (Separator-Swap unter Beachtung von Quotes); exotische Excel-Formeln fallen auf den Wert zurück. -- Kein Text-Verkettungs-/Vergleichsoperator, keine benannten Bereiche, kein - Kopieren/Einfügen von Zellbereichen, keine Undo-Historie, kein Snapping der - Canvas-Objekte. +- Kein Text-Verkettungs-/Vergleichsoperator, keine benannten Bereiche (nur + benutzerdefinierte *Funktionen*, keine benannten *Zellen/Bereiche*), kein + Snapping der Canvas-Objekte. +- **Benutzerdefinierte Funktionen** (Phase 1, siehe oben) bewusst ohne: + Autovervollständigen des Funktionsnamens im Editor, Live-Vorschau des + Ergebnisses im Bearbeiten-Dialog, variadische Parameter/Default-Werte, und + automatisches Nachführen bestehender Aufrufe bei Umbenennung (Verhalten + entspricht bewusst dem einer Tabellen-Umbenennung, s. o.). ## Projektstruktur diff --git a/src/components/AppToolbar.vue b/src/components/AppToolbar.vue index 65abff4..da1da9e 100644 --- a/src/components/AppToolbar.vue +++ b/src/components/AppToolbar.vue @@ -4,6 +4,7 @@ import { useDocument } from '../store/document'; import { useTheme } from '../composables/useTheme'; import { useToasts } from '../composables/useToasts'; import BaseModal from './BaseModal.vue'; +import CustomFunctionsModal from './CustomFunctionsModal.vue'; import TheLogo from './TheLogo.vue'; import { WICOM, WICOM_SOCIAL } from '../config/brand'; import { @@ -27,6 +28,7 @@ const toasts = useToasts(); const fileMenuOpen = ref(false); const aboutOpen = ref(false); +const functionsOpen = ref(false); const busy = ref(false); const fileInput = ref(null); @@ -232,6 +234,11 @@ function onReset() { + + +