Add Rails backend: accounts via magic link, server documents, sharing

The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
+594
View File
@@ -0,0 +1,594 @@
# Formel-Engine
Selbst geschriebene, framework-agnostische Formel-Engine. Kein HyperFormula, kein
`formula-parser`, keine Spreadsheet-Library. Reines ES-Modul, in Vitest getestet.
Die adressierbare Einheit ist eine **Tabelle** – ein Tabellen-Objekt auf einer
Numbers-artigen Blattfläche. Tabellennamen sind nur **pro Blatt** eindeutig
(zwei Blätter dürfen je eine Tabelle "Umsatz" haben) – `Umsatz!B2` (bzw.
`Umsatz::B2`) löst dann zur Tabelle auf dem **gleichen Blatt wie die Formel**
auf, sofern eindeutig; die explizite Form `Blatt!Tabelle::B2` trifft immer
genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter unten.
## Module
| Datei | Aufgabe |
|----------------|---------|
| `lexer.ts` | Tokenizer. Flacher Token-Strom, keine strukturellen Entscheidungen. Kennt `!` und `::`. |
| `parser.ts` | Recursive-Descent → AST. Grammatik als EBNF-Kommentarblock im Kopf der Datei. |
| `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. Enthält auch den kleinen Interpreter für `kind: 'block'`-Funktionsaufrufe (`BlockCallNode`) – siehe „Schleifen" unten. |
| `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`), Funktionsnamen-Erkennung fürs Autovervollständigen (`currentIdentifierAtCaret`). |
| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`), `DATE/TIME/TODAY/NOW` als Tabelle `{ NAME → fn(values) }`. `IF` und `XLOOKUP` sind hier nur als Platzhalter registriert (für `hasFunction()`/Namensreservierung); die echte Auswertung sitzt in `evaluator.ts`, s. u. Eingebaute Namen sind nie überschreibbar. |
| `dates.ts` | Datum/Zeit als Zahl ("Serial", Tage seit 1899-12-30): `dateToSerial`/`timeSerial` (Bausteine für `DATE`/`TIME`), `todaySerial`/`nowSerial`, `serialToUtcDate` (für die Anzeige), `parseDateTimeLiteral` (Eingabe-Erkennung, s. u. „Datum & Zeit"). |
| `userFunctions.ts` | Benutzerdefinierte Funktionen (dokumentweite Bibliothek, angelegt über die UI — siehe Haupt-`README.md`). `expandUserFunctions()` setzt einen `kind: 'expression'`-Aufruf beim Parsen der aufrufenden Zelle als Kopie des rekursiv expandierten, parametersubstituierten Funktionskörpers in deren AST ein; einen `kind: 'block'`-Aufruf (FOR/IF/RETURN-Code) verpackt sie stattdessen in einen `BlockCallNode` mit bereits aufgelösten verschachtelten Aufrufen, siehe „Schleifen" unten. |
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. Ruft `expandUserFunctions()` direkt nach `parseFormula()`. `literalValue()` erkennt Datums-/Zeit-Eingaben (`dates.ts`) vor dem generischen Zahl-Test. |
| `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. |
Die Engine kennt **kein** Vue. Der Vue-Store (`src/store/`) legt eine `Workbook`-
Instanz in ein `reactive()` und spiegelt berechnete Werte über einen
`recalcTick`-Ref – dadurch verhält sich jede Formelzelle im UI wie ein `computed`.
## Grammatik (EBNF)
```ebnf
formula = "=" expression ;
expression = comparison ;
comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ; (* höchstens
ein Vergleich, nicht
verkettbar; Ergebnis
1/0 (siehe Auswertungs-
modell unten) *)
concat = additive { "&" additive } ; (* links-assoziativ; Text-
Verkettung, zwischen
Vergleich und "+ -" *)
additive = term { ("+" | "-") term } ; (* links-assoziativ; die
alte "expression"-Regel,
bevor Vergleiche
existierten *)
term = unary { ("*" | "/") unary } ; (* links-assoziativ *)
unary = ("+" | "-") unary
| power ;
power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet
stärker als unäres Minus:
-2^2 = -(2^2) = -4 *)
primary = NUMBER
| STRING
| ERRORLIT
| "(" expression ")"
| funcall
| reference
| namedref ;
funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
namedref = IDENT ; (* unqualifizierter, nicht zellförmiger
Bezeichner, kein Parameter -- ein
benannter-Bereich-Kandidat (s. u.),
nur außerhalb eines Funktionskörpers *)
reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname; "!" und der
Numbers-Stil "::" sind
austauschbar *)
sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt,
wenn direkt gefolgt von
vollem "Tabelle::" (die
3-teilige Form); ein
einzelnes "X!Y" bleibt
die alte, rückwärts-
kompatible 2-teilige
Form (X = Tabelle) *)
NUMBER = /[0-9]+ ("." [0-9]+)? ([eE] [+-]? [0-9]+)?/ | /"." [0-9]+/ ;
STRING = /" ... "/ (Backslash-Escapes) ;
IDENT = /[A-Za-z_][A-Za-z0-9_]*/ ;
QUOTED = /' ( [^'] | '' )* '/ (* Tabellenname mit Leer-/Sonderzeichen *)
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
ERRORLIT = "#REF" | "#DIV/0!" | "#NAME?" | "#ERR" | "#CIRC" |
"#VALUE!" | "#N/A" ; (* longest-match in the lexer -- see
lexer.ts's ERROR_CODES_BY_LENGTH *)
```
Präzedenz, schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` < unäres `+ -` < `^`.
`$` sperrt eine Referenz-Achse: `$A1` (Spalte fix), `A$1` (Zeile fix), `$A$1`
(beide fix) vs. `A1` (relativ). Für die **Auswertung** ist das irrelevant – sie
ist rein positionsbasiert, `$` wird geparst und sonst ignoriert. Es wirkt sich
ausschließlich beim **Kopieren/Ausschneiden** aus (`shiftFormula` in
`src/store/document.ts`, außerhalb der Engine): eine gesperrte Achse bleibt
beim *Kopieren* exakt wie eingegeben stehen, folgt aber beim *Ausschneiden*
trotzdem der verschobenen Zelle (dort verschiebt sich die referenzierte Zelle
selbst – `$` ändert nur, ob eine *Kopie* mitzieht, nicht wohin ein *Verschieben*
zeigt). `refs.ts#cycleReferenceLocks` implementiert den `F4`-Zyklus
(relativ → `$A$1` → `A$1` → `$A1` → relativ) für Formelleiste und Zell-Editor
— mit Cursor nur den einen Bezug dort, mit echter Textauswahl **jeden**
darin gefundenen Bezug auf einmal, jeden ausgehend vom eigenen Zustand.
Tabellennamen werden **case-insensitiv** und whitespace-tolerant aufgelöst:
`Kosten!A2`, `'Kosten'!A2`, `kosten!a2`, `Kosten::A2` sind identisch.
`refs.ts#currentIdentifierAtCaret(formula, caret)` treibt die Funktions-
Autovervollständigung (Formelleiste und Zell-Editor, `src/composables/
useFormulaAutocomplete.ts`): strukturell dasselbe Muster wie
`cycleReferenceLocks` (`=` abstreifen, tokenizen, am Cursor nachsehen), aber
nur der Text **bis** zum Cursor wird überhaupt tokenisiert (was danach
kommt, spielt nie eine Rolle — wie bei den meisten Editoren), und nur das
*letzte* Token zählt. Ein `IDENT`-Token, das bis genau an den Cursor reicht
und nicht wie ein Zellbezug aussieht (`looksLikeCell`), ist der gerade
getippte Funktionsname; alles andere (kein Token dort, ein Zellbezug, der
Cursor sitzt hinter Leerraum nach einem abgeschlossenen Wort, `tokenize()`
wirft bei einem kaputten Präfix) liefert `null` — kein Dropdown.
`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 (`paramNames === undefined`, jede gewöhnliche
Top-Level-Zellformel) hat sich das Verhalten für einen bare Bezeichner ohne
Zellform seit **benannten Bereichen** geändert: er parst jetzt als
`NamedRefNode` (Feld `namedRefFallback` am `Parser`, s. u. „Benannte
Bereiche") statt als `ParseError`. **Mit** `paramNames` — auch als leeres
Array, d. h. innerhalb eines Funktionskörpers — bleibt das alte, strikte
Verhalten exakt erhalten: ein bare, nicht-zellförmiger, nicht-Parameter-Name
ist weiterhin ein harter `ParseError`. Grund: eine lokale/Schleifen-Variable,
die vor ihrer eigenen deklarierenden Anweisung verwendet wird (`x = x + 1`),
soll sofort beim Speichern scheitern, statt sich still zu einem
gleichnamigen benannten Bereich im Dokument aufzulösen (oder erst zur
Laufzeit als `#NAME?` aufzufallen).
## Blatt-Qualifier ("Blatt!Tabelle::Zelle")
`parser.ts#Parser.afterFirstQualifierToken` entscheidet per Lookahead, ob ein
`!` einen (alten) Tabellen-Qualifier einleitet oder einen **Blatt**-Qualifier:
nur wenn direkt danach `IDENT/QUOTED "::"` folgt, ist es ein Blattname und der
Parser konsumiert drei Segmente (`Blatt`, `Tabelle`, `Zelle`) statt zwei —
sonst bleibt die alte, einstufige Bedeutung (`X!Y` = Tabelle `X`, Zelle `Y`)
exakt erhalten. `CellNode`/`RangeNode` bekommen dafür ein optionales
`sheetQualifier?: string`-Feld neben dem bestehenden `qualifier`.
Die Auflösung passiert in `workbook.ts#Workbook.resolveTableName(name,
sheetName?)` (Teil von `EngineHost`):
- `sheetName` gegeben (3-teilige Form) → nur eine Tabelle mit diesem Namen
**auf diesem Blatt** trifft; sonst `null` (→ `#REF`).
- `sheetName` fehlt und der Tabellenname ist **eindeutig** im Dokument → wie
bisher, unverändert.
- `sheetName` fehlt und der Name kommt auf **mehreren** Blättern vor →
bevorzugt die Tabelle auf demselben Blatt wie `this.homeTableId` (dafür
setzen sowohl `evaluateCell` als auch `precedentsOf` `homeTableId`, bevor
sie referenzieren — Letzteres, damit auch die **Graph-Kanten** beim Parsen
schon dieselbe Auflösung wie später die Auswertung verwenden). Bleibt es
danach mehrdeutig: `null` (→ `#REF`).
`TableModel` (workbook.ts) trägt dafür zusätzlich `sheetName: string`. Die
Store-Seite (`document.ts#toTableModels`) befüllt es aus dem Blatt, dem die
Tabelle gehört; `tableNamesOnSheet(sheetId)`/`objectNamesOnSheet(sheetId)`
ersetzen dort die früher dokumentweiten Eindeutigkeits-Prüfungen beim Anlegen/
Umbenennen einer Tabelle. `qualifyRef()` (Point-Mode) fügt die volle
3-teilige Form nur dann ein, wenn der angeklickte Tabellenname wirklich
mehrdeutig **und** auf einem anderen Blatt als der Editor liegt — sonst reicht
weiterhin die einfache Form.
## 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?`.
## Benannte Bereiche
Anders als eine benutzerdefinierte Funktion wird ein `NamedRefNode`
**nicht** beim Parsen aufgelöst/inlined, sondern **live, bei jeder
Auswertung**, gegen `EngineHost.resolveNamedRange(name)` – dieselbe
"Name wird nicht in die AST gebacken"-Philosophie wie ein Tabellen-Qualifier
(auch der wird nie zur Parse-Zeit zu einer Tabellen-*ID* aufgelöst). Grund:
so übersteht ein benannter Bereich eine spätere Umbenennung *seiner*
Zieltabelle, ohne dass irgendeine gespeicherte Formel neu geparst werden
müsste.
- `types.ts#NamedRangeDef` (`{id, name, tableId, start, end}`) wird dem
`Workbook`-Konstruktor als dritter Parameter mitgegeben (analog zu den
`FunctionDef`s); `store/document.ts#toNamedRangeDefs` mappt
`DocumentState.namedRanges` dorthin.
- `workbook.ts#Workbook.resolveNamedRange(name)` sucht case-insensitiv/
getrimmt (dieselbe Konvention wie `resolveTableName`), löst `tableId` über
`tableById()` in den **aktuellen** Tabellennamen auf und gibt
`{qualifier, sheetQualifier, start, end}` zurück – exakt die Form, die ein
`CellNode`/`RangeNode` sonst auch trägt. Zieltabelle nicht mehr vorhanden
→ `null` → `#NAME?` (dieselbe Fehlerklasse wie ein unbekannter Name).
- `evaluator.ts#resolveNamedRef()` baut aus diesem Ergebnis einen
synthetischen `CellNode` (1×1-Bereich) oder `RangeNode` und lässt jeden
Aufrufer (`evalScalar`s `case 'namedref'`, `evalArg`,
`rangeOrCellToCellNodes` für eine `FOR`-Quelle) in die **bereits
vorhandene** Zell-/Bereichs-Logik durchfallen – keine Duplikation.
- `parser.ts#collectRefs` sammelt einen `NamedRefNode` unaufgelöst (der
Parser kennt das Dokument nicht); `workbook.ts#precedentsOf` löst ihn
dann über `resolveNamedRange` in konkrete Zell-Keys für den
Abhängigkeitsgraphen auf – unbekannter Name → keine Kante (keine
Neuberechnung nötig, `evaluate()` liefert ohnehin `#NAME?`).
- **Grammatik-Änderung, die das erst ermöglicht:** ein unqualifizierter,
nicht zellförmiger, nicht Parameter-Bezeichner ist jetzt ein `NamedRefNode`
statt eines `ParseError` – aber **nur außerhalb eines Funktionskörpers**
(`Parser`s `namedRefFallback`-Flag, `true` nur wenn `parseFormula()` ganz
ohne `paramNames`-Argument aufgerufen wird). Ein Aufrufer kann trotzdem
einen benannten Bereich als Argument an eine Funktion übergeben – der
`NamedRefNode` steht dann in `node.args`, wird über
`expand()`/`substitute()` (beide bekamen dafür einen `case 'namedref':
return node;`-Leaf, wie `cell`/`range`) an die passende Parameterstelle im
Funktionskörper substituiert, und trifft dort z. B. als `FOR`-Quelle auf
`rangeOrCellToCellNodes`, das ihn ganz normal auflöst.
`userFunctions.ts#findInvalidForSource` erlaubt `namedref` deshalb
ausdrücklich als gültige `FOR`-Quellenform (neben `param`/`cell`/`range`).
- **Kein Zyklus-Check.** Ein benannter Bereich hat keinen eigenen Ausdruck,
nur feste Koordinaten – er kann sich nicht selbst referenzieren, anders
als eine benutzerdefinierte Funktion.
## Schleifen ("Mit Schleife"-Funktionen, `kind: 'block'`)
Eine zweite, eigene Grammatik nur für Funktionskörper mit `kind: 'block'`
(neben der oben beschriebenen Ausdrucks-Form, `kind: 'expression'`, die
unverändert bleibt) – mit echtem Kontrollfluss:
```ebnf
functionBlock = block ; (* ein Block = die ganze Funktion *)
block = { statement } ;
statement = assignment | forLoop | ifStmt | returnStmt ;
assignment = IDENT "=" expression ;
forLoop = "FOR" IDENT "IN" expression "DO" block "END" ;
ifStmt = "IF" expression "THEN" block [ "ELSE" block ] "END" ;
returnStmt = "RETURN" expression ;
```
`FOR`/`IN`/`DO`/`END`/`IF`/`THEN`/`ELSE`/`RETURN` sind – wie Funktionsnamen –
gewöhnliche `IDENT`-Token, case-insensitiv per Wortvergleich erkannt
(`Parser.isKeyword`), kein eigener Lexer-Token dafür nötig; `=` als Zuweisung
nutzt den `EQUALS`-Token (rein additiv – vorher ein `LexError`, kann also
keine vorher gültige Formel beeinflussen; derselbe Token dient in
`expression()` als Gleichheits-Vergleich, die beiden Grammatiken kollidieren
nicht, weil eine Zuweisung nur an Anweisungs-Anfang steht). `RETURN` ist eine
gewöhnliche **Anweisung**, keine feste Blockendung – sie darf überall
stehen, auch verschachtelt in `FOR`/`IF`.
`parseFunctionBlock(src, paramNames)` (`parser.ts`) parst rekursiv (`parse-
StatementBlock` ruft sich über `FOR`/`IF`-Körper selbst wieder auf): jeder
eingeführte Name (Zuweisungsziel oder Schleifenvariable) wird sofort zu
`Parser.paramNames` hinzugefügt – **flacher Namensraum**, kein Scope-Stack,
ein Name bleibt für den *Rest* der Funktion sichtbar, auch nach seinem
eigenen `FOR`/`IF`. `AssignNode`/`ForNode`/`IfNode`/`ReturnNode`/`BlockNode`
sind bewusst **nicht** Teil des `Node`-Unions – nur der eine Knoten, der
tatsächlich in einer normalen Zell-AST landet, ist es:
```ts
interface BlockCallNode {
kind: 'blockcall';
block: BlockNode; // bereits: verschachtelte Aufrufe anderer Funktionen aufgelöst
args: Node[]; // die echten Argument-Ausdrücke des Aufrufers
paramNames: string[];
label: string; // für Fehlermeldungen
}
```
**Warum kein Abrollen mehr (wie noch bei einer früheren Version dieses
Features):** ein `IF`-Zweig oder ein bedingtes `RETURN` hängt vom
tatsächlichen Zellwert ab – das lässt sich nicht mehr rein aus der
Bereichsgröße vorausberechnen. `userFunctions.ts#expand()`s `func`-Fall löst
für `kind: 'block'` deshalb nur noch **verschachtelte Funktionsaufrufe**
innerhalb des Blocks auf (`expandBlock()`, läuft wie `expand()` durch jede
Anweisung/jeden verschachtelten `FOR`/`IF`-Körper, mit denselben "von innen
nach außen"-Regeln wie bisher) und verpackt das Ergebnis unverändert in einen
`BlockCallNode` – die *eigenen* Parameter dieser Funktion bleiben als
`ParamNode` im Block stehen, erst zur Auswertungszeit gebunden.
**Der Interpreter (`evaluator.ts`)** läuft, wenn `evalScalar` auf einen
`blockcall`-Knoten trifft:
- `evaluateBlockCall(node, host)` baut die Startumgebung `env: Map<string,
Node>` aus `paramNames`/`args` und ruft `execBlock`/`execStatement` auf.
- Eine `assignment`/`return`/`ifstmt`-Bedingung wird über `substitute(expr,
env)` (aus `userFunctions.ts`, dafür **exportiert**) gegen `env` aufgelöst
und dann ganz normal `evalScalar`-ausgewertet; das Ergebnis wird (als
Literal-`Node`) zurück in `env` geschrieben.
- Eine `forLoop` löst ihre (substituierte) Quelle über `rangeOrCellToCell-
Nodes()` in eine konkrete Zellliste auf (`expandRange`, wie
`evalArg` es für ein Bereichsargument tut) – ein Einzelzellbezug ergibt
genau eine Iteration – und führt `body` (ein vollständiger, ggf.
verschachtelter `BlockNode`) einmal je Zelle aus, in Zellreihenfolge.
Schleifenvariable und alle im Körper zugewiesenen Namen bleiben danach in
`env` (flacher Namensraum: sichtbar mit ihrem letzten Wert für den Rest der
Ausführung).
- `return` wirft ein `ReturnSignal` (Wert), das nur `evaluateBlockCall`
fängt – ein Standard-"Unwind zu einer bekannten Grenze"-Muster für frühen
Ausstieg. Das bricht **sofort** jede laufende Schleife/jedes `IF` ab, egal
wie tief verschachtelt.
- Läuft die Ausführung durch, ohne dass je `ReturnSignal` geworfen wurde,
wirft `evaluateBlockCall` selbst einen `Error` ("… endet ohne RETURN").
Jeder `Error` hier (Schritt-Limit, ungültige FOR-Quelle, kein `RETURN`
erreicht) propagiert unverändert zu `evaluateCell`s bestehendem catch-all in
`workbook.ts` → `#ERR` – dieselbe Absicherung, die schon jeden anderen
Auswertungsfehler abfängt, keine neue Fehlerbehandlung nötig.
**Kein Endlos-Risiko trotz echter Schleife:** `MAX_LOOP_STEPS` (10.000,
Summe aller Iterationen einer `evaluateBlockCall`-Ausführung, auch über
verschachtelte Schleifen hinweg) wirft bei Überschreitung. Eine Kette
verschachtelter `kind: 'block'`-Aufrufe bleibt zusätzlich durch
`MAX_EXPANSION_DEPTH` (32, unverändert, greift beim Auflösen verschachtelter
Aufrufe in `expandBlock`) begrenzt, sodass die Gesamtarbeit pro Formel immer
endlich bleibt.
**Abhängigkeiten:** `collectRefs()` (`parser.ts`) bekommt einen `blockcall`-
Fall, der sowohl `.args` als auch – über die neue Hilfsfunktion
`collectRefsInBlock` – jeden im Block erreichbaren Zellbezug einsammelt,
rekursiv durch `FOR`/`IF`. Das ist bewusst **konservativ**: ein Bezug in
einem zur Laufzeit nicht genommenen `IF`-Zweig zählt trotzdem als
Abhängigkeit (genau wie bei einem gewöhnlichen `IF` in Excel) – lieber
gelegentlich unnötig neu rechnen als je einen veralteten Wert zeigen.
**Namens-Validierung ist Store-Sache, nicht Engine-Sache** (wie beim
Fehlermodell durchgängig: die Engine liefert Mechanik, deutsche
Fehlertexte entstehen im Store). `userFunctions.ts` exportiert dafür,
jeweils rekursiv durch verschachtelte `FOR`/`IF`-Körper:
- `declaredBlockNames(block)` – jeder eingeführte Name, inkl. Wiederholungen
bei Wiederverwendung (z. B. das Akkumulator-Muster `ergebnis = 1 FOR x IN
bereich DO ergebnis = ergebnis * x END`, wo „ergebnis" zweimal auftaucht,
aber **keine** Kollision ist).
- `findInvalidForSource(block)` – die erste `FOR`-Quelle, die syntaktisch
weder Parameter noch Zelle/Bereich ist (egal wie tief verschachtelt).
- `collectCalledFunctionNamesInBlock(block)` – läuft das bestehende
`collectCalledFunctionNames()` über jeden Blatt-Ausdruck des Blocks; von
der Store-Zyklusprüfung genutzt.
- `blockAlwaysReturns(block)` – konservative statische Prüfung "erreicht
jeder Pfad garantiert ein `RETURN`": wahr, wenn der Block eine
`return`-Anweisung enthält, oder ein `IF` mit `ELSE`, bei dem **beide**
Zweige immer returnen. Eine `FOR`-Schleife allein reicht nie (könnte 0
Iterationen haben) – das ist eine Frühwarnung beim Speichern, die
Laufzeit-Prüfung oben ("… endet ohne RETURN" → `#ERR`) ist die tatsächliche
Absicherung.
`document.ts`s `validateCustomFunction` prüft damit: jeder Name gültig/nicht
zellartig/kein reserviertes Wort (`FOR`/`IN`/`DO`/`END`/`IF`/`THEN`/`ELSE`/
`RETURN`), kein *unterschiedlicher* lokaler Name überschattet einen
Parameter (Wiederverwendung *desselben* Namens über mehrere Anweisungen ist
dagegen erlaubt), `blockAlwaysReturns` sowie die Zyklus-Prüfung.
## Auswertungsmodell
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
zahlenförmige Zeichenkette wird gecastet, jede andere ergibt `#VALUE!`.
- **Vergleiche** (`= <> > < >= <=`), Ergebnis `1` (wahr) oder `0` (falsch);
genutzt vor allem in einer `IF`-Bedingung eines „Mit Schleife"-
Funktionskörpers (s. o.), aber in jeder Formel gültig. Zwei Zahlen (oder
eine leere Zelle, die als `0` zählt) vergleichen **numerisch**
(`toNumber()`-Koerzierung wie bei `+ - * /`); zwei Texte (oder eine leere
Zelle, die dann als `''` zählt) vergleichen **text**uell –
case-insensitiv und getrimmt, dieselbe Konvention wie `XLOOKUP`s
Text-Matching (s. u.), bewusst als eine einzige Regel im Motor statt
zweier verschiedener. Eine leere Zelle nimmt dabei immer den Typ der
**anderen** Seite an, sodass sowohl `=A1=0` als auch `=A1=""` auf einer
echt leeren `A1` funktionieren. Eine echte (nicht-leere) Zahl gegen
echten Text ist ein **Typ-Mismatch**, kein Crossmatch: `=`/`<>`
antworten eindeutig falsch/wahr, `> < >= <=` haben keine sinnvolle
Antwort und ergeben `#VALUE!` (siehe `evaluator.ts`s `compareValues()`).
- **`&` verkettet als Text** (`toText()`): eine Zahl rendert `String(n)`
ohne Locale-Formatierung (kein `Intl`, dieselbe „locale-neutral"-
Konvention wie der CSV-/XLSX-Export), eine leere Zelle trägt `''` bei
(nicht `'0'`). Präzedenz zwischen `+ -` und einem Vergleich (s. o. EBNF),
links-assoziativ. `CONCAT`/`CONCATENATE(Text1; Text2; …)` sind das
Funktions-Äquivalent (`functions.ts`), ohne Operator-Präzedenz.
- **Fehler propagieren unverändert** durch Operatoren und Funktionen (der erste
gefundene Fehler gewinnt).
- Ein **Bereich** darf nur als Funktionsargument stehen. In Skalar-Position
ergibt er `#VALUE!`.
- Ein **`NamedRefNode`** (benannter Bereich) löst live gegen
`host.resolveNamedRange()` auf und verhält sich danach exakt wie die
Zelle/der Bereich, die er dort ergibt (inkl. der Skalar-Regel oben für
einen mehrzelligen Treffer). Unbekannter Name → `#NAME?`. Details:
„Benannte Bereiche" oben.
- `SUM/MIN/MAX` über „keine Zahlen“ → `0`; `AVG` über „keine Zahlen“ → `#DIV/0!`.
- `COUNT` zählt nur echte Zahlen – leere Zellen und Text werden nicht gezählt.
- `^` = `Math.pow`, rechts-assoziativ: `=2^3^2` → `2^(3^2)` = `512`.
- **`IF(Bedingung; Dann; Sonst?)` ist die einzige Funktion, die nicht eager
ausgewertet wird.** Jede andere Funktion sieht in `functions.ts` bereits
fertig berechnete `CellValue`s (der Evaluator wertet vorher jedes Argument
aus und short-circuited beim ersten Fehler); `IF` wird stattdessen schon in
`evalScalar`s `case 'func':` abgefangen, bevor `functions.ts` je erreicht
wird (`evalIf()`): nur die Bedingung wird immer ausgewertet, danach
ausschließlich der genommene Zweig – der andere Zweig wird gar nicht erst
evaluiert, ein Fehler darin schlägt also nie durch (klassisch:
`=IF(B1=0; 0; A1/B1)`). Ohne Sonst-Zweig liefert eine falsche Bedingung
`0`. `collectRefs()` bleibt für `IF` bewusst generisch/konservativ (läuft
durch alle 2–3 Argumente unabhängig vom Laufzeit-Zweig) – dieselbe
Konvention wie bei `kind: 'block'`s `IF`-Abhängigkeiten (s. u.). Ein
benutzerdefiniertes „IF" ist nicht anlegbar (Namenskollision mit dem
eingebauten `FUNCTIONS.IF`-Eintrag).
- **`AND`/`OR`/`NOT` brauchen dagegen keine Sonderbehandlung** — anders als
`IF`/`XLOOKUP` sind es ganz normale `functions.ts`-Einträge, ohne
`evaluator.ts`-Kurzschluss: `AND`/`OR` werten (wie in echtem Excel) **alle**
Argumente eager aus, ein Fehler in irgendeinem Argument propagiert also
immer, egal ob das logische Ergebnis schon feststeht (`=AND(1=2; 1/0)` →
`#DIV/0!`, nicht `0`). `functions.ts#isTruthy()` ist die eine
Wahrheits-Konvention, die überall im Motor geteilt wird, wo eine Bedingung
gebraucht wird – `evalIf()` und `execStatement`s `ifstmt`-Fall importieren
sie jetzt aus `functions.ts`, statt sie (wie vor diesem Feature) je einmal
inline zu duplizieren.
## Datum & Zeit
Kein eigener Zellwert-Typ — ein Datum ist wie in jeder Tabellenkalkulation
einfach eine **Zahl** (`dates.ts`): das "Serial", Tage seit dem Nullpunkt
**1899-12-30** (Google-Sheets-Konvention: wie Excel, aber ohne dessen
historischen 1900-Schaltjahr-Bug), Uhrzeit als Bruchteil eines Tages
(`0.5` = 12:00). Serial 1 = 1899-12-31, Serial 2 = 1900-01-01. Alle
Berechnungen laufen über UTC-Felder (`Date.UTC`/`getUTC*`), damit ein Serial
unabhängig von der Browser-Zeitzone exakt rundtrippt. Weil ein Datum einfach
eine Zahl ist, funktionieren `+`/`-`/Vergleiche darauf automatisch — keine
Änderung an Operatoren nötig.
- **`dateToSerial(y, mo, d, h?, mi?, s?)`** — Baustein für `DATE()`, reines
`Date.UTC`, **Überlauf bewusst erlaubt** (`dateToSerial(2026, 13, 1)` =
2027-01-01), wie Excels `DATE()`.
- **`timeSerial(h, mi, s)`** — Baustein für `TIME()`, wrapt modulo 86400
Sekunden wie Excels `TIME()` (`timeSerial(25,0,0)` = der `1/24`-Anteil des
Folgetags).
- **`todaySerial()`/`nowSerial()`** — nutzen das **lokale** Kalenderdatum
(ein Datum ist ein Kalender-, kein Zeitzonen-Konzept), nicht UTC.
- **`parseDateTimeLiteral(raw)`** — erkennt eine rohe Zelleingabe als Datum/
Zeit/Datum+Zeit, genutzt sowohl von `workbook.ts`s `literalValue()` (Text →
Serial) als auch vom Store (`document.ts`s `setCellInput`, Auto-Format
beim Eintippen, s. u.). **Bewusst konservativ**: nur ISO (`2026-09-02`,
optional `[ T]HH:MM[:SS]`) und Punkt-Daten (`02.09.2026`, `02.09.26` —
2-stellig: 00–29 → 20xx, 30–99 → 19xx —, ebenfalls mit Uhrzeit) sowie reine
`HH:MM[:SS]`-Zeiten werden erkannt. **Schrägstrich-Daten (`02/09/2026`)
absichtlich nicht** — Tag-zuerst vs. Monat-zuerst ist über die
unterstützten Locales (en-US monatzuerst, alle anderen tagzuerst) nicht
eindeutig auflösbar; falsches Raten würde das Datum lautlos verfälschen,
das bleibt schlechter als es als Text stehen zu lassen. Ein intern über
`Date.UTC` gebauter Kandidat wird durch Rück-Dekomposition verifiziert, um
stillen Überlauf abzulehnen (`31.04.2026`, `30.02.2026` → **kein** Treffer,
bleibt Text) — anders als `dateToSerial`/`DATE()`, die Überlauf bewusst
zulassen.
- **`DATE`/`TIME`/`TODAY`/`NOW`** (`functions.ts`) — `DATE`/`TIME` erwarten
genau 3 Argumente (sonst `#ERR`), koerzieren jedes wie `evaluator.ts`s
`toNumber()` (null → 0, zahlenförmiger Text gecastet, sonst `#VALUE!`);
`TODAY`/`NOW` erwarten 0 Argumente. **Nicht volatil**: diese Engine
berechnet rein vorgänger-getrieben neu, ohne Timer/Hintergrundarbeit — eine
Zelle mit `=TODAY()`/`=NOW()` aktualisiert sich erst bei ihrer eigenen
nächsten Neuberechnung, nicht spontan mit der Zeit (dokumentierte Grenze).
- **Anzeige** (`store/format.ts`) — `CellNumFmt`-Werte `date`/`time`/
`datetime` rendern über `serialToUtcDate()` + `Intl.DateTimeFormat(locale,
{ timeZone: 'UTC', ... })`; das Pseudo-Locale `'plain'` bekommt eine
manuelle, locale-neutrale `YYYY-MM-DD`/`HH:MM`-Formatierung (bewusst nicht
`Intl` mit `undefined`-Locale, das würde lautlos die Host-Locale des
Browsers durchsickern lassen). Formel-Ergebnisse bekommen **kein**
automatisches Format — wie jedes andere Formelergebnis auch. Ein viertes
`CellNumFmt` — `dateiso` — rendert dasselbe `YYYY-MM-DD` **unabhängig von
der Locale** (nutzt intern dieselbe Formatier-Funktion wie `'plain'`s
Datumsanteil), für export-nahe/sortierbare Datumsspalten, die sich nicht
mit der Blatt-/Dokument-Locale mitändern sollen.
- **Auto-Format beim Eintippen** (`store/document.ts`s `setCellInput`) — eine
erkannte Literal-Eingabe (nicht `=`-Formeln) setzt automatisch das
passende `numFmt`, aber **nur** solange die Zelle noch `'auto'` ist; ein
bereits explizit gesetztes Format wird nie überschrieben.
## Leere Argumentstelle (`OmittedNode`)
`funcall`s Grammatik (s. o.) erlaubt jetzt eine leere Stelle zwischen zwei
`;` (oder zwischen einem `;` und dem schließenden `)`) statt eines
Ausdrucks — Excels "dieses optionale Argument einfach auslassen"-Idiom,
z. B. `XLOOKUP(x; a; b; ; -1)`, das `WennNichtGefunden` überspringt, um
`Vergleichsmodus` zu erreichen. Der Parser produziert dafür einen eigenen
`OmittedNode` (Teil des `Node`-Unions, s. o.) statt eines Parse-Fehlers.
`evalScalar` wertet ihn wie eine leere Zelle aus (`null`) — für die meisten
Funktionen (`SUM`, `COUNT`, …) verhält er sich also genauso wie eine echte
leere Zelle in einem Bereich. Eine Funktion, die "explizit ausgelassen"
von "wertet zu leer aus" unterscheiden muss (nur `XLOOKUP`, bisher), prüft
`arg.kind === 'omitted'` auf dem rohen Argument-Knoten, **bevor** sie ihn
auswertet (`evalXLookup`s `given()`-Hilfsfunktion in `evaluator.ts`).
`expand()`/`substitute()` (`userFunctions.ts`) behandeln ihn als Blatt ohne
weitere Rekursion, `collectRefs()` ignoriert ihn stillschweigend (kein
Bezug enthalten).
## Nachschlagen (XLOOKUP)
`XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden];
[Vergleichsmodus]; [Suchmodus])` ist – wie `IF` – eine Funktion, die
`evalScalar`s `case 'func':` **vor** dem generischen eager/flatten-Pfad
abfängt (`evalXLookup()`), aus zwei Gründen, nicht nur einem:
1. **`WennNichtGefunden` muss lazy ausgewertet werden** — nur wenn
tatsächlich kein Treffer gefunden wurde (derselbe Kurzschluss-Grundsatz
wie bei `IF`s Zweigen).
2. **`Suchbereich` und `Rückgabebereich` dürfen nicht zusammen flach
geklopft werden.** Der generische Pfad (jede Funktion außer `IF` und
`XLOOKUP`) wertet jedes Argument über `evalArg()` aus und hängt alle
Ergebnisse an **eine gemeinsame** flache Liste an (siehe
`functions.ts`s Modul-Kommentar) — dabei geht die Grenze zwischen zwei
Bereichs-Argumenten komplett verloren. `evalArg()` selbst liefert pro
Argument aber bereits ein eigenes Array zurück; `evalXLookup()` ruft es
für `Suchbereich` und `Rückgabebereich` separat auf und hält die beiden
Arrays getrennt, statt sie (wie der generische Pfad) zusammenzuführen.
Trefferlogik: Text wird über eine eigene `xlookupEquals()` verglichen
(case-insensitiv, getrimmt) statt über den `=`-Operator (der über
`toNumber()` koerziert und deshalb nie auf Text träfe – XLOOKUPs
häufigster Anwendungsfall). Zahl und Text gelten nie als gleich. Bei
`Vergleichsmodus -1`/`1` gewinnt ein exakter Treffer immer sofort, sonst
wird der nächstkleinere/-größere **numerische** Kandidat gesucht (Text
wird dabei übersprungen, nicht verglichen). `Suchmodus -1` kehrt einfach
die Durchlaufreihenfolge um (kein Binärsuche-Algorithmus, kein sortierter
Bereich vorausgesetzt).
`#N/A` (kein Treffer, kein `WennNichtGefunden`) ist eine dokumentierte
Erweiterung des ursprünglichen 5-Fehlercode-Sets, mit derselben Begründung
wie seinerzeit `#VALUE!`. `collectRefs()` braucht **keine** Änderung –
sein generischer `case 'func':` läuft bereits durch alle Argumente
(inkl. `Suchbereich`/`Rückgabebereich`), unabhängig davon, ob ein Treffer
gefunden wird — dieselbe konservative Konvention wie bei `IF`.
## Neuberechnung
1. `setCell` reparst **eine** Zelle, aktualisiert ihre Graph-Kanten.
2. `graph.affected(dirty)` = geänderte Zellen + alle transitiven Abhängigen.
3. `graph.topoOrder(affected)` liefert eine sichere Auswertungsreihenfolge
(Präzedenzen zuerst) **und** die Menge der Zellen auf einem Zyklus.
4. Zyklus-Zellen bekommen direkt `#CIRC`; der Rest wird in topologischer
Reihenfolge ausgewertet. Nicht betroffene Zellen behalten ihren Wert
(„nur betroffene Zellen neu rechnen“).
Strukturänderungen (Tabelle umbenennen/löschen, Zeilen/Spalten, Blatt löschen)
rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf.
**`Workbook.graphPrecedents`/`graphDependents`** (dünne öffentliche Wrapper
um `DependencyGraph.getPrecedents`/`getDependents`) sind der einzige Ort, an
dem der Graph *abgefragt* statt nur für die Neuberechnung *benutzt* wird –
die Datenquelle des interaktiven Abhängigkeiten-Panels (`src/store/
document.ts`s `cellGraphNeighbors`/`cellGraphSelf`, `src/components/
DependencyGraphView.vue`, s. Haupt-README „Abhängigkeiten erkunden"). Rein
lesend, ohne jeden Effekt auf Auswertung/Neuberechnung; bleiben auch für
eine Zelle auf einem Zirkelbezug korrekt (die Kante existiert im Graphen
unabhängig davon, dass `topoOrder` sie aus der Auswertung ausschließt).
## Fehlermodell
| Wert | Bedeutung |
|------------|-----------|
| `#REF` | ungültiger/gelöschter Bezug, unbekannter Tabellen-/Blattname, **oder** ein unqualifizierter Tabellenname, der auf mehreren Blättern existiert und auch über die "gleiches Blatt wie die Formel"-Regel nicht eindeutig wird |
| `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) |
| `#NAME?` | unbekannte Funktion (eingebaut oder benutzerdefiniert) |
| `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`); auch Arity-Mismatch/Expansionstiefen-Limit bei einer benutzerdefinierten Funktion, oder bei `kind: 'block'` eine ungültige FOR-Quelle, mehr als `MAX_LOOP_STEPS` Schleifenschritte, oder eine Ausführung ohne je erreichtes `RETURN` |
| `#CIRC` | Zirkelbezug – auch tabellen-/blattübergreifend |
| `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar); auch `XLOOKUP` mit ungleich großem Such-/Rückgabebereich. *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
| `#N/A` | `XLOOKUP` ohne Treffer und ohne `WennNichtGefunden`. *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
Es gibt **keinen** Absturz und **keinen** Hang: Lexer/Parser werfen intern,
`Workbook` fängt alles ab und schreibt einen Fehlerwert in die Zelle.
Jeder dieser sieben Codes ist außerdem als **`ErrorLiteralNode`** direkt in
eine Formel tippbar (`=#REF`, `=IF(A1=0;#N/A;A1)`, …) – der Lexer erkennt
ihn als eigenes `ERRORLIT`-Token (längster Treffer zuerst, s. o. EBNF),
`evalScalar`s `case 'errorliteral':` gibt unbedingt `new FormulaError(code)`
zurück. `store/document.ts`s Zeilen-/Spalten-Löschen ist der eine Ort, der
selbst *Formeltext* mit `#REF`-Literalen erzeugt (für eine dadurch
kollabierte Referenz) – ansonsten ist es reine Nutzer-Eingabe, wie eine
Zahl- oder Text-Konstante. Kein `!` am Ende (`#REF`, nicht Excels `#REF!`)
– derselbe Code, den diese Engine selbst überall sonst produziert.