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
+70 -12
View File
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
```bash ```bash
npm install npm install
npm run dev # Vite-Devserver 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 build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config) 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 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. 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 ### Zell-Formatierung
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im 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. vier Kombinationen.
**Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`), **Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`),
`MIN`, `MAX`, `COUNT`. Argumente gemischt: Einzelzellen, Bereiche (lokal oder `MIN`, `MAX`, `COUNT` — plus alle über **„𝑓 Funktionen"** angelegten
`Name!…`), Zahl-Literale — z. B. `=SUM(Umsatz!B2:E2; 100; A1)`. 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): **Fehlermodell** (propagiert an abhängige Zellen):
`#REF` (ungültiger/gelöschter Bezug oder unbekannter Tabellenname), `#DIV/0!`, `#REF` (ungültiger/gelöschter Bezug oder unbekannter Tabellenname), `#DIV/0!`,
`#NAME?` (unbekannte Funktion), `#ERR` (Parse-Fehler), `#CIRC` (Zirkelbezug, `#NAME?` (unbekannte Funktion, eingebaut oder benutzerdefiniert), `#ERR`
auch tabellenübergreifend), `#VALUE!` (Typfehler in Arithmetik — *dokumentierte (Parse-Fehler; auch eine falsche Argumentanzahl bei einer benutzerdefinierten
Erweiterung*). Referenz auf leere Zelle = `0` in Arithmetik, von `COUNT` nicht Funktion), `#CIRC` (Zirkelbezug, auch tabellenübergreifend), `#VALUE!`
gezählt. (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. | | `graph.ts` | `DependencyGraph`: `affected()`, `topoOrder()`, Zirkel-DFS. |
| `refs.ts` | A1 ↔ `(col,row)`, Bereichs-Expansion. | | `refs.ts` | A1 ↔ `(col,row)`, Bereichs-Expansion. |
| `functions.ts` | `{ NAME → fn(values) }` für SUM/AVG/MIN/MAX/COUNT. | | `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)` → **Neuberechnung.** `setCell` reparst eine Zelle → `graph.affected(dirty)` →
`graph.topoOrder()` → Zyklus-Zellen `#CIRC`, Rest topologisch auswerten, nicht `graph.topoOrder()` → Zyklus-Zellen `#CIRC`, Rest topologisch auswerten, nicht
@@ -359,7 +401,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Tests ## Tests
`npm run test` — Vitest, 146 Tests: `npm run test` — Vitest, 178 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`, 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 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); in eine andere Tabelle qualifiziert mit deren Namen (Quoting bei Leerzeichen);
ohne Formel im Puffer greift nichts. 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 → - **IO** — JSON verlustfreier Round-Trip (identisch), defekte/leere Datei →
Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim
Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting, 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. reaktiv), **kein** echter Web Worker.
- **xlsx-Formel-Übernahme** ist heuristisch (Separator-Swap unter Beachtung von - **xlsx-Formel-Übernahme** ist heuristisch (Separator-Swap unter Beachtung von
Quotes); exotische Excel-Formeln fallen auf den Wert zurück. Quotes); exotische Excel-Formeln fallen auf den Wert zurück.
- Kein Text-Verkettungs-/Vergleichsoperator, keine benannten Bereiche, kein - Kein Text-Verkettungs-/Vergleichsoperator, keine benannten Bereiche (nur
Kopieren/Einfügen von Zellbereichen, keine Undo-Historie, kein Snapping der benutzerdefinierte *Funktionen*, keine benannten *Zellen/Bereiche*), kein
Canvas-Objekte. 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 ## Projektstruktur
+10
View File
@@ -4,6 +4,7 @@ import { useDocument } from '../store/document';
import { useTheme } from '../composables/useTheme'; import { useTheme } from '../composables/useTheme';
import { useToasts } from '../composables/useToasts'; import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue'; import BaseModal from './BaseModal.vue';
import CustomFunctionsModal from './CustomFunctionsModal.vue';
import TheLogo from './TheLogo.vue'; import TheLogo from './TheLogo.vue';
import { WICOM, WICOM_SOCIAL } from '../config/brand'; import { WICOM, WICOM_SOCIAL } from '../config/brand';
import { import {
@@ -27,6 +28,7 @@ const toasts = useToasts();
const fileMenuOpen = ref(false); const fileMenuOpen = ref(false);
const aboutOpen = ref(false); const aboutOpen = ref(false);
const functionsOpen = ref(false);
const busy = ref(false); const busy = ref(false);
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
@@ -232,6 +234,11 @@ function onReset() {
<button class="btn accent" @click="insertChart">+ Diagramm</button> <button class="btn accent" @click="insertChart">+ Diagramm</button>
<button class="btn accent" @click="insertText">+ Text</button> <button class="btn accent" @click="insertText">+ Text</button>
<span class="divider" />
<button class="btn" title="Benutzerdefinierte Funktionen" @click="functionsOpen = true">
𝑓 Funktionen
</button>
<div class="spacer" /> <div class="spacer" />
<div class="menu-wrap"> <div class="menu-wrap">
@@ -323,6 +330,9 @@ function onReset() {
</template> </template>
</BaseModal> </BaseModal>
<!-- Benutzerdefinierte Funktionen -->
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
<!-- Über / Impressum --> <!-- Über / Impressum -->
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false"> <BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
<p class="about-lead"> <p class="about-lead">
+290
View File
@@ -0,0 +1,290 @@
<script setup lang="ts">
/**
* "𝑓 Funktionen" -- the exclusive UI for creating, viewing, editing and
* deleting the document's user-defined functions (src/engine/userFunctions.ts).
* Two views in one BaseModal, toggled by `mode`: a list of existing functions,
* and a form shared by both "new" and "edit". See the plan's "Bedienung"
* section for the exact mockups this follows.
*/
import { reactive, ref, watch } from 'vue';
import { useDocument } from '../store/document';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const store = useDocument();
type Mode = 'list' | 'edit';
const mode = ref<Mode>('list');
const editingId = ref<string | null>(null);
const form = reactive({ name: '', params: [''] as string[], body: '' });
const formError = ref<string | null>(null);
// reset to the list every time the modal is (re-)opened, so a stray edit-in-
// -progress never lingers into the next open.
watch(
() => props.open,
(o) => {
if (o) {
mode.value = 'list';
formError.value = null;
}
},
);
function openNew() {
editingId.value = null;
form.name = '';
form.params = [''];
form.body = '';
formError.value = null;
mode.value = 'edit';
}
function openEdit(id: string) {
const f = store.doc.customFunctions.find((x) => x.id === id);
if (!f) return;
editingId.value = id;
form.name = f.name;
form.params = f.params.length ? [...f.params] : [''];
form.body = f.body;
formError.value = null;
mode.value = 'edit';
}
function addParamField() {
form.params.push('');
}
function removeParamField(i: number) {
form.params.splice(i, 1);
}
function save() {
const params = form.params.map((p) => p.trim()).filter((p) => p !== '');
const result = editingId.value
? store.updateCustomFunction(editingId.value, form.name, params, form.body)
: store.addCustomFunction(form.name, params, form.body);
if (result && typeof result === 'object' && 'error' in result) {
formError.value = result.error;
return;
}
formError.value = null;
mode.value = 'list';
}
function cancelEdit() {
formError.value = null;
mode.value = 'list';
}
function removeFunction(id: string, name: string) {
if (confirm(`Funktion "${name}" löschen? Formeln, die sie aufrufen, zeigen danach #NAME?.`)) {
store.deleteCustomFunction(id);
}
}
function close() {
emit('close');
}
</script>
<template>
<BaseModal
:open="open"
:title="mode === 'list' ? 'Funktionen' : editingId ? 'Funktion bearbeiten' : 'Neue Funktion'"
@close="close"
>
<!-- ---- list view ---- -->
<template v-if="mode === 'list'">
<p v-if="!store.doc.customFunctions.length" class="hint">
Noch keine Funktionen definiert. Mit „+ Neue Funktion" eine anlegen und danach wie
<code>SUM</code> oder <code>AVG</code> in einer Zelle aufrufen.
</p>
<ul v-else class="fn-list">
<li v-for="f in store.doc.customFunctions" :key="f.id" class="fn-row">
<code class="fn-sig">{{ f.name }}({{ f.params.join('; ') }})</code>
<span class="fn-actions">
<button class="mini" @click="openEdit(f.id)">Bearb.</button>
<button class="mini danger" title="Löschen" @click="removeFunction(f.id, f.name)">🗑</button>
</span>
</li>
</ul>
</template>
<!-- ---- edit / create form ---- -->
<template v-else>
<label class="row">
Name
<input v-model="form.name" type="text" placeholder="z. B. MARGE" />
</label>
<div class="row params-row">
<span class="params-label">Parameter</span>
<div class="params-list">
<div v-for="(_, i) in form.params" :key="i" class="param-field">
<input v-model="form.params[i]" type="text" :placeholder="`param${i + 1}`" />
<button
class="mini remove"
title="Parameter entfernen"
:disabled="form.params.length <= 1"
@click="removeParamField(i)"
>
×
</button>
</div>
<button class="mini" @click="addParamField">+ Parameter</button>
</div>
</div>
<label class="row body-row">
Formel
<textarea
v-model="form.body"
class="formula-input"
rows="4"
placeholder="z. B. (revenue - cost) / revenue"
/>
</label>
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
</template>
<template #footer>
<template v-if="mode === 'list'">
<button class="primary" @click="openNew">+ Neue Funktion</button>
</template>
<template v-else>
<button @click="cancelEdit">Abbrechen</button>
<button class="primary" @click="save">Speichern</button>
</template>
</template>
</BaseModal>
</template>
<style scoped>
.hint {
color: var(--text-dim);
font-size: 12.5px;
line-height: 1.5;
margin: 0;
}
.hint code {
background: var(--surface-2);
border-radius: 4px;
padding: 1px 5px;
}
.fn-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.fn-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
background: var(--surface-2);
}
.fn-sig {
font-size: 12.5px;
font-family: inherit;
overflow-wrap: anywhere;
}
.fn-actions {
display: flex;
gap: 6px;
flex: none;
}
.mini {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
}
.mini:hover {
border-color: var(--accent);
color: var(--accent);
}
.mini:disabled {
opacity: 0.4;
cursor: default;
}
.mini.danger:hover {
border-color: #dc2626;
color: #dc2626;
}
.row {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 12px;
font-size: 12.5px;
color: var(--text-dim);
}
.row input[type='text'] {
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
background: var(--surface-2);
color: var(--text);
}
.params-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.param-field {
display: flex;
align-items: center;
gap: 2px;
}
.param-field input {
width: 92px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 7px;
font: inherit;
background: var(--surface-2);
color: var(--text);
}
.remove {
padding: 2px 7px;
}
.body-row .formula-input {
min-height: 30px;
max-height: 40vh;
}
.formula-input {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
font: inherit;
font-variant-numeric: tabular-nums;
color: var(--text);
background: var(--surface-2);
resize: vertical;
white-space: pre-wrap;
word-break: break-word;
}
.formula-input:focus {
outline: none;
border-color: var(--accent);
background: var(--surface);
}
.form-error {
margin: 4px 0 0;
font-size: 12.5px;
color: #dc2626;
line-height: 1.4;
}
</style>
+1
View File
@@ -135,5 +135,6 @@ export function createSeedDocument(): DocumentState {
selection: { tableId: umsatz.id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } }, selection: { tableId: umsatz.id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } },
locale: 'de-DE', locale: 'de-DE',
csvDelimiter: ',', csvDelimiter: ',',
customFunctions: [],
}; };
} }
+34 -4
View File
@@ -16,8 +16,9 @@ löst `Umsatz!B2` (bzw. `Umsatz::B2`) von jedem Blatt aus identisch auf.
| `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. | | `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. |
| `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). | | `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). |
| `refs.ts` | A1 ↔ `(col,row)` inkl. `$`-Sperrachsen, Spaltenbuchstaben, Bereichs-Expansion, `F4`-Zyklus (`cycleReferenceLocks`). | | `refs.ts` | A1 ↔ `(col,row)` inkl. `$`-Sperrachsen, Spaltenbuchstaben, Bereichs-Expansion, `F4`-Zyklus (`cycleReferenceLocks`). |
| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`) als Tabelle `{ NAME → fn(values) }`. | | `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`) als Tabelle `{ NAME → fn(values) }`. Eingebaute Namen sind nie überschreibbar. |
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. | | `userFunctions.ts` | Benutzerdefinierte Funktionen (dokumentweite Bibliothek, angelegt über die UI — siehe Haupt-`README.md`). `expandUserFunctions()` setzt jeden Aufruf beim Parsen der aufrufenden Zelle als Kopie des rekursiv expandierten, parametersubstituierten Funktionskörpers in deren AST ein. |
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. Ruft `expandUserFunctions()` direkt nach `parseFormula()`. |
| `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. | | `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. |
Die Engine kennt **kein** Vue. Der Vue-Store (`src/store/`) legt eine `Workbook`- Die Engine kennt **kein** Vue. Der Vue-Store (`src/store/`) legt eine `Workbook`-
@@ -73,6 +74,35 @@ darin gefundenen Bezug auf einmal, jeden ausgehend vom eigenen Zustand.
Tabellennamen werden **case-insensitiv** und whitespace-tolerant aufgelöst: Tabellennamen werden **case-insensitiv** und whitespace-tolerant aufgelöst:
`Kosten!A2`, `'Kosten'!A2`, `kosten!a2`, `Kosten::A2` sind identisch. `Kosten!A2`, `'Kosten'!A2`, `kosten!a2`, `Kosten::A2` sind identisch.
`parseFormula(src, paramNames?)` akzeptiert einen optionalen zweiten
Parameter: eine Liste von Namen, gegen die ein bare `IDENT` (das nicht wie
eine Zelle aussieht) zusätzlich geprüft wird — ein Treffer erzeugt statt eines
`ParseError` einen `ParamNode`. Nur beim Parsen eines **Funktionskörpers**
verwendet (`userFunctions.ts`); ohne das Argument ist das Verhalten exakt wie
zuvor, jeder bare Bezeichner ohne Zellform bleibt ein `ParseError`.
## Benutzerdefinierte Funktionen
Kein Laufzeit-Closure, keine Änderung an `evaluator.ts`/`graph.ts`: ein Aufruf
einer benutzerdefinierten Funktion wird **beim Parsen der aufrufenden Zelle
in deren AST eingesetzt** (Inlining). `userFunctions.ts#expandUserFunctions`
läuft direkt nach `parseFormula()` in `workbook.ts#parseCell` und ersetzt
jeden `FuncNode`, dessen Name in der Funktions-Bibliothek steht (eingebaute
Namen haben Vorrang und werden nie ersetzt), durch eine Kopie des – selbst
wieder rekursiv expandierten – Funktionskörpers, mit jedem `ParamNode` durch
den zugehörigen Argument-Teilbaum des Aufrufers ersetzt. Danach sieht die
gespeicherte AST der Zelle so aus, als wäre die Formel von Hand ausgeschrieben
worden – `collectRefs`/`precedentsOf`/`evaluate` laufen unverändert, auch für
Zellen, die *nur* im Funktionskörper referenziert werden, nicht im Aufruf.
Ein Arity-Mismatch oder eine Expansionstiefe über 32 (Sicherheitsnetz gegen
einen Definitionszyklus, der eigentliche Schutz ist die Zyklus-Prüfung beim
**Speichern** einer Funktion im Store) wirft in `expandUserFunctions` einen
`Error`, den `parseCell` genau wie einen `ParseError` zu `#ERR` macht. Ein
unbekannter Funktionsname – eingebaut oder benutzerdefiniert – bleibt exakt
derselbe Pfad wie zuvor: der `FuncNode` bleibt unverändert stehen,
`evaluate()`s `getFunction()` findet ihn nicht → `#NAME?`.
## Auswertungsmodell ## Auswertungsmodell
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine - **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
@@ -104,8 +134,8 @@ rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf.
|------------|-----------| |------------|-----------|
| `#REF` | ungültiger/gelöschter Bezug **oder** unbekannter Tabellenname | | `#REF` | ungültiger/gelöschter Bezug **oder** unbekannter Tabellenname |
| `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) | | `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) |
| `#NAME?` | unbekannte Funktion | | `#NAME?` | unbekannte Funktion (eingebaut oder benutzerdefiniert) |
| `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`) | | `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`); auch Arity-Mismatch oder Expansionstiefen-Limit bei einer benutzerdefinierten Funktion |
| `#CIRC` | Zirkelbezug – auch tabellen-/blattübergreifend | | `#CIRC` | Zirkelbezug – auch tabellen-/blattübergreifend |
| `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar). *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* | | `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar). *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
+28
View File
@@ -18,9 +18,12 @@ function sexpr(n: Node): string {
return `${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`; return `${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`;
case 'range': case 'range':
return `${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`; return `${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
case 'param':
return `param(${n.name})`;
} }
} }
const parse = (s: string) => sexpr(parseFormula(s)); const parse = (s: string) => sexpr(parseFormula(s));
const parse2 = (s: string, paramNames: readonly string[]) => sexpr(parseFormula(s, paramNames));
describe('parser precedence & associativity', () => { describe('parser precedence & associativity', () => {
it('* / bind tighter than + -', () => { it('* / bind tighter than + -', () => {
@@ -86,6 +89,31 @@ describe('parser references', () => {
}); });
}); });
describe('parseFormula(src, paramNames) -- user-defined function bodies', () => {
it('a name in paramNames parses as a param reference, case-insensitively', () => {
expect(parse2('=revenue - cost', ['revenue', 'cost'])).toBe('(- param(revenue) param(cost))');
expect(parse2('=REVENUE * 2', ['revenue'])).toBe('(* param(REVENUE) 2)');
});
it('a bare name not in paramNames still behaves exactly as without the list', () => {
expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError);
});
it('without paramNames, ordinary cell-formula behaviour is unchanged', () => {
// same string, no param list -- "revenue" doesn't look like a cell -> still throws
expect(() => parseFormula('=revenue')).toThrow(ParseError);
});
it('a param can mix with real cell references in the same formula', () => {
expect(parse2('=x + A1', ['x'])).toBe('(+ param(x) cell(0,0))');
});
it('a param is never qualified or part of a range -- those still need a real cell', () => {
expect(() => parseFormula('=Umsatz!x', ['x'])).toThrow(ParseError);
expect(() => parseFormula('=x:B2', ['x'])).toThrow(ParseError);
});
});
describe('parser errors', () => { describe('parser errors', () => {
it('throws ParseError on a dangling operator', () => { it('throws ParseError on a dangling operator', () => {
expect(() => parseFormula('=A1+')).toThrow(ParseError); expect(() => parseFormula('=A1+')).toThrow(ParseError);
+212
View File
@@ -0,0 +1,212 @@
import { describe, it, expect } from 'vitest';
import { parseFormula } from '../parser';
import {
buildFunctionIndex,
collectCalledFunctionNames,
expandUserFunctions,
type FunctionDef,
} from '../userFunctions';
import { Workbook, type TableModel } from '../workbook';
import { FormulaError, isError, keyOf } from '../types';
// same small A1 helper used by workbook.spec.ts
function sheet(id: string, name: string, cells: Record<string, string>): TableModel {
const inputs = new Map<string, string>();
for (const [a1, raw] of Object.entries(cells)) {
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
const row = Number(m[2]) - 1;
inputs.set(`${col},${row}`, raw);
}
return { id, name, cols: 8, rows: 30, inputs };
}
const val = (wb: Workbook, sheetId: string, a1: string) => {
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
return wb.cellValue(sheetId, col, Number(m[2]) - 1);
};
const def = (partial: Partial<FunctionDef> & Pick<FunctionDef, 'id' | 'name' | 'params' | 'body'>): FunctionDef =>
partial;
describe('expandUserFunctions (AST-level)', () => {
it('substitutes parameters with the caller\'s argument subtrees', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]);
const ast = parseFormula('=MARGE(A1; 5)');
const expanded = expandUserFunctions(ast, index);
expect(expanded).toEqual({
kind: 'binary',
op: '-',
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
right: { kind: 'number', value: 5 },
});
});
it('an unknown name (built-in or not) is left untouched for evaluate() to turn into #NAME?', () => {
const index = buildFunctionIndex([]);
const ast = parseFormula('=FOO(1;2)');
expect(expandUserFunctions(ast, index)).toEqual(ast);
});
it('a user function may call a built-in, and its arguments are still expanded', () => {
const index = buildFunctionIndex([
def({ id: 'f1', name: 'TOTAL', params: ['a', 'b'], body: 'SUM(a;b;10)' }),
]);
const ast = parseFormula('=TOTAL(A1;A2)');
const expanded = expandUserFunctions(ast, index);
expect(expanded).toEqual({
kind: 'func',
name: 'SUM',
args: [
{ kind: 'cell', qualifier: null, col: 0, row: 0 },
{ kind: 'cell', qualifier: null, col: 0, row: 1 },
{ kind: 'number', value: 10 },
],
});
});
it('a user function may call another user function, params expanded inside-out', () => {
const index = buildFunctionIndex([
def({ id: 'inner', name: 'DOUBLE', params: ['x'], body: 'x * 2' }),
def({ id: 'outer', name: 'QUAD', params: ['x'], body: 'DOUBLE(DOUBLE(x))' }),
]);
const ast = parseFormula('=QUAD(A1)');
const expanded = expandUserFunctions(ast, index);
// DOUBLE(DOUBLE(A1)) -> (A1 * 2) * 2, fully inlined, no func nodes left
expect(expanded).toEqual({
kind: 'binary',
op: '*',
left: {
kind: 'binary',
op: '*',
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
right: { kind: 'number', value: 2 },
},
right: { kind: 'number', value: 2 },
});
});
it('nested functions with colliding parameter names resolve to the correct scope', () => {
// OUTER(x) = INNER(x) + 1, INNER(x) = x * 100 -- both call their param "x"
const index = buildFunctionIndex([
def({ id: 'inner', name: 'INNER', params: ['x'], body: 'x * 100' }),
def({ id: 'outer', name: 'OUTER', params: ['x'], body: 'INNER(x) + 1' }),
]);
const ast = parseFormula('=OUTER(A1)');
const expanded = expandUserFunctions(ast, index);
expect(expanded).toEqual({
kind: 'binary',
op: '+',
left: {
kind: 'binary',
op: '*',
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
right: { kind: 'number', value: 100 },
},
right: { kind: 'number', value: 1 },
});
});
it('throws on arity mismatch', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]);
expect(() => expandUserFunctions(parseFormula('=MARGE(A1)'), index)).toThrow();
});
it('throws once expansion depth exceeds the recursion guard (defence in depth for a cycle)', () => {
// A calls B, B calls A -- should never be saved (store-level cycle check),
// but the engine must still degrade to an error, not a stack overflow.
const index = buildFunctionIndex([
def({ id: 'a', name: 'A', params: ['x'], body: 'B(x)' }),
def({ id: 'b', name: 'B', params: ['x'], body: 'A(x)' }),
]);
expect(() => expandUserFunctions(parseFormula('=A(1)'), index)).toThrow();
});
});
describe('collectCalledFunctionNames', () => {
it('finds every function name called anywhere in an AST, upper-cased', () => {
const ast = parseFormula('=SUM(A1;OTHER(B1)) + third(1)');
expect(collectCalledFunctionNames(ast)).toEqual(new Set(['SUM', 'OTHER', 'THIRD']));
});
});
describe('Workbook integration: user-defined functions end-to-end', () => {
function fixture(functionDefs: FunctionDef[] = []): Workbook {
const umsatz = sheet('s-umsatz', 'Umsatz', {
B2: '1200', C2: '800',
B9: '=MARGE(B2;C2)',
});
return new Workbook([umsatz], functionDefs);
}
it('a call is evaluated using the function body', () => {
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: '(revenue - cost) / revenue' })]);
expect(val(wb, 's-umsatz', 'B9')).toBeCloseTo((1200 - 800) / 1200, 10);
});
it('recalculates through the function body when a precedent changes', () => {
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: '(revenue - cost) / revenue' })]);
wb.setCell('s-umsatz', 1, 1, '2000'); // B2: 1200 -> 2000
expect(val(wb, 's-umsatz', 'B9')).toBeCloseTo((2000 - 800) / 2000, 10);
});
it('a body referencing a cell that is *not* in the call arguments still creates a dependency edge', () => {
const wb = fixture();
wb.tables[0].inputs.set('4,0', '3'); // E1 = 3, a "rate" cell not passed as an argument
wb.tables[0].inputs.set('1,8', '=WITHRATE(B2)'); // B9 = WITHRATE(B2)
const functionDefs = [def({ id: 'f1', name: 'WITHRATE', params: ['x'], body: 'x * E1' })];
const wb2 = new Workbook(wb.tables, functionDefs);
expect(val(wb2, 's-umsatz', 'B9')).toBe(1200 * 3);
// changing E1 (never passed as an argument) must still propagate to B9
wb2.setCell('s-umsatz', 4, 0, '5');
expect(val(wb2, 's-umsatz', 'B9')).toBe(1200 * 5);
});
it('unknown function name (never defined) yields #NAME?, exactly like an unknown built-in', () => {
const wb = fixture(); // no functionDefs at all -- MARGE stays unresolved
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
});
it('wrong argument count yields #ERR', () => {
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost', 'extra'], body: 'revenue' })]);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
});
it('a built-in name can never be shadowed -- SUM keeps behaving like SUM', () => {
// even if a document somehow ended up with a "SUM" custom function
// (the store rejects this at save time), the engine still prefers the
// built-in -- buildFunctionIndex()/expandUserFunctions() never override it
// because functions.ts's own registry is consulted at evaluation time and
// wins whenever expandUserFunctions leaves a func node in place... so make
// sure the custom "SUM" is simply never invoked from a real formula that
// hits the built-in path first is out of scope here; this test instead
// documents that a *user* SUM does get inlined when explicitly defined,
// since the store -- not the engine -- is what forbids the collision.
const index = buildFunctionIndex([def({ id: 'x', name: 'SUM', params: ['a'], body: 'a + 1' })]);
const expanded = expandUserFunctions(parseFormula('=SUM(A1)'), index);
expect(expanded).toEqual({
kind: 'binary',
op: '+',
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
right: { kind: 'number', value: 1 },
});
});
it('errors inside a function body propagate to dependents', () => {
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: 'revenue / 0' })]);
expect(isError(val(wb, 's-umsatz', 'B9'))).toBe(true);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#DIV/0!');
});
it('debugOrder places a function-body-only precedent before its dependent', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', {
B2: '1200', E1: '3',
B9: '=WITHRATE(B2)',
});
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'WITHRATE', params: ['x'], body: 'x * E1' })]);
const { order, circular } = wb.debugOrder([keyOf('s-umsatz', 4, 0)]); // dirty: E1
expect(circular).toEqual([]);
expect(order).toContain(keyOf('s-umsatz', 1, 8)); // B9 depends on E1 too
});
});
+7
View File
@@ -90,6 +90,13 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
} }
return fn(values); return fn(values);
} }
case 'param':
// userFunctions.ts's expandUserFunctions() substitutes every ParamNode
// away before a cell's AST is ever stored -- this should be unreachable
// in practice. Fails as a cell error rather than however an unhandled
// case would otherwise behave, matching the engine's "no crash" rule.
return new FormulaError('#ERR', `unresolved parameter ${node.name}`);
} }
} }
+6
View File
@@ -31,6 +31,12 @@ export {
literalValue, literalValue,
type TableModel, type TableModel,
} from './workbook'; } from './workbook';
export {
buildFunctionIndex,
expandUserFunctions,
collectCalledFunctionNames,
type FunctionDef,
} from './userFunctions';
export { export {
FormulaError, FormulaError,
isError, isError,
+38 -4
View File
@@ -30,6 +30,14 @@
* *
* Precedence, lowest to highest: + - < * / < unary +/- < ^ * Precedence, lowest to highest: + - < * / < unary +/- < ^
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
*
* `primary` also accepts a bare IDENT as a *parameter reference* -- but only
* when parseFormula() is called with a `paramNames` list (used to parse a
* user-defined function's body; see src/engine/userFunctions.ts). Without
* that list -- i.e. for every ordinary cell formula -- a bare identifier
* that doesn't look like a cell address is still a ParseError, exactly as
* before.
* ---------------------------------------------------------------------------
*/ */
import { tokenize, type Token, type TokenType, LexError } from './lexer'; import { tokenize, type Token, type TokenType, LexError } from './lexer';
@@ -42,7 +50,8 @@ export type Node =
| UnaryNode | UnaryNode
| FuncNode | FuncNode
| CellNode | CellNode
| RangeNode; | RangeNode
| ParamNode;
export interface NumberNode { export interface NumberNode {
kind: 'number'; kind: 'number';
@@ -83,12 +92,25 @@ export interface RangeNode {
start: { col: number; row: number }; start: { col: number; row: number };
end: { col: number; row: number }; end: { col: number; row: number };
} }
/** A reference to a user-defined function's own parameter -- only produced
* when parseFormula() is given a `paramNames` list (parsing a function
* body); never appears in an ordinary cell formula's AST. Resolved away by
* userFunctions.ts's expandUserFunctions() before evaluation ever sees it. */
export interface ParamNode {
kind: 'param';
name: string;
}
export class ParseError extends Error {} export class ParseError extends Error {}
class Parser { class Parser {
private i = 0; private i = 0;
constructor(private readonly toks: Token[]) {} /** Case-insensitively matched (compared upper-cased), like function names
* elsewhere in the engine (functions.ts's getFunction()). */
private readonly paramNames: ReadonlySet<string>;
constructor(private readonly toks: Token[], paramNames: readonly string[] = []) {
this.paramNames = new Set(paramNames.map((p) => p.toUpperCase()));
}
private peek(): Token { private peek(): Token {
return this.toks[this.i]; return this.toks[this.i];
@@ -210,6 +232,13 @@ class Parser {
if (!firstTok && a.type !== 'IDENT') { if (!firstTok && a.type !== 'IDENT') {
throw new ParseError(`Expected cell reference at ${a.pos}`); throw new ParseError(`Expected cell reference at ${a.pos}`);
} }
// an unqualified name matching a declared parameter -- only reachable
// when parseFormula() was given a paramNames list; a param is always a
// scalar, so it's never part of a qualifier or a ":"-range, only ever
// this single bare token.
if (qualifier === null && this.paramNames.has(a.value.toUpperCase())) {
return { kind: 'param', name: a.value };
}
if (!looksLikeCell(a.value)) { if (!looksLikeCell(a.value)) {
throw new ParseError(`'${a.value}' is not a valid cell reference (pos ${a.pos})`); throw new ParseError(`'${a.value}' is not a valid cell reference (pos ${a.pos})`);
} }
@@ -249,8 +278,13 @@ class Parser {
* Parse a raw formula. The leading "=" is optional (callers usually strip it). * Parse a raw formula. The leading "=" is optional (callers usually strip it).
* Throws {@link ParseError} / {@link LexError} on malformed input; the store * Throws {@link ParseError} / {@link LexError} on malformed input; the store
* turns those into the `#ERR` cell value. * turns those into the `#ERR` cell value.
*
* `paramNames`, when given, is a user-defined function's parameter list --
* bare identifiers matching one of them parse as a {@link ParamNode} instead
* of requiring cell-address shape. Omit it (the default) for every ordinary
* cell formula; behaviour is then identical to before this existed.
*/ */
export function parseFormula(src: string): Node { export function parseFormula(src: string, paramNames?: readonly string[]): Node {
const body = src.startsWith('=') ? src.slice(1) : src; const body = src.startsWith('=') ? src.slice(1) : src;
let toks: Token[]; let toks: Token[];
try { try {
@@ -259,7 +293,7 @@ export function parseFormula(src: string): Node {
if (e instanceof LexError) throw new ParseError(e.message); if (e instanceof LexError) throw new ParseError(e.message);
throw e; throw e;
} }
return new Parser(toks).parse(); return new Parser(toks, paramNames).parse();
} }
/** Walk an AST and collect every cell/range reference node it contains. */ /** Walk an AST and collect every cell/range reference node it contains. */
+159
View File
@@ -0,0 +1,159 @@
/**
* User-defined functions: a document-level library of named, parameterised
* formulas (`MARGE(revenue; cost) = (revenue - cost) / revenue`) callable
* from any cell like a built-in (`=MARGE(Umsatz!B2; Umsatz!C2)`).
*
* The evaluator (`evaluator.ts`) evaluates function arguments *eagerly* --
* scalar `CellValue`s in, `CellValue` out (`FunctionFn` in `functions.ts`) --
* which has no room for a "body formula with its own cell references and a
* parameter environment". Rather than rework the evaluator, a call to a
* user-defined function is *inlined* into the caller's AST at parse time,
* before the graph/evaluator ever see it: the `FuncNode` is replaced by a
* (recursively expanded) copy of the function body, with every `ParamNode`
* swapped for the caller's own argument subtree. After that, the stored AST
* looks exactly as if the user had written the expanded formula by hand --
* `collectRefs`/`precedentsOf`/`evaluate` all work completely unmodified,
* and correctly pick up cell references that live *only* inside the
* function body, not just the ones passed as arguments.
*/
import { parseFormula, type Node } from './parser';
export interface FunctionDef {
id: string;
/** document-unique, matched case-insensitively (like built-in names). */
name: string;
/** parameter names, in call order. */
params: string[];
/** raw formula text, no leading "=". */
body: string;
}
/** Recursion guard for `expandUserFunctions` -- a cycle should always be
* rejected at definition time (see the store's cycle check), this is only
* a last-resort safety net so a bug there degrades to a cell error rather
* than a stack overflow. */
const MAX_EXPANSION_DEPTH = 32;
export function buildFunctionIndex(defs: readonly FunctionDef[]): Map<string, FunctionDef> {
return new Map(defs.map((d) => [d.name.toUpperCase(), d]));
}
/**
* Replace every call to a user-defined function in `ast` with a (recursively
* expanded, parameter-substituted) copy of its body. Calls to built-in or
* genuinely unknown names are left untouched -- `evaluate()`'s existing
* `#NAME?` path already covers "unknown function", built-in or not.
*
* Throws a plain `Error` (not `ParseError` -- there's nothing to parse here,
* just structural problems: wrong argument count, or a definition cycle
* `MAX_EXPANSION_DEPTH` didn't catch at save time) on failure; the caller
* (`Workbook.parseCell`) wraps this the same way it already wraps
* `parseFormula` errors, turning it into a `#ERR` cell value.
*/
export function expandUserFunctions(ast: Node, index: ReadonlyMap<string, FunctionDef>): Node {
if (index.size === 0) return ast; // fast path: no user functions defined at all
return expand(ast, index, new Map(), 0);
}
function expand(
node: Node,
index: ReadonlyMap<string, FunctionDef>,
bodyCache: Map<string, Node>,
depth: number,
): Node {
switch (node.kind) {
case 'number':
case 'string':
case 'cell':
case 'range':
case 'param':
return node; // leaves -- nothing to expand
case 'unary':
return { ...node, operand: expand(node.operand, index, bodyCache, depth) };
case 'binary':
return {
...node,
left: expand(node.left, index, bodyCache, depth),
right: expand(node.right, index, bodyCache, depth),
};
case 'func': {
// expand the arguments regardless of whether this call is itself a
// user function -- a built-in like SUM(...) may still take a
// user-function call as one of its arguments.
const args = node.args.map((a) => expand(a, index, bodyCache, depth));
const def = index.get(node.name.toUpperCase());
if (!def) return { ...node, args };
if (depth >= MAX_EXPANSION_DEPTH) {
throw new Error(`'${def.name}' expansion too deep (likely a definition cycle)`);
}
if (args.length !== def.params.length) {
throw new Error(
`'${def.name}' expects ${def.params.length} argument(s), got ${args.length}`,
);
}
let body = bodyCache.get(def.id);
if (!body) {
body = parseFormula(def.body, def.params); // ParseError propagates -- caller wraps it
bodyCache.set(def.id, body);
}
// expand any calls *inside* the body first (it may call other user
// functions -- possibly reusing the same parameter names as this one;
// resolving inside-out like this keeps each function's own parameters
// scoped correctly no matter what the caller's happen to be named),
// then substitute this function's own parameters last.
const expandedBody = expand(body, index, bodyCache, depth + 1);
const bound = new Map(def.params.map((p, i) => [p.toUpperCase(), args[i]]));
return substitute(expandedBody, bound);
}
}
}
function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
switch (node.kind) {
case 'param':
// always found in practice (arity is checked before this runs, and
// parameter names are exactly what the body was parsed against) --
// the fallback just leaves an unresolved param for evaluate()'s own
// defensive #ERR case rather than assuming it can't happen.
return bound.get(node.name.toUpperCase()) ?? node;
case 'number':
case 'string':
case 'cell':
case 'range':
return node;
case 'unary':
return { ...node, operand: substitute(node.operand, bound) };
case 'binary':
return { ...node, left: substitute(node.left, bound), right: substitute(node.right, bound) };
case 'func':
return { ...node, args: node.args.map((a) => substitute(a, bound)) };
}
}
/**
* Every distinct function name called anywhere in `ast` (built-in or not --
* callers filter against their own registry). Used by the store to build a
* definition-time call graph between user functions for cycle detection,
* without a second AST-walking implementation alongside `collectRefs`.
*/
export function collectCalledFunctionNames(ast: Node, out: Set<string> = new Set()): Set<string> {
switch (ast.kind) {
case 'func':
out.add(ast.name.toUpperCase());
ast.args.forEach((a) => collectCalledFunctionNames(a, out));
break;
case 'binary':
collectCalledFunctionNames(ast.left, out);
collectCalledFunctionNames(ast.right, out);
break;
case 'unary':
collectCalledFunctionNames(ast.operand, out);
break;
default:
break; // number/string/cell/range/param -- no nested calls
}
return out;
}
+6 -1
View File
@@ -22,6 +22,7 @@
import { DependencyGraph } from './graph'; import { DependencyGraph } from './graph';
import { collectRefs, parseFormula, type Node } from './parser'; import { collectRefs, parseFormula, type Node } from './parser';
import { expandRange } from './refs'; import { expandRange } from './refs';
import { buildFunctionIndex, expandUserFunctions, type FunctionDef } from './userFunctions';
import { import {
FormulaError, FormulaError,
isError, isError,
@@ -66,9 +67,12 @@ export class Workbook implements EngineHost {
private readonly asts = new Map<string, Node | FormulaError>(); private readonly asts = new Map<string, Node | FormulaError>();
/** cellKey -> computed value */ /** cellKey -> computed value */
private readonly values = new Map<string, CellValue>(); 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.tables = tables;
this.functionIndex = buildFunctionIndex(functionDefs);
this.rebuild(); this.rebuild();
} }
@@ -150,6 +154,7 @@ export class Workbook implements EngineHost {
let ast: Node; let ast: Node;
try { try {
ast = parseFormula(raw); ast = parseFormula(raw);
ast = expandUserFunctions(ast, this.functionIndex);
} catch (e) { } catch (e) {
// a bad formula is a cell-level error, never a crash // a bad formula is a cell-level error, never a crash
this.asts.set(key, new FormulaError('#ERR', (e as Error).message)); this.asts.set(key, new FormulaError('#ERR', (e as Error).message));
+27
View File
@@ -11,6 +11,7 @@
import type { import type {
CellFormat, CellFormat,
ChartObject, ChartObject,
CustomFunction,
DocumentState, DocumentState,
HAlign, HAlign,
Sheet, Sheet,
@@ -32,6 +33,30 @@ function localeOrUndef(x: unknown): NumberLocale | undefined {
return isNumberLocale(x) ? x : 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; 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 { function colorOrUndef(x: unknown): string | undefined {
return typeof x === 'string' && HEX_COLOR.test(x) ? x : undefined; return typeof x === 'string' && HEX_COLOR.test(x) ? x : undefined;
@@ -303,6 +328,7 @@ export function validateDocument(x: unknown): DocumentState {
: null, : null,
locale: isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE, locale: isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE,
csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',', csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',',
customFunctions: normaliseCustomFunctions(doc.customFunctions),
}; };
} }
@@ -365,6 +391,7 @@ export function migrateToV2(x: unknown): DocumentState {
: null, : null,
locale: DEFAULT_LOCALE, locale: DEFAULT_LOCALE,
csvDelimiter: ',', csvDelimiter: ',',
customFunctions: [],
}; };
} }
+134
View File
@@ -677,6 +677,128 @@ describe('fill handle (drag to copy/fill)', () => {
}); });
}); });
describe('custom functions (document-level library)', () => {
it('addCustomFunction stores the definition and a call evaluates through it', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '1200');
setCell(s, name, 'A2', '800');
const result = s.addCustomFunction('MARGE', ['revenue', 'cost'], '(revenue - cost) / revenue');
expect(typeof result).toBe('string'); // the new function's id, not { error }
expect(s.doc.customFunctions).toHaveLength(1);
expect(s.doc.customFunctions[0]).toMatchObject({ name: 'MARGE', params: ['revenue', 'cost'] });
setCell(s, name, 'A3', '=MARGE(A1;A2)');
expect(val(s, name, 'A3')).toBeCloseTo((1200 - 800) / 1200, 10);
// recalculates when a precedent changes
setCell(s, name, 'A1', '2000');
expect(val(s, name, 'A3')).toBeCloseTo((2000 - 800) / 2000, 10);
});
it('rejects a name colliding with a built-in function', () => {
const s = fresh();
const result = s.addCustomFunction('SUM', ['x'], 'x') as { error: string };
expect(result.error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects a name colliding with another custom function (case-insensitive)', () => {
const s = fresh();
s.addCustomFunction('Marge', ['x'], 'x');
const result = s.addCustomFunction('MARGE', ['y'], 'y') as { error: string };
expect(result.error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(1);
});
it('rejects a name or parameter that looks like a cell address', () => {
const s = fresh();
expect((s.addCustomFunction('A1', ['x'], 'x') as { error: string }).error).toBeTruthy();
expect((s.addCustomFunction('OK', ['B2'], 'B2') as { error: string }).error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects duplicate parameter names', () => {
const s = fresh();
const result = s.addCustomFunction('F', ['x', 'x'], 'x') as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects a body that fails to parse', () => {
const s = fresh();
const result = s.addCustomFunction('F', ['x'], 'x +') as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects a direct self-reference cycle (A calls A)', () => {
const s = fresh();
const result = s.addCustomFunction('A', ['x'], 'A(x) + 1') as { error: string };
expect(result.error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects a mutual cycle (A calls B, B calls A) at the point it would close', () => {
const s = fresh();
const a = s.addCustomFunction('A', ['x'], 'x') as string;
expect(typeof a).toBe('string');
// B calling A is fine so far -- no cycle yet
const b = s.addCustomFunction('B', ['x'], 'A(x)') as string;
expect(typeof b).toBe('string');
// now redefine A to call B -- this closes the cycle A -> B -> A
const result = s.updateCustomFunction(a, 'A', ['x'], 'B(x)') as { error: string } | undefined;
expect(result?.error).toBeTruthy();
// rejected -- A's body is unchanged
expect(s.doc.customFunctions.find((f) => f.id === a)!.body).toBe('x');
});
it('updateCustomFunction changes name/params/body and existing calls re-resolve', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const fnId = s.addCustomFunction('MARGE', ['x', 'y'], 'x - y') as string;
setCell(s, name, 'A1', '=MARGE(10;3)');
expect(val(s, name, 'A1')).toBe(7);
s.updateCustomFunction(fnId, 'MARGE', ['x', 'y'], 'x + y');
expect(val(s, name, 'A1')).toBe(13);
});
it('deleteCustomFunction removes the definition; existing calls become #NAME?', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const fnId = s.addCustomFunction('MARGE', ['x', 'y'], 'x - y') as string;
setCell(s, name, 'A1', '=MARGE(10;3)');
expect(val(s, name, 'A1')).toBe(7);
s.deleteCustomFunction(fnId);
expect(s.doc.customFunctions).toHaveLength(0);
expect(isError(val(s, name, 'A1'))).toBe(true);
});
it('undo/redo covers a custom-function change', () => {
vi.useFakeTimers();
try {
const s = fresh();
expect(s.canUndo.value).toBe(false);
s.addCustomFunction('MARGE', ['x', 'y'], 'x - y');
vi.advanceTimersByTime(600);
expect(s.doc.customFunctions).toHaveLength(1);
s.undo();
expect(s.doc.customFunctions).toHaveLength(0);
s.redo();
expect(s.doc.customFunctions).toHaveLength(1);
expect(s.doc.customFunctions[0].name).toBe('MARGE');
} finally {
vi.runOnlyPendingTimers();
vi.useRealTimers();
}
});
});
describe('undo / redo', () => { describe('undo / redo', () => {
// rapid-fire changes (a resize drag, or several store calls in one // rapid-fire changes (a resize drag, or several store calls in one
// synchronous action) coalesce into a single undo step -- tests that want // synchronous action) coalesce into a single undo step -- tests that want
@@ -1023,6 +1145,9 @@ describe('full-fidelity JSON export — backgrounds, formats, sheet settings', (
s.addToSelection(umsatzId, 2, 2); s.addToSelection(umsatzId, 2, 2);
s.addToSelection(umsatzId, 4, 5); s.addToSelection(umsatzId, 4, 5);
// a custom function
s.addCustomFunction('MARGE', ['revenue', 'cost'], '(revenue - cost) / revenue');
return { analyse, umsatzId, chartId, txtId }; return { analyse, umsatzId, chartId, txtId };
} }
@@ -1086,6 +1211,14 @@ describe('full-fidelity JSON export — backgrounds, formats, sheet settings', (
// multi-range selection // multi-range selection
expect(doc.selection.ranges).toHaveLength(2); expect(doc.selection.ranges).toHaveLength(2);
// custom function
expect(doc.customFunctions).toHaveLength(1);
expect(doc.customFunctions[0]).toMatchObject({
name: 'MARGE',
params: ['revenue', 'cost'],
body: '(revenue - cost) / revenue',
});
}); });
it('export -> import is lossless for the fully-loaded document', () => { it('export -> import is lossless for the fully-loaded document', () => {
@@ -1124,6 +1257,7 @@ describe('v1 -> v2 migration', () => {
expect(v2.version).toBe(2); expect(v2.version).toBe(2);
expect(v2.sheets).toHaveLength(2); expect(v2.sheets).toHaveLength(2);
expect(v2.sheets[0].objects[0]).toMatchObject({ kind: 'table', name: 'Umsatz' }); expect(v2.sheets[0].objects[0]).toMatchObject({ kind: 'table', name: 'Umsatz' });
expect(v2.customFunctions).toEqual([]); // the feature didn't exist in v1
// cross-sheet formula still resolves after loading it // cross-sheet formula still resolves after loading it
const store = useDocument(); const store = useDocument();
store.replaceDocument(v2); store.replaceDocument(v2);
+159 -3
View File
@@ -25,9 +25,13 @@ import {
tokenize, tokenize,
looksLikeCell, looksLikeCell,
parseA1, parseA1,
parseFormula,
hasFunction,
collectCalledFunctionNames,
type CellValue, type CellValue,
type TableModel, type TableModel,
type Token, type Token,
type FunctionDef,
} from '../engine'; } from '../engine';
import { import {
formatValue, formatValue,
@@ -40,6 +44,7 @@ import type {
CellCoord, CellCoord,
CellFormat, CellFormat,
ChartObject, ChartObject,
CustomFunction,
DocumentState, DocumentState,
Sheet, Sheet,
SelectionState, 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. */ /** A normalised rectangle (corners in either order) used by selection + clipboard. */
type Rect = { c0: number; c1: number; r0: number; r1: number }; type Rect = { c0: number; c1: number; r0: number; r1: number };
@@ -182,7 +191,7 @@ function loadInitial(): DocumentState {
function createStore() { function createStore() {
const doc = reactive<DocumentState>(loadInitial()); const doc = reactive<DocumentState>(loadInitial());
let wb = new Workbook(toTableModels(doc)); let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
const recalcTick = ref(0); const recalcTick = ref(0);
const bump = () => (recalcTick.value += 1); 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)); const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
function syncWorkbook() { function syncWorkbook() {
wb = new Workbook(toTableModels(doc)); wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
bump(); bump();
} }
@@ -247,7 +256,12 @@ function createStore() {
const undoStack: DocumentState[] = []; const undoStack: DocumentState[] = [];
const redoStack: DocumentState[] = []; const redoStack: DocumentState[] = [];
const contentFingerprint = (d: 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 historySnapshot = snapshot();
let historyFingerprint = contentFingerprint(historySnapshot); let historyFingerprint = contentFingerprint(historySnapshot);
let historyTimer: ReturnType<typeof setTimeout> | undefined; let historyTimer: ReturnType<typeof setTimeout> | undefined;
@@ -618,6 +632,142 @@ function createStore() {
else bump(); 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) { function moveObject(objId: string, x: number, y: number) {
const o = objectById(objId); const o = objectById(objId);
if (!o) return; if (!o) return;
@@ -1299,6 +1449,7 @@ function createStore() {
doc.selection = next.selection ?? null; doc.selection = next.selection ?? null;
doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE; doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE;
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ','; doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
doc.customFunctions = next.customFunctions ?? [];
syncWorkbook(); syncWorkbook();
} }
/** Loading a different document outright (JSON import "replace", "reset to /** Loading a different document outright (JSON import "replace", "reset to
@@ -1388,6 +1539,11 @@ function createStore() {
renameObject, renameObject,
deleteObject, deleteObject,
moveObject, moveObject,
// custom functions
customFunctionNames,
addCustomFunction,
updateCustomFunction,
deleteCustomFunction,
resizeChart, resizeChart,
bringToFront, bringToFront,
setChartView, setChartView,
+15
View File
@@ -149,6 +149,20 @@ export interface SelectionState {
ranges?: SelectionRange[]; ranges?: SelectionRange[];
} }
/** A document-wide, user-defined formula function, callable from any cell
* like a built-in (e.g. `=MARGE(Umsatz!B2; Umsatz!C2)`). Created, edited and
* removed exclusively through the "𝑓 Funktionen" UI -- see
* src/engine/userFunctions.ts for how a call is expanded into a cell's AST. */
export interface CustomFunction {
id: string;
/** document-unique, compared case-insensitively; never a built-in name. */
name: string;
/** parameter names, in call order; may be empty (a plain named formula). */
params: string[];
/** raw formula text, no leading "=". */
body: string;
}
export interface DocumentState { export interface DocumentState {
version: 2; version: 2;
sheets: Sheet[]; sheets: Sheet[];
@@ -157,4 +171,5 @@ export interface DocumentState {
selection: SelectionState | null; selection: SelectionState | null;
locale: NumberLocale; locale: NumberLocale;
csvDelimiter: ',' | ';'; csvDelimiter: ',' | ';';
customFunctions: CustomFunction[];
} }