Text-Vergleich, &-Verkettung und benannte Bereiche

- Vergleichsoperatoren (= <> > < >= <=) unterscheiden jetzt Text- von
  Zahlenvergleich statt jede Seite zwangsweise durch toNumber() zu
  schicken (=A1="Nord" gab vorher #VALUE! statt 1); ermöglicht
  Text-Kriterien in selbstgebauten COUNTIF/SUMIF/AVERAGEIF-Funktionen
  (neue README-Sektion "SUMIF/COUNTIF/AVERAGEIF nachbauen").
- Neuer &-Operator (Excel-Präzedenz zwischen Vergleich und +/-) plus
  CONCAT/CONCATENATE als Funktions-Äquivalent.
- Benannte Bereiche: neuer Toolbar-Button "🏷" verwaltet dokumentweite
  Namen für eine feste Zelle/einen festen Bereich (z. B. Umsatz_Q1statt
  Umsatz!B2:B6), unqualifiziert in jeder Top-Level-Formel nutzbar.
  Live gegen die Tabellen-ID aufgelöst (übersteht Tabellen-Umbenennung),
  bewusst nicht innerhalb einer benutzerdefinierten Funktion selbst
  nutzbar (Aufrufer können sie aber als Argument übergeben).
- Nebenbei: COUNT2s Autovervollständigungs-Hint korrigiert (zeigte
  "COUNT(...)" statt "COUNT2(...)").

374 Tests, vue-tsc/eslint/build sauber, Playwright-live-verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-08 21:59:47 +02:00
co-authored by Claude Sonnet 5
parent 9015a991aa
commit 2c54537427
22 changed files with 1460 additions and 86 deletions
+196 -17
View File
@@ -258,6 +258,115 @@ ebenso — das Formular warnt davor zusätzlich schon **beim Speichern**, wenn
nicht erkennbar jeder Codepfad ein `RETURN` erreicht. Details:
[`src/engine/README.md`](src/engine/README.md)s „Schleifen"-Abschnitt.
#### SUMIF/COUNTIF/AVERAGEIF nachbauen
Es gibt keine eingebauten `SUMIF`/`COUNTIF`/`AVERAGEIF` — aber die **„Mit
Schleife"**-Funktionen reichen aus, um die **selbstbezügliche** Form
nachzubauen: Kriterium und Aggregation laufen über **denselben** Bereich
(„Summe/Anzahl/Mittelwert aller Werte in `bereich`, die eine Bedingung
erfüllen"). Das deckt den häufigsten Alltagsfall ab — z. B. „Summe aller
Umsätze über 100" oder „Anzahl der Zeilen mit Region = Nord", solange
Kriterium und zu summierender Wert in derselben Zelle stehen.
```
summe = 0
FOR x IN bereich DO
IF x > schwelle THEN
summe = summe + x
END
END
RETURN summe
```
aufgerufen als `=SUMME_WENN(Umsatz!B2:B13; 100)` — das Excel-Äquivalent zu
`SUMIF(Umsatz!B2:B13; ">100")` mit gleichem Kriterien- und Summenbereich.
`COUNTIF`/`AVERAGEIF` folgen demselben Muster, nur mit `zaehler = zaehler + 1`
statt `summe = summe + x`, bzw. am Ende `RETURN summe / zaehler` (mit
`IF zaehler = 0 THEN RETURN 0 END`-Guard gegen `#DIV/0!`).
Seit dem Fix der Vergleichsoperatoren (s. u. „Formelgrammatik") funktioniert
das Kriterium auch mit **Text** — case-insensitiv und getrimmt, wie bei
`XLOOKUP`:
```
anzahl = 0
FOR zeile IN bereich DO
IF zeile = kriterium THEN
anzahl = anzahl + 1
END
END
RETURN anzahl
```
aufgerufen als `=ANZAHL_WENN(Umsatz!A2:A13; "Nord")` — zählt alle Zellen in
`Umsatz!A2:A13`, deren Text „Nord" ist (unabhängig von Groß-/Kleinschreibung
und Leerzeichen).
**Grenze, die (noch) bleibt:** ein Excel-`SUMIF(Kriterienbereich; Kriterium;
Summenbereich)` mit **getrennten** Bereichen (Kriterium in Spalte A, zu
summierender Wert in Spalte B) ist damit **nicht** nachbaubar — die Sprache
hat kein `OFFSET`/`INDEX`-Äquivalent, um „die Zelle an derselben Position im
anderen Bereich" zu adressieren; eine `FOR`-Schleife erreicht nur den
Bereich, über den sie selbst iteriert. Siehe „Bekannte Grenzen" unten.
### Benannte Bereiche
Der Button **„🏷" (Benannte Bereiche)** in der Symbolleiste, direkt neben
**„𝑓 Funktionen"**, öffnet die einzige Stelle, an der dokumentweite Namen für
eine feste Zelle/einen festen Bereich angelegt, bearbeitet und gelöscht
werden — z. B. `Umsatz_Q1` statt `Umsatz!B2:B6`. Danach in **jeder**
Top-Level-Formel unqualifiziert nutzbar, ohne Tabellen-Präfix:
`=SUM(Umsatz_Q1)` oder `=Umsatz_Q1 + 1` für eine einzelne Zelle. Das
vorbefüllte Dokument bringt `Umsatz_Q1` (→ `Umsatz!B2:B6`) bereits als
Beispiel mit.
- **Liste** zeigt jeden Namen als `Name → Tabelle (Blatt)!Bereich` — mit
**Bearb.** und **🗑** wie bei den Funktionen.
- **Formular**: Name, eine Tabelle (Auswahl über alle Blätter hinweg) und
zwei Zellfelder (Start/Ende als A1-Notation, z. B. `B2` und `B6`); ein
leeres zweites Feld bedeutet „nur die erste Zelle". **„+ Neuer Name"**
übernimmt automatisch die aktuelle Zellauswahl (Tabelle + Bereich) als
Vorbelegung, wenn eine Zelle/ein Bereich gerade markiert ist — analog zu
Excels „Namen aus Auswahl erstellen".
- **Validierung beim Speichern**: gültiger Name (keine Zahl/Zellform wie
`A1`), kein reserviertes Schlüsselwort (`FOR`/`IN`/`DO`/`RETURN`), keine
Kollision mit einem eingebauten Namen, einer benutzerdefinierten Funktion
oder einem anderen benannten Bereich (case-insensitiv). **Kein
Zyklus-Check nötig** — anders als eine benutzerdefinierte Funktion hat ein
benannter Bereich keinen eigenen Ausdruck, nur feste Koordinaten, kann sich
also nie selbst referenzieren.
- **Überlebt eine Tabellen-Umbenennung.** Intern wird die Tabellen-*ID*
gespeichert, nicht ihr Name — anders als ein normaler `Tabelle!Zelle`-Bezug
im Formeltext (der bricht bei Umbenennung, wie eh und je) wird ein
benannter Bereich beim Nachschlagen live auf den *aktuellen* Tabellennamen
aufgelöst. Wird die Tabelle dagegen **gelöscht**, bleibt der Name bestehen
und zeigt in Formeln danach `#NAME?` — „nichts wird automatisch
umgeschrieben", dieselbe Konvention wie beim Löschen einer benutzerdefinierten
Funktion oder Tabelle.
- Vollständig **undo-/redo-fähig** und Teil des JSON-Exports/-Imports.
**Bewusst ohne** (Stand jetzt):
- **Nicht innerhalb einer benutzerdefinierten Funktion nutzbar** — im
Funktionskörper selbst bleibt ein unqualifizierter, nicht-zellförmiger
Name ein harter Parse-Fehler, genau wie vor diesem Feature. Grund: sonst
würde z. B. eine lokale Variable, die versehentlich vor ihrer eigenen
Zuweisung verwendet wird, nicht mehr sofort beim Speichern auffallen,
sondern erst zur Laufzeit als `#NAME?` — oder, schlimmer, sie würde
still passend zu einem gleichnamigen benannten Bereich irgendwo im
Dokument auflösen. Ein **Aufrufer** kann einen benannten Bereich aber
ganz normal als Argument übergeben (`=MEINEFUNKTION(Umsatz_Q1)`) — die
Einschränkung gilt nur fürs *Eintippen des bloßen Namens* im Funktionskörper
selbst.
- **Keine eigene Formel/kein Ausdruck** — nur eine feste Zelle/ein fester
Bereich, kein benannter *berechneter* Wert (dafür gibt es weiterhin
benutzerdefinierte Funktionen ohne Parameter, s. o.).
- **Nicht in der Funktions-Autovervollständigung** — das Dropdown beim
Tippen nach `=` schlägt bisher nur Funktionsnamen vor, keine benannten
Bereiche.
- **Kein „Bereich" als `Name:B2`** — ein benannter Bereich ist immer die
ganze, vordefinierte Adresse; er kann nicht als Startpunkt einer neu
getippten `:`-Bereichsangabe kombiniert werden.
### Zell-Formatierung
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
@@ -327,7 +436,7 @@ fixierter Kopfzeile und fixierter Spalte A** angelegt, um das Feature zu zeigen.
formula = "=" expression ;
expression = comparison ;
comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchstens
comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ; (* höchstens
ein Vergleich, nicht
verkettbar wie
"a < b < c"; vor allem
@@ -335,6 +444,12 @@ comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchsten
„Mit Schleife"-Code
(s. o.), aber in jeder
Formel nutzbar *)
concat = additive { "&" additive } ; (* links-assoziativ; Text-
Verkettung, bindet
schwächer als "+ -",
stärker als ein
Vergleich -- derselbe
Platz wie bei Excel *)
additive = term { ("+" | "-") term } ; (* links-assoziativ *)
term = unary { ("*" | "/") unary } ; (* links-assoziativ *)
unary = ("+" | "-") unary
@@ -342,10 +457,19 @@ unary = ("+" | "-") unary
power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet
stärker als unäres Minus,
d.h. -2^2 = -(2^2) = -4 *)
primary = NUMBER | STRING | "(" expression ")" | funcall | reference ;
primary = NUMBER | STRING | "(" expression ")" | funcall | reference | namedref ;
funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
namedref = IDENT ; (* ein unqualifizierter,
nicht zellförmiger,
nicht in `(` mündender
Bezeichner -- ein
benannter-Bereich-
Kandidat, s. u.; nur
außerhalb eines
Funktionskörpers *)
reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname *)
sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt,
@@ -362,9 +486,38 @@ QUOTED = /' ( [^'] | '' )* '/ ; (* Name mit Leerzeichen *)
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
```
Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `+ -` < `* /` <
Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` <
unäres `+ -` < `^`.
**Vergleiche** sind numerisch, wenn beide Seiten Zahlen sind (leere Zelle
zählt als `0`), und **text**uell — case-insensitiv, getrimmt, wie bei
`XLOOKUP` (s. u.) —, wenn beide Seiten Text sind (leere Zelle zählt dann als
`''`). Eine echte Zahl gegen echten Text: `=`/`<>` antworten eindeutig
falsch/wahr, `> < >= <=` ergeben `#VALUE!` (kein sinnvoller Vergleich über
Typgrenzen). Nützlich z. B. für `=A1="Nord"` oder als Kriterium einer
selbstgebauten `COUNTIF`/`SUMIF`-Funktion (s. „SUMIF/COUNTIF/AVERAGEIF
nachbauen" oben).
**`&` verkettet als Text**, z. B. `=A1&" "&B1` oder `="Herr "&Name`. Eine Zahl
wird dabei ohne Locale-Formatierung dargestellt (`5`, nicht `5,00`), eine
leere Zelle trägt `''` bei (nicht `'0'`). Bindet schwächer als `+`/`-`
(`="a"&1+2` = `"a3"`, nicht `"a1"+2`), aber stärker als ein Vergleich
(`=A1&B1="ab"` prüft das *Ergebnis* der Verkettung, nicht `B1="ab"` allein).
`CONCAT(Text1; Text2; …)`/`CONCATENATE(...)` machen dasselbe als Funktion,
ohne Operator-Präzedenz zu beachten — nützlich mit vielen Teilen oder
innerhalb eines „Mit Schleife"-Codes.
Ein unqualifizierter Bezeichner, der weder eine Zelle noch (mit
`paramNames`) ein Parameter ist, parst als **`namedref`** — ein Kandidat für
einen **benannten Bereich** (s. „Benannte Bereiche" oben), erst zur
Auswertung/Abhängigkeits-Ermittlung gegen das Dokument aufgelöst (genau wie
ein Tabellen-Qualifier nie beim Parsen selbst geprüft wird). Ein unbekannter
Name ergibt `#NAME?`. **Nur außerhalb eines Funktionskörpers** — mit
`paramNames` bleibt ein unqualifizierter, nicht-zellförmiger Bezeichner ein
harter Parse-Fehler wie zuvor (s. „Benannte Bereiche"s „Bewusst ohne"). Ein
*qualifizierter* nicht-zellförmiger Bezeichner (`Tabelle!Foo`) ist immer ein
Parse-Fehler — ein benannter Bereich ist nie qualifiziert.
Der (innere) Qualifier ist der **Tabellenname**. `!` und der Numbers-Stil `::`
sind dabei austauschbar und **case-/whitespace-tolerant**:
`Kosten!A2` = `'Kosten'!A2` = `kosten!a2` = `Kosten::A2`.
@@ -411,10 +564,10 @@ Vorschlag (fügt `NAME(` ein, bereit für die Argumente), `Esc` schließt nur
das Dropdown, ohne die Bearbeitung selbst abzubrechen.
**Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`),
`MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)`, `DATE(Jahr; Monat;
Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`, `XLOOKUP(Suchwert;
Suchbereich; Rückgabebereich; [WennNichtGefunden]; [Vergleichsmodus];
[Suchmodus])` — plus alle über **„𝑓 Funktionen"** angelegten
`MIN`, `MAX`, `COUNT`, `CONCAT` (Alias `CONCATENATE`), `IF(Bedingung; Dann;
Sonst?)`, `DATE(Jahr; Monat; Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`,
`XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden];
[Vergleichsmodus]; [Suchmodus])` — plus alle über **„𝑓 Funktionen"** angelegten
benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben),
die sich für den Aufrufer identisch verhalten. Argumente gemischt:
Einzelzellen, Bereiche (lokal oder `Name!…`), Zahl-Literale — z. B.
@@ -712,7 +865,23 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
Funktionsänderung, voller JSON-Round-Trip, `customFunctions: []` bei
v1→v2-Migration.
- **Vergleichsoperatoren** — alle sechs (`= <> > < >= <=`), Präzedenz
unterhalb `+ -`, nicht verkettbar (`a < b < c` ist ein Parse-Fehler).
unterhalb `+ -`, nicht verkettbar (`a < b < c` ist ein Parse-Fehler);
numerischer Vergleich, Text-Vergleich (case-insensitiv/getrimmt), leere
Zelle nimmt den Typ der Gegenseite an, Zahl-gegen-Text-Mismatch
(`=`/`<>` eindeutig, `> < >= <=` → `#VALUE!`).
- **`&`-Verkettung** — Präzedenz zwischen `+ -` und einem Vergleich,
links-assoziativ, Zahl rendert locale-neutral, leere Zelle trägt `''` bei,
Fehler propagieren; `CONCAT`/`CONCATENATE` als Funktions-Äquivalent.
- **Benannte Bereiche** — Auflösung als Einzelzelle/Bereich (Skalar- und
Funktionsargument-Position), case-insensitives/getrimmtes Namens-Matching,
Abhängigkeitsgraph-Kante (Neuberechnung bei Änderung der Zielzelle),
Überleben einer Tabellen-Umbenennung, `#NAME?` bei unbekanntem Namen und
bei gelöschter Zieltabelle, `addNamedRange`/`updateNamedRange`/
`deleteNamedRange`-Validierung (Kollision mit Built-in/eigener
Funktion/anderem Namen, Zellform), Undo/Redo, JSON-Round-Trip, Nutzung als
`FOR`-Quelle in einer benutzerdefinierten Funktion (als übergebenes
Argument), harter Parse-Fehler statt Fallback innerhalb eines
Funktionskörpers selbst.
- **„Mit Schleife"-Code** — Parser-Ebene (`parseFunctionBlock`): ein
`FOR...DO...END` mit mehreren Anweisungen, mehrere unabhängige Schleifen
nacheinander, `IF...THEN...END` und `IF...THEN...ELSE...END`, `IF`
@@ -784,9 +953,12 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
reaktiv), **kein** echter Web Worker.
- **xlsx-Formel-Übernahme** ist heuristisch (Separator-Swap unter Beachtung von
Quotes); exotische Excel-Formeln fallen auf den Wert zurück.
- Kein Text-Verkettungsoperator, keine benannten Bereiche (nur
benutzerdefinierte *Funktionen*, keine benannten *Zellen/Bereiche*), kein
Snapping der Canvas-Objekte.
- Kein Snapping der Canvas-Objekte.
- **Benannte Bereiche** bewusst ohne: Nutzung innerhalb einer
benutzerdefinierten Funktion (ein Aufrufer kann aber einen benannten
Bereich als Argument übergeben), einen eigenen Ausdruck/berechneten Wert
(nur feste Koordinaten), Funktions-Autovervollständigungs-Vorschläge, und
`Name:B2` als kombinierten Bereichsstart — s. „Benannte Bereiche" oben.
- **Datum & Zeit**: die automatische Erkennung beim Eintippen erfasst nur ISO-
und Punkt-Daten, absichtlich **nicht** Schrägstrich-Daten (Tag-zuerst vs.
Monat-zuerst ist über die unterstützten Locales nicht eindeutig auflösbar,
@@ -813,12 +985,19 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
zählt — wie in den meisten Editoren), Live-Update der Vorschlagsliste bei
reiner Cursor-Bewegung ohne Tippen.
- **„Mit Schleife"-Code** bewusst ohne: verkettete Vergleiche (`a < b < c`,
nur ein Vergleich pro Ausdruck), Text-Vergleich (Vergleichsoperatoren
bleiben numerisch), `AND`/`OR`/`NOT` für zusammengesetzte Bedingungen,
`BREAK`/`CONTINUE` (nur `RETURN` als Kontrollfluss-Ausstieg), echtes
Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes sichtbar).
Verschachtelte Schleifen/`IF`s und `RETURN` an beliebiger Stelle sind
dagegen unterstützt (s. o.).
nur ein Vergleich pro Ausdruck), `AND`/`OR`/`NOT` für zusammengesetzte
Bedingungen, `BREAK`/`CONTINUE` (nur `RETURN` als Kontrollfluss-Ausstieg),
echtes Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes
sichtbar). Verschachtelte Schleifen/`IF`s und `RETURN` an beliebiger
Stelle sind dagegen unterstützt (s. o.), ebenso Text-Vergleich in `IF`-
Bedingungen (s. „SUMIF/COUNTIF/AVERAGEIF nachbauen" oben).
- **Keine Positions-Adressierung zwischen zwei Bereichen** (kein
`OFFSET`/`INDEX`-Äquivalent) — eine `FOR`-Schleife kann nur den Bereich
erreichen, über den sie selbst iteriert, nicht „die Zelle an derselben
Position in einem anderen Bereich". Ein klassisches dreiargumentiges
`SUMIF(Kriterienbereich; Kriterium; Summenbereich)` mit **getrennten**
Bereichen ist deshalb nicht nachbaubar; nur die selbstbezügliche Form
(Kriterium und Aggregation auf demselben Bereich) geht (s. o.).
## Projektstruktur