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:
@@ -258,6 +258,115 @@ ebenso — das Formular warnt davor zusätzlich schon **beim Speichern**, wenn
|
|||||||
nicht erkennbar jeder Codepfad ein `RETURN` erreicht. Details:
|
nicht erkennbar jeder Codepfad ein `RETURN` erreicht. Details:
|
||||||
[`src/engine/README.md`](src/engine/README.md)s „Schleifen"-Abschnitt.
|
[`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
|
### Zell-Formatierung
|
||||||
|
|
||||||
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
|
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 ;
|
formula = "=" expression ;
|
||||||
|
|
||||||
expression = comparison ;
|
expression = comparison ;
|
||||||
comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchstens
|
comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ; (* höchstens
|
||||||
ein Vergleich, nicht
|
ein Vergleich, nicht
|
||||||
verkettbar wie
|
verkettbar wie
|
||||||
"a < b < c"; vor allem
|
"a < b < c"; vor allem
|
||||||
@@ -335,6 +444,12 @@ comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchsten
|
|||||||
„Mit Schleife"-Code
|
„Mit Schleife"-Code
|
||||||
(s. o.), aber in jeder
|
(s. o.), aber in jeder
|
||||||
Formel nutzbar *)
|
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 *)
|
additive = term { ("+" | "-") term } ; (* links-assoziativ *)
|
||||||
term = unary { ("*" | "/") unary } ; (* links-assoziativ *)
|
term = unary { ("*" | "/") unary } ; (* links-assoziativ *)
|
||||||
unary = ("+" | "-") unary
|
unary = ("+" | "-") unary
|
||||||
@@ -342,10 +457,19 @@ unary = ("+" | "-") unary
|
|||||||
power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet
|
power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet
|
||||||
stärker als unäres Minus,
|
stärker als unäres Minus,
|
||||||
d.h. -2^2 = -(2^2) = -4 *)
|
d.h. -2^2 = -(2^2) = -4 *)
|
||||||
primary = NUMBER | STRING | "(" expression ")" | funcall | reference ;
|
primary = NUMBER | STRING | "(" expression ")" | funcall | reference | namedref ;
|
||||||
|
|
||||||
funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
|
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 ] ;
|
reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
|
||||||
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname *)
|
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname *)
|
||||||
sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt,
|
sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt,
|
||||||
@@ -362,9 +486,38 @@ QUOTED = /' ( [^'] | '' )* '/ ; (* Name mit Leerzeichen *)
|
|||||||
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
||||||
```
|
```
|
||||||
|
|
||||||
Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `+ -` < `* /` <
|
Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` <
|
||||||
unäres `+ -` < `^`.
|
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 `::`
|
Der (innere) Qualifier ist der **Tabellenname**. `!` und der Numbers-Stil `::`
|
||||||
sind dabei austauschbar und **case-/whitespace-tolerant**:
|
sind dabei austauschbar und **case-/whitespace-tolerant**:
|
||||||
`Kosten!A2` = `'Kosten'!A2` = `kosten!a2` = `Kosten::A2`.
|
`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.
|
das Dropdown, ohne die Bearbeitung selbst abzubrechen.
|
||||||
|
|
||||||
**Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`),
|
**Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`),
|
||||||
`MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)`, `DATE(Jahr; Monat;
|
`MIN`, `MAX`, `COUNT`, `CONCAT` (Alias `CONCATENATE`), `IF(Bedingung; Dann;
|
||||||
Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`, `XLOOKUP(Suchwert;
|
Sonst?)`, `DATE(Jahr; Monat; Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`,
|
||||||
Suchbereich; Rückgabebereich; [WennNichtGefunden]; [Vergleichsmodus];
|
`XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden];
|
||||||
[Suchmodus])` — plus alle über **„𝑓 Funktionen"** angelegten
|
[Vergleichsmodus]; [Suchmodus])` — plus alle über **„𝑓 Funktionen"** angelegten
|
||||||
benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben),
|
benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben),
|
||||||
die sich für den Aufrufer identisch verhalten. Argumente gemischt:
|
die sich für den Aufrufer identisch verhalten. Argumente gemischt:
|
||||||
Einzelzellen, Bereiche (lokal oder `Name!…`), Zahl-Literale — z. B.
|
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
|
Funktionsänderung, voller JSON-Round-Trip, `customFunctions: []` bei
|
||||||
v1→v2-Migration.
|
v1→v2-Migration.
|
||||||
- **Vergleichsoperatoren** — alle sechs (`= <> > < >= <=`), Präzedenz
|
- **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
|
- **„Mit Schleife"-Code** — Parser-Ebene (`parseFunctionBlock`): ein
|
||||||
`FOR...DO...END` mit mehreren Anweisungen, mehrere unabhängige Schleifen
|
`FOR...DO...END` mit mehreren Anweisungen, mehrere unabhängige Schleifen
|
||||||
nacheinander, `IF...THEN...END` und `IF...THEN...ELSE...END`, `IF`
|
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.
|
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-Verkettungsoperator, keine benannten Bereiche (nur
|
- Kein Snapping der Canvas-Objekte.
|
||||||
benutzerdefinierte *Funktionen*, keine benannten *Zellen/Bereiche*), kein
|
- **Benannte Bereiche** bewusst ohne: Nutzung innerhalb einer
|
||||||
Snapping der Canvas-Objekte.
|
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-
|
- **Datum & Zeit**: die automatische Erkennung beim Eintippen erfasst nur ISO-
|
||||||
und Punkt-Daten, absichtlich **nicht** Schrägstrich-Daten (Tag-zuerst vs.
|
und Punkt-Daten, absichtlich **nicht** Schrägstrich-Daten (Tag-zuerst vs.
|
||||||
Monat-zuerst ist über die unterstützten Locales nicht eindeutig auflösbar,
|
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
|
zählt — wie in den meisten Editoren), Live-Update der Vorschlagsliste bei
|
||||||
reiner Cursor-Bewegung ohne Tippen.
|
reiner Cursor-Bewegung ohne Tippen.
|
||||||
- **„Mit Schleife"-Code** bewusst ohne: verkettete Vergleiche (`a < b < c`,
|
- **„Mit Schleife"-Code** bewusst ohne: verkettete Vergleiche (`a < b < c`,
|
||||||
nur ein Vergleich pro Ausdruck), Text-Vergleich (Vergleichsoperatoren
|
nur ein Vergleich pro Ausdruck), `AND`/`OR`/`NOT` für zusammengesetzte
|
||||||
bleiben numerisch), `AND`/`OR`/`NOT` für zusammengesetzte Bedingungen,
|
Bedingungen, `BREAK`/`CONTINUE` (nur `RETURN` als Kontrollfluss-Ausstieg),
|
||||||
`BREAK`/`CONTINUE` (nur `RETURN` als Kontrollfluss-Ausstieg), echtes
|
echtes Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes
|
||||||
Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes sichtbar).
|
sichtbar). Verschachtelte Schleifen/`IF`s und `RETURN` an beliebiger
|
||||||
Verschachtelte Schleifen/`IF`s und `RETURN` an beliebiger Stelle sind
|
Stelle sind dagegen unterstützt (s. o.), ebenso Text-Vergleich in `IF`-
|
||||||
dagegen unterstützt (s. o.).
|
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
|
## Projektstruktur
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 CustomFunctionsModal from './CustomFunctionsModal.vue';
|
||||||
|
import NamedRangesModal from './NamedRangesModal.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 {
|
||||||
@@ -29,6 +30,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 functionsOpen = ref(false);
|
||||||
|
const namedRangesOpen = ref(false);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
@@ -249,6 +251,14 @@ function onReset() {
|
|||||||
>
|
>
|
||||||
𝑓
|
𝑓
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn accent icon-btn"
|
||||||
|
title="Benannte Bereiche"
|
||||||
|
aria-label="Benannte Bereiche"
|
||||||
|
@click="namedRangesOpen = true"
|
||||||
|
>
|
||||||
|
🏷
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
|
|
||||||
@@ -343,6 +353,7 @@ function onReset() {
|
|||||||
|
|
||||||
<!-- Benutzerdefinierte Funktionen -->
|
<!-- Benutzerdefinierte Funktionen -->
|
||||||
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
|
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
|
||||||
|
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
|
||||||
|
|
||||||
<!-- Über / Impressum -->
|
<!-- Über / Impressum -->
|
||||||
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
|
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* "🏷 Namen" -- the exclusive UI for creating, viewing, editing and deleting
|
||||||
|
* the document's named ranges (a document-wide name standing for a fixed
|
||||||
|
* cell/range on a specific table, usable unqualified in any top-level
|
||||||
|
* formula -- see src/store/types.ts's `NamedRange`). Mirrors
|
||||||
|
* CustomFunctionsModal.vue's list/form structure.
|
||||||
|
*/
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { looksLikeCell, parseA1, toA1 } from '../engine';
|
||||||
|
import { useDocument } from '../store/document';
|
||||||
|
import type { TableObject } from '../store/types';
|
||||||
|
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: '', tableId: '', start: '', end: '' });
|
||||||
|
const formError = ref<string | null>(null);
|
||||||
|
|
||||||
|
const allTables = computed(() =>
|
||||||
|
store.doc.sheets.flatMap((s) =>
|
||||||
|
s.objects
|
||||||
|
.filter((o): o is TableObject => o.kind === 'table')
|
||||||
|
.map((t) => ({ id: t.id, name: t.name, sheetName: s.name })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function tableLabel(tableId: string): string {
|
||||||
|
const t = allTables.value.find((x) => x.id === tableId);
|
||||||
|
return t ? `${t.name} (${t.sheetName})` : '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e.g. "B2" or "B2:B6" (single cell when start === end). */
|
||||||
|
function rangeLabel(r: { start: { col: number; row: number }; end: { col: number; row: number } }): string {
|
||||||
|
const a = toA1(r.start.col, r.start.row);
|
||||||
|
const b = toA1(r.end.col, r.end.row);
|
||||||
|
return a === b ? a : `${a}:${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
formError.value = null;
|
||||||
|
// prefill from the current cell selection, if any -- "create name from
|
||||||
|
// selection", same idea as Excel's own shortcut for this.
|
||||||
|
const sel = store.doc.selection;
|
||||||
|
if (sel) {
|
||||||
|
const rect = store.selectionRects(sel)[0];
|
||||||
|
form.tableId = sel.tableId;
|
||||||
|
form.start = toA1(rect.c0, rect.r0);
|
||||||
|
form.end = toA1(rect.c1, rect.r1);
|
||||||
|
} else {
|
||||||
|
form.tableId = allTables.value[0]?.id ?? '';
|
||||||
|
form.start = '';
|
||||||
|
form.end = '';
|
||||||
|
}
|
||||||
|
form.name = '';
|
||||||
|
mode.value = 'edit';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(id: string) {
|
||||||
|
const r = store.doc.namedRanges.find((x) => x.id === id);
|
||||||
|
if (!r) return;
|
||||||
|
editingId.value = id;
|
||||||
|
form.name = r.name;
|
||||||
|
form.tableId = r.tableId;
|
||||||
|
form.start = toA1(r.start.col, r.start.row);
|
||||||
|
form.end = toA1(r.end.col, r.end.row);
|
||||||
|
formError.value = null;
|
||||||
|
mode.value = 'edit';
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
const startTok = form.start.trim();
|
||||||
|
const endTok = form.end.trim() || startTok;
|
||||||
|
if (!looksLikeCell(startTok)) {
|
||||||
|
formError.value = `"${form.start}" ist kein gültiger Zellbezug.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!looksLikeCell(endTok)) {
|
||||||
|
formError.value = `"${form.end}" ist kein gültiger Zellbezug.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = parseA1(startTok);
|
||||||
|
const end = parseA1(endTok);
|
||||||
|
const result = editingId.value
|
||||||
|
? store.updateNamedRange(editingId.value, form.name, form.tableId, start, end)
|
||||||
|
: store.addNamedRange(form.name, form.tableId, start, end);
|
||||||
|
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 removeRange(id: string, name: string) {
|
||||||
|
if (confirm(`Name "${name}" löschen? Formeln, die ihn verwenden, zeigen danach #NAME?.`)) {
|
||||||
|
store.deleteNamedRange(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseModal
|
||||||
|
:open="open"
|
||||||
|
:title="mode === 'list' ? 'Benannte Bereiche' : editingId ? 'Namen bearbeiten' : 'Neuer Name'"
|
||||||
|
@close="close"
|
||||||
|
>
|
||||||
|
<!-- ---- list view ---- -->
|
||||||
|
<template v-if="mode === 'list'">
|
||||||
|
<p v-if="!store.doc.namedRanges.length" class="hint">
|
||||||
|
Noch keine benannten Bereiche definiert. Mit „+ Neuer Name" einen anlegen und danach
|
||||||
|
wie <code>=Umsatz_Q1</code> in jeder Formel verwenden, ohne Tabellen-Qualifier.
|
||||||
|
</p>
|
||||||
|
<ul v-else class="nr-list">
|
||||||
|
<li v-for="r in store.doc.namedRanges" :key="r.id" class="nr-row">
|
||||||
|
<code class="nr-sig">
|
||||||
|
{{ r.name }} → {{ tableLabel(r.tableId) }}!{{ rangeLabel(r) }}
|
||||||
|
</code>
|
||||||
|
<span class="nr-actions">
|
||||||
|
<button class="mini" @click="openEdit(r.id)">Bearb.</button>
|
||||||
|
<button class="mini danger" title="Löschen" @click="removeRange(r.id, r.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. Umsatz_Q1" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="row">
|
||||||
|
Tabelle
|
||||||
|
<select v-model="form.tableId">
|
||||||
|
<option v-for="t in allTables" :key="t.id" :value="t.id">
|
||||||
|
{{ t.name }} ({{ t.sheetName }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="row range-row">
|
||||||
|
<span class="range-label">Bereich</span>
|
||||||
|
<div class="range-fields">
|
||||||
|
<input v-model="form.start" type="text" placeholder="B2" class="cell-input" />
|
||||||
|
<span class="range-sep">:</span>
|
||||||
|
<input v-model="form.end" type="text" placeholder="B6 (leer = nur B2)" class="cell-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
Eine leere zweite Zelle bedeutet „nur die erste Zelle" (kein Bereich). Der Name ist
|
||||||
|
danach in jeder Formel unqualifiziert nutzbar, z. B. <code>=SUM(Umsatz_Q1)</code> oder
|
||||||
|
<code>=Umsatz_Q1 + 1</code> für eine einzelne Zelle. Er überlebt eine spätere Umbenennung
|
||||||
|
der Tabelle; wird die Tabelle gelöscht, zeigen Formeln, die den Namen nutzen, danach
|
||||||
|
<code>#NAME?</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<template v-if="mode === 'list'">
|
||||||
|
<button class="primary" @click="openNew">+ Neuer Name</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;
|
||||||
|
}
|
||||||
|
.nr-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.nr-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.nr-sig {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.nr-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.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'],
|
||||||
|
.row select {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.range-fields {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.cell-input {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
.range-sep {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.form-error {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #dc2626;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -136,5 +136,10 @@ export function createSeedDocument(): DocumentState {
|
|||||||
locale: 'de-DE',
|
locale: 'de-DE',
|
||||||
csvDelimiter: ',',
|
csvDelimiter: ',',
|
||||||
customFunctions: [],
|
customFunctions: [],
|
||||||
|
namedRanges: [
|
||||||
|
// demos "🏷 Namen": =Umsatz_Q1 anywhere resolves to Umsatz!B2:B6, even
|
||||||
|
// from a different sheet, without a table qualifier.
|
||||||
|
{ id: nextId('nr'), name: 'Umsatz_Q1', tableId: umsatz.id, start: { col: 1, row: 1 }, end: { col: 1, row: 5 } },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+99
-9
@@ -35,11 +35,14 @@ Instanz in ein `reactive()` und spiegelt berechnete Werte über einen
|
|||||||
formula = "=" expression ;
|
formula = "=" expression ;
|
||||||
|
|
||||||
expression = comparison ;
|
expression = comparison ;
|
||||||
comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchstens
|
comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ; (* höchstens
|
||||||
ein Vergleich, nicht
|
ein Vergleich, nicht
|
||||||
verkettbar; Ergebnis
|
verkettbar; Ergebnis
|
||||||
1/0 (siehe Auswertungs-
|
1/0 (siehe Auswertungs-
|
||||||
modell unten) *)
|
modell unten) *)
|
||||||
|
concat = additive { "&" additive } ; (* links-assoziativ; Text-
|
||||||
|
Verkettung, zwischen
|
||||||
|
Vergleich und "+ -" *)
|
||||||
additive = term { ("+" | "-") term } ; (* links-assoziativ; die
|
additive = term { ("+" | "-") term } ; (* links-assoziativ; die
|
||||||
alte "expression"-Regel,
|
alte "expression"-Regel,
|
||||||
bevor Vergleiche
|
bevor Vergleiche
|
||||||
@@ -54,10 +57,16 @@ primary = NUMBER
|
|||||||
| STRING
|
| STRING
|
||||||
| "(" expression ")"
|
| "(" expression ")"
|
||||||
| funcall
|
| funcall
|
||||||
| reference ;
|
| reference
|
||||||
|
| namedref ;
|
||||||
|
|
||||||
funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
|
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 ] ;
|
reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
|
||||||
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname; "!" und der
|
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname; "!" und der
|
||||||
Numbers-Stil "::" sind
|
Numbers-Stil "::" sind
|
||||||
@@ -78,7 +87,7 @@ QUOTED = /' ( [^'] | '' )* '/ (* Tabellenname mit Leer-/Sonderzeichen *
|
|||||||
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
||||||
```
|
```
|
||||||
|
|
||||||
Präzedenz, schwach → stark: Vergleich (`= <> > < >= <=`) < `+ -` < `* /` < unäres `+ -` < `^`.
|
Präzedenz, schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` < unäres `+ -` < `^`.
|
||||||
|
|
||||||
`$` sperrt eine Referenz-Achse: `$A1` (Spalte fix), `A$1` (Zeile fix), `$A$1`
|
`$` 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
|
(beide fix) vs. `A1` (relativ). Für die **Auswertung** ist das irrelevant – sie
|
||||||
@@ -111,8 +120,20 @@ wirft bei einem kaputten Präfix) liefert `null` — kein Dropdown.
|
|||||||
Parameter: eine Liste von Namen, gegen die ein bare `IDENT` (das nicht wie
|
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
|
eine Zelle aussieht) zusätzlich geprüft wird — ein Treffer erzeugt statt eines
|
||||||
`ParseError` einen `ParamNode`. Nur beim Parsen eines **Funktionskörpers**
|
`ParseError` einen `ParamNode`. Nur beim Parsen eines **Funktionskörpers**
|
||||||
verwendet (`userFunctions.ts`); ohne das Argument ist das Verhalten exakt wie
|
verwendet (`userFunctions.ts`).
|
||||||
zuvor, jeder bare Bezeichner ohne Zellform bleibt ein `ParseError`.
|
|
||||||
|
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")
|
## Blatt-Qualifier ("Blatt!Tabelle::Zelle")
|
||||||
|
|
||||||
@@ -169,6 +190,54 @@ unbekannter Funktionsname – eingebaut oder benutzerdefiniert – bleibt exakt
|
|||||||
derselbe Pfad wie zuvor: der `FuncNode` bleibt unverändert stehen,
|
derselbe Pfad wie zuvor: der `FuncNode` bleibt unverändert stehen,
|
||||||
`evaluate()`s `getFunction()` findet ihn nicht → `#NAME?`.
|
`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'`)
|
## Schleifen ("Mit Schleife"-Funktionen, `kind: 'block'`)
|
||||||
|
|
||||||
Eine zweite, eigene Grammatik nur für Funktionskörper mit `kind: 'block'`
|
Eine zweite, eigene Grammatik nur für Funktionskörper mit `kind: 'block'`
|
||||||
@@ -302,14 +371,35 @@ dagegen erlaubt), `blockAlwaysReturns` sowie die Zyklus-Prüfung.
|
|||||||
|
|
||||||
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
|
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
|
||||||
zahlenförmige Zeichenkette wird gecastet, jede andere ergibt `#VALUE!`.
|
zahlenförmige Zeichenkette wird gecastet, jede andere ergibt `#VALUE!`.
|
||||||
- **Vergleiche sind ebenfalls numerisch** (`toNumber()`-Koerzierung wie bei
|
- **Vergleiche** (`= <> > < >= <=`), Ergebnis `1` (wahr) oder `0` (falsch);
|
||||||
`+ - * /`), Ergebnis `1` (wahr) oder `0` (falsch); genutzt vor allem in
|
genutzt vor allem in einer `IF`-Bedingung eines „Mit Schleife"-
|
||||||
einer `IF`-Bedingung eines „Mit Schleife"-Funktionskörpers (s. o.), aber in
|
Funktionskörpers (s. o.), aber in jeder Formel gültig. Zwei Zahlen (oder
|
||||||
jeder Formel gültig.
|
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
|
- **Fehler propagieren unverändert** durch Operatoren und Funktionen (der erste
|
||||||
gefundene Fehler gewinnt).
|
gefundene Fehler gewinnt).
|
||||||
- Ein **Bereich** darf nur als Funktionsargument stehen. In Skalar-Position
|
- Ein **Bereich** darf nur als Funktionsargument stehen. In Skalar-Position
|
||||||
ergibt er `#VALUE!`.
|
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!`.
|
- `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.
|
- `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`.
|
- `^` = `Math.pow`, rechts-assoziativ: `=2^3^2` → `2^(3^2)` = `512`.
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ import { parseFormula } from '../parser';
|
|||||||
import { evaluate } from '../evaluator';
|
import { evaluate } from '../evaluator';
|
||||||
import { FormulaError, isError, type CellValue, type EngineHost } from '../types';
|
import { FormulaError, isError, type CellValue, type EngineHost } from '../types';
|
||||||
|
|
||||||
/** Minimal in-memory host: two tables addressed by name. */
|
type NamedRangeShape = { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } };
|
||||||
|
|
||||||
|
/** Minimal in-memory host: two tables addressed by name, plus an optional
|
||||||
|
* name -> named-range map (name lower-cased, same matching convention as
|
||||||
|
* the real Workbook). */
|
||||||
function makeHost(
|
function makeHost(
|
||||||
data: Record<string, Record<string, CellValue>>,
|
data: Record<string, Record<string, CellValue>>,
|
||||||
home = 'S1',
|
home = 'S1',
|
||||||
|
namedRanges: Record<string, NamedRangeShape> = {},
|
||||||
): EngineHost {
|
): EngineHost {
|
||||||
const sizes: Record<string, { cols: number; rows: number }> = {
|
const sizes: Record<string, { cols: number; rows: number }> = {
|
||||||
S1: { cols: 10, rows: 10 },
|
S1: { cols: 10, rows: 10 },
|
||||||
@@ -21,6 +26,7 @@ function makeHost(
|
|||||||
return {
|
return {
|
||||||
homeTableId: home,
|
homeTableId: home,
|
||||||
resolveTableName: idFor,
|
resolveTableName: idFor,
|
||||||
|
resolveNamedRange: (name) => namedRanges[name.trim().toLowerCase()] ?? null,
|
||||||
tableSize: (id) => sizes[id] ?? null,
|
tableSize: (id) => sizes[id] ?? null,
|
||||||
getComputed(tableId, col, row) {
|
getComputed(tableId, col, row) {
|
||||||
const cell = String.fromCharCode(65 + col) + (row + 1);
|
const cell = String.fromCharCode(65 + col) + (row + 1);
|
||||||
@@ -130,6 +136,122 @@ describe('evaluator IF', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('evaluator comparisons', () => {
|
||||||
|
const host = makeHost({ S1: { A1: 'Nord', B1: 5 } });
|
||||||
|
|
||||||
|
it('numeric comparisons work as before, empty cell counts as 0', () => {
|
||||||
|
expect(ev('=B1>=5', host)).toBe(1);
|
||||||
|
expect(ev('=B1<>5', host)).toBe(0);
|
||||||
|
expect(ev('=Z9=0', host)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('text equality is case-insensitive and trimmed, like XLOOKUP', () => {
|
||||||
|
expect(ev('=A1="Nord"', host)).toBe(1);
|
||||||
|
expect(ev('=A1=" nord "', host)).toBe(1);
|
||||||
|
expect(ev('=A1="Süd"', host)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('text ordering compares lexicographically', () => {
|
||||||
|
expect(ev('=A1<"Süd"', host)).toBe(1);
|
||||||
|
expect(ev('=A1>"Süd"', host)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty cell takes on whichever type the other side is', () => {
|
||||||
|
expect(ev('=Z9=""', host)).toBe(1);
|
||||||
|
expect(ev('=Z9="x"', host)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a real number against real text never crossmatches: = / <> answer definitively, ordering is #VALUE!', () => {
|
||||||
|
expect(ev('=B1="5"', host)).toBe(0);
|
||||||
|
expect(ev('=B1<>"5"', host)).toBe(1);
|
||||||
|
expect(isError(ev('=B1>"5"', host))).toBe(true);
|
||||||
|
expect(isError(ev('=A1<5', host))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluator & (text concatenation)', () => {
|
||||||
|
const host = makeHost({ S1: { A1: 'Herr', B1: 5 } });
|
||||||
|
|
||||||
|
it('joins two strings', () => {
|
||||||
|
expect(ev('="a"&"b"', host)).toBe('ab');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a number plain, no locale formatting', () => {
|
||||||
|
expect(ev('="Wert: "&B1', host)).toBe('Wert: 5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty cell contributes "" (not "0")', () => {
|
||||||
|
expect(ev('="["&Z9&"]"', host)).toBe('[]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is left-associative and chains', () => {
|
||||||
|
expect(ev('=A1&" "&"Wild"', host)).toBe('Herr Wild');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds looser than + but tighter than a comparison', () => {
|
||||||
|
expect(ev('="a"&1+2', host)).toBe('a3');
|
||||||
|
expect(ev('=A1&"x"="Herrx"', host)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates an error from either side', () => {
|
||||||
|
expect(isError(ev('=(1/0)&"x"', host))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluator CONCAT/CONCATENATE', () => {
|
||||||
|
const host = makeHost({ S1: { A1: 'x', B1: 'y' } });
|
||||||
|
|
||||||
|
it('joins any number of arguments, no separator', () => {
|
||||||
|
expect(ev('=CONCAT("a";"b";"c")', host)).toBe('abc');
|
||||||
|
expect(ev('=CONCATENATE(A1;" ";B1)', host)).toBe('x y');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty cell contributes ""', () => {
|
||||||
|
expect(ev('=CONCAT(A1;Z9;B1)', host)).toBe('xy');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluator named ranges (NamedRefNode)', () => {
|
||||||
|
it('a single-cell named range behaves exactly like that cell in scalar position', () => {
|
||||||
|
const host = makeHost({ S1: { B2: 42 } }, 'S1', {
|
||||||
|
preis: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } },
|
||||||
|
});
|
||||||
|
expect(ev('=Preis + 1', host)).toBe(43);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matching is case-insensitive and trimmed, like a table qualifier', () => {
|
||||||
|
const host = makeHost({ S1: { B2: 42 } }, 'S1', {
|
||||||
|
preis: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } },
|
||||||
|
});
|
||||||
|
expect(ev('=PREIS', host)).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a multi-cell named range works as a function argument (range position), same as SUM(B2:B3)', () => {
|
||||||
|
const host = makeHost({ S1: { B2: 10, B3: 5 } }, 'S1', {
|
||||||
|
bereich: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } },
|
||||||
|
});
|
||||||
|
expect(ev('=SUM(Bereich)', host)).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a multi-cell named range in scalar position is #VALUE!, same as a bare range', () => {
|
||||||
|
const host = makeHost({ S1: { B2: 10, B3: 5 } }, 'S1', {
|
||||||
|
bereich: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } },
|
||||||
|
});
|
||||||
|
expect(isError(ev('=Bereich', host))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown name is #NAME?', () => {
|
||||||
|
expect(isError(ev('=DoesNotExist', makeHost({})))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves through another table via its qualifier', () => {
|
||||||
|
const host = makeHost({ S2: { C3: 7 } }, 'S1', {
|
||||||
|
andernorts: { qualifier: 'S2', start: { col: 2, row: 2 }, end: { col: 2, row: 2 } },
|
||||||
|
});
|
||||||
|
expect(ev('=Andernorts * 2', host)).toBe(14);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('evaluator XLOOKUP', () => {
|
describe('evaluator XLOOKUP', () => {
|
||||||
const textHost = makeHost({
|
const textHost = makeHost({
|
||||||
S1: {
|
S1: {
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ describe('lexer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('tokenizes operators and punctuation', () => {
|
it('tokenizes operators and punctuation', () => {
|
||||||
expect(types('+-*/^():;!')).toEqual([
|
expect(types('+-*/^&():;!')).toEqual([
|
||||||
'PLUS',
|
'PLUS',
|
||||||
'MINUS',
|
'MINUS',
|
||||||
'STAR',
|
'STAR',
|
||||||
'SLASH',
|
'SLASH',
|
||||||
'CARET',
|
'CARET',
|
||||||
|
'AMP',
|
||||||
'LPAREN',
|
'LPAREN',
|
||||||
'RPAREN',
|
'RPAREN',
|
||||||
'COLON',
|
'COLON',
|
||||||
@@ -66,7 +67,7 @@ describe('lexer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('throws on an unexpected character', () => {
|
it('throws on an unexpected character', () => {
|
||||||
expect(() => tokenize('A1 & B2')).toThrow();
|
expect(() => tokenize('A1 @ B2')).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tokenizes assignment/equality and comparison operators', () => {
|
it('tokenizes assignment/equality and comparison operators', () => {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ function sexpr(n: Node): string {
|
|||||||
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
|
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
|
||||||
case 'param':
|
case 'param':
|
||||||
return `param(${n.name})`;
|
return `param(${n.name})`;
|
||||||
|
case 'namedref':
|
||||||
|
return `namedref(${n.name})`;
|
||||||
case 'blockcall':
|
case 'blockcall':
|
||||||
// never appears in freshly-parsed input (see parser.ts's comment on
|
// never appears in freshly-parsed input (see parser.ts's comment on
|
||||||
// BlockCallNode) -- exhaustiveness only, no test exercises this.
|
// BlockCallNode) -- exhaustiveness only, no test exercises this.
|
||||||
@@ -84,6 +86,15 @@ describe('parser precedence & associativity', () => {
|
|||||||
'(- (* (+ cell(1,1) cell(2,1)) 2) (/ cell(3,1) 4))',
|
'(- (* (+ cell(1,1) cell(2,1)) 2) (/ cell(3,1) 4))',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('& binds looser than + - but tighter than a comparison', () => {
|
||||||
|
expect(parse('="a"&1+2')).toBe('(& "a" (+ 1 2))');
|
||||||
|
expect(parse('=A1&B1="ab"')).toBe('(= (& cell(0,0) cell(1,0)) "ab")');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('& is left-associative', () => {
|
||||||
|
expect(parse('="a"&"b"&"c"')).toBe('(& (& "a" "b") "c")');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parser references', () => {
|
describe('parser references', () => {
|
||||||
@@ -176,9 +187,13 @@ describe('parseFormula(src, paramNames) -- user-defined function bodies', () =>
|
|||||||
expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError);
|
expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('without paramNames, ordinary cell-formula behaviour is unchanged', () => {
|
it('without paramNames, a bare non-cell identifier is a named-range reference, not a parse error', () => {
|
||||||
// same string, no param list -- "revenue" doesn't look like a cell -> still throws
|
// same string, no param list -- "revenue" doesn't look like a cell, but
|
||||||
expect(() => parseFormula('=revenue')).toThrow(ParseError);
|
// this is now an ordinary top-level formula, where that's a named-range
|
||||||
|
// candidate (see the "named ranges" describe block below) rather than a
|
||||||
|
// ParseError -- unlike inside a function body (the case just above),
|
||||||
|
// where the fallback is deliberately off.
|
||||||
|
expect(parse('=revenue')).toBe('namedref(revenue)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a param can mix with real cell references in the same formula', () => {
|
it('a param can mix with real cell references in the same formula', () => {
|
||||||
@@ -198,8 +213,32 @@ describe('parser errors', () => {
|
|||||||
it('throws on an unbalanced paren', () => {
|
it('throws on an unbalanced paren', () => {
|
||||||
expect(() => parseFormula('=SUM(A1')).toThrow(ParseError);
|
expect(() => parseFormula('=SUM(A1')).toThrow(ParseError);
|
||||||
});
|
});
|
||||||
it('throws on a non-cell identifier used as a reference', () => {
|
});
|
||||||
expect(() => parseFormula('=foo + 1')).toThrow(ParseError);
|
|
||||||
|
describe('parser named-range references (NamedRefNode)', () => {
|
||||||
|
it('a bare, unqualified, non-cell identifier parses as a namedref', () => {
|
||||||
|
expect(parse('=foo + 1')).toBe('(+ namedref(foo) 1)');
|
||||||
|
expect(parse('=Umsatz_Q1')).toBe('namedref(Umsatz_Q1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixes with real cell/table references and functions', () => {
|
||||||
|
expect(parse('=SUM(Umsatz_Q1; A1; Umsatz!B2)')).toBe(
|
||||||
|
'(SUM namedref(Umsatz_Q1) cell(0,0) Umsatz!cell(1,1))',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a qualified non-cell identifier is still a ParseError -- a named range is always unqualified', () => {
|
||||||
|
expect(() => parseFormula('=Umsatz!foo')).toThrow(ParseError);
|
||||||
|
expect(() => parseFormula("='Kosten Q3'!foo")).toThrow(ParseError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never a range-start: "Name:B2" is not supported', () => {
|
||||||
|
expect(() => parseFormula('=foo:B2')).toThrow(ParseError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inside a function body, a bare non-cell/non-param name is still a hard ParseError (no named-range fallback there)', () => {
|
||||||
|
expect(() => parseFormula('=foo', ['bar'])).toThrow(ParseError);
|
||||||
|
expect(() => parseFunctionBlock('RETURN foo', ['bar'])).toThrow(ParseError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,18 @@ describe('Workbook integration: block-kind (FOR/IF/RETURN "code") user-defined f
|
|||||||
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
|
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a caller may pass a named range as the argument -- the FOR loop iterates it exactly like an ordinary range', () => {
|
||||||
|
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(Werte)' });
|
||||||
|
const wb = new Workbook(
|
||||||
|
[umsatz],
|
||||||
|
[def({ id: 'f1', name: 'PRODUCT2', ...productBlock })],
|
||||||
|
[{ id: 'n1', name: 'Werte', tableId: 's-umsatz', start: { col: 0, row: 0 }, end: { col: 0, row: 2 } }],
|
||||||
|
);
|
||||||
|
expect(val(wb, 's-umsatz', 'B9')).toBe(24);
|
||||||
|
wb.setCell('s-umsatz', 0, 0, '10'); // A1: 2 -> 10 -- still tracked as a precedent via the named range
|
||||||
|
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
|
||||||
|
});
|
||||||
|
|
||||||
it('a FOR body with multiple statements runs all of them, every iteration', () => {
|
it('a FOR body with multiple statements runs all of them, every iteration', () => {
|
||||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', B9: '=DOUBLESUM(A1:A2)' });
|
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', B9: '=DOUBLESUM(A1:A2)' });
|
||||||
const doubleSum = def({
|
const doubleSum = def({
|
||||||
|
|||||||
@@ -138,6 +138,61 @@ describe('Workbook structural changes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Workbook named ranges', () => {
|
||||||
|
function namedRangeFixture(): Workbook {
|
||||||
|
const umsatz = sheet('s-umsatz', 'Umsatz', { B2: '100', B3: '200', B9: '=Preis + 1' });
|
||||||
|
return new Workbook(
|
||||||
|
[umsatz],
|
||||||
|
[],
|
||||||
|
[{ id: 'n1', name: 'Preis', tableId: 's-umsatz', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('resolves a single-cell named range like an ordinary cell reference', () => {
|
||||||
|
const wb = namedRangeFixture();
|
||||||
|
expect(val(wb, 's-umsatz', 'B9')).toBe(101);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a dependency -- recalculates when the referenced cell changes', () => {
|
||||||
|
const wb = namedRangeFixture();
|
||||||
|
wb.setCell('s-umsatz', 1, 1, '500'); // B2: 100 -> 500
|
||||||
|
expect(val(wb, 's-umsatz', 'B9')).toBe(501);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a multi-cell named range works as a range function argument', () => {
|
||||||
|
const umsatz = sheet('s-umsatz', 'Umsatz', { B2: '100', B3: '200', B9: '=SUM(Bereich)' });
|
||||||
|
const wb = new Workbook(
|
||||||
|
[umsatz],
|
||||||
|
[],
|
||||||
|
[{ id: 'n1', name: 'Bereich', tableId: 's-umsatz', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } }],
|
||||||
|
);
|
||||||
|
expect(val(wb, 's-umsatz', 'B9')).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives its target table being renamed (resolved live by id, not by the name baked into the formula)', () => {
|
||||||
|
const wb = namedRangeFixture();
|
||||||
|
wb.tables[0].name = 'Verkauf'; // rename the table Preis points at
|
||||||
|
wb.rebuild();
|
||||||
|
expect(val(wb, 's-umsatz', 'B9')).toBe(101); // still resolves -- no #NAME?/#REF
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown name is #NAME?', () => {
|
||||||
|
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=DoesNotExist' });
|
||||||
|
const wb = new Workbook([umsatz], [], []);
|
||||||
|
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a dangling tableId (target table deleted) is #NAME?, same as a name that never existed', () => {
|
||||||
|
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=Preis' });
|
||||||
|
const wb = new Workbook(
|
||||||
|
[umsatz],
|
||||||
|
[],
|
||||||
|
[{ id: 'n1', name: 'Preis', tableId: 'no-such-table', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }],
|
||||||
|
);
|
||||||
|
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Workbook recalculation order', () => {
|
describe('Workbook recalculation order', () => {
|
||||||
it('evaluates precedents before dependents (chain across sheets)', () => {
|
it('evaluates precedents before dependents (chain across sheets)', () => {
|
||||||
const wb = fixture();
|
const wb = fixture();
|
||||||
|
|||||||
+136
-21
@@ -11,10 +11,22 @@
|
|||||||
* ----------------
|
* ----------------
|
||||||
* - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking
|
* - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking
|
||||||
* string is coerced; any other string yields `#VALUE!`.
|
* string is coerced; any other string yields `#VALUE!`.
|
||||||
* - Comparisons (`= <> > < >= <=`) are numeric too, result `1`/`0`.
|
* - Comparisons (`= <> > < >= <=`) result `1`/`0`. Two numbers (or an empty
|
||||||
|
* cell, which takes on `0`) compare numerically; two texts (or an empty
|
||||||
|
* cell, which takes on `''`) compare case-insensitively and trimmed --
|
||||||
|
* same convention as XLOOKUP's own text matching, see evalXLookup(). A
|
||||||
|
* number against a real (non-empty) text never crossmatches: `=`/`<>`
|
||||||
|
* answer definitively false/true, `> < >= <=` have no sane answer and
|
||||||
|
* yield `#VALUE!` (see compareValues()).
|
||||||
|
* - `&` concatenates as text: a number renders plain (`String(n)`, no
|
||||||
|
* locale formatting), an empty cell renders as `''` (see toText()).
|
||||||
* - Any error among operands / arguments propagates unchanged (first wins).
|
* - Any error among operands / arguments propagates unchanged (first wins).
|
||||||
* - A range may only appear as a function argument. Used in scalar position it
|
* - A range may only appear as a function argument. Used in scalar position it
|
||||||
* yields `#VALUE!`.
|
* yields `#VALUE!`.
|
||||||
|
* - A `NamedRefNode` (a bare, unqualified name that isn't a cell) resolves
|
||||||
|
* live against `host.resolveNamedRange()` into the cell/range it stands
|
||||||
|
* for, then evaluates exactly like that reference would have (see
|
||||||
|
* `resolveNamedRef()`). Unknown name -> `#NAME?`.
|
||||||
* - Unknown sheet name -> `#REF`. Unknown function -> `#NAME?`.
|
* - Unknown sheet name -> `#REF`. Unknown function -> `#NAME?`.
|
||||||
* - Division by zero -> `#DIV/0!`.
|
* - Division by zero -> `#DIV/0!`.
|
||||||
*
|
*
|
||||||
@@ -27,7 +39,7 @@
|
|||||||
* ordinary function call this can't be resolved any earlier.
|
* ordinary function call this can't be resolved any earlier.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BlockCallNode, BlockNode, CellNode, FuncNode, Node, Statement } from './parser';
|
import type { BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser';
|
||||||
import { getFunction } from './functions';
|
import { getFunction } from './functions';
|
||||||
import { expandRange } from './refs';
|
import { expandRange } from './refs';
|
||||||
import { substitute } from './userFunctions';
|
import { substitute } from './userFunctions';
|
||||||
@@ -58,6 +70,20 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
|
|||||||
if (isError(r)) return r;
|
if (isError(r)) return r;
|
||||||
return Math.pow(l, r);
|
return Math.pow(l, r);
|
||||||
}
|
}
|
||||||
|
if (isComparisonOp(node.op)) {
|
||||||
|
const lv = evalScalar(node.left, host);
|
||||||
|
if (isError(lv)) return lv;
|
||||||
|
const rv = evalScalar(node.right, host);
|
||||||
|
if (isError(rv)) return rv;
|
||||||
|
return compareValues(node.op, lv, rv);
|
||||||
|
}
|
||||||
|
if (node.op === '&') {
|
||||||
|
const lv = toText(evalScalar(node.left, host));
|
||||||
|
if (isError(lv)) return lv;
|
||||||
|
const rv = toText(evalScalar(node.right, host));
|
||||||
|
if (isError(rv)) return rv;
|
||||||
|
return lv + rv;
|
||||||
|
}
|
||||||
const l = toNumber(evalScalar(node.left, host));
|
const l = toNumber(evalScalar(node.left, host));
|
||||||
if (isError(l)) return l;
|
if (isError(l)) return l;
|
||||||
const r = toNumber(evalScalar(node.right, host));
|
const r = toNumber(evalScalar(node.right, host));
|
||||||
@@ -71,18 +97,6 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
|
|||||||
return l * r;
|
return l * r;
|
||||||
case '/':
|
case '/':
|
||||||
return r === 0 ? new FormulaError('#DIV/0!') : l / r;
|
return r === 0 ? new FormulaError('#DIV/0!') : l / r;
|
||||||
case '=':
|
|
||||||
return l === r ? 1 : 0;
|
|
||||||
case '<>':
|
|
||||||
return l !== r ? 1 : 0;
|
|
||||||
case '>':
|
|
||||||
return l > r ? 1 : 0;
|
|
||||||
case '<':
|
|
||||||
return l < r ? 1 : 0;
|
|
||||||
case '>=':
|
|
||||||
return l >= r ? 1 : 0;
|
|
||||||
case '<=':
|
|
||||||
return l <= r ? 1 : 0;
|
|
||||||
}
|
}
|
||||||
return new FormulaError('#ERR', `unknown operator ${node.op}`);
|
return new FormulaError('#ERR', `unknown operator ${node.op}`);
|
||||||
}
|
}
|
||||||
@@ -100,6 +114,12 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
|
|||||||
// A bare range can't collapse to a scalar.
|
// A bare range can't collapse to a scalar.
|
||||||
return new FormulaError('#VALUE!', 'range used where a value is expected');
|
return new FormulaError('#VALUE!', 'range used where a value is expected');
|
||||||
|
|
||||||
|
case 'namedref': {
|
||||||
|
const resolved = resolveNamedRef(node, host);
|
||||||
|
if (isError(resolved)) return resolved;
|
||||||
|
return evalScalar(resolved, host); // a resolved 1x1 range is a 'cell', so this recursion terminates
|
||||||
|
}
|
||||||
|
|
||||||
case 'func': {
|
case 'func': {
|
||||||
// IF is the one built-in that must NOT evaluate its arguments eagerly
|
// IF is the one built-in that must NOT evaluate its arguments eagerly
|
||||||
// (see functions.ts's FUNCTIONS.IF comment): only the taken branch may
|
// (see functions.ts's FUNCTIONS.IF comment): only the taken branch may
|
||||||
@@ -390,7 +410,7 @@ function execStatement(
|
|||||||
}
|
}
|
||||||
case 'for': {
|
case 'for': {
|
||||||
const source = substitute(stmt.source, env);
|
const source = substitute(stmt.source, env);
|
||||||
const cells = rangeOrCellToCellNodes(source, stmt.varName);
|
const cells = rangeOrCellToCellNodes(source, stmt.varName, host);
|
||||||
for (const cell of cells) {
|
for (const cell of cells) {
|
||||||
budget.steps++;
|
budget.steps++;
|
||||||
if (budget.steps > MAX_LOOP_STEPS) {
|
if (budget.steps > MAX_LOOP_STEPS) {
|
||||||
@@ -405,12 +425,21 @@ function execStatement(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A FOR loop's source, once names are resolved against the current
|
/** A FOR loop's source, once names are resolved against the current
|
||||||
* environment, must be a single cell or a range -- never a computed value
|
* environment, must be a single cell, a range, or a named range -- never a
|
||||||
* (mirrors "a range may only appear as a function argument" for ordinary
|
* computed value (mirrors "a range may only appear as a function argument"
|
||||||
* formulas). Expands a range to its individual cells via `expandRange`
|
* for ordinary formulas). Expands a range to its individual cells via
|
||||||
* (the same helper `evalArg` uses for a range function argument), carrying
|
* `expandRange` (the same helper `evalArg` uses for a range function
|
||||||
* the same table/sheet qualifier through to each. */
|
* argument), carrying the same table/sheet qualifier through to each. A
|
||||||
function rangeOrCellToCellNodes(node: Node, loopVarName: string): CellNode[] {
|
* named range resolves first (via `host`), then falls into the same
|
||||||
|
* cell/range handling as any other reference. */
|
||||||
|
function rangeOrCellToCellNodes(node: Node, loopVarName: string, host: EngineHost): CellNode[] {
|
||||||
|
if (node.kind === 'namedref') {
|
||||||
|
const resolved = resolveNamedRef(node, host);
|
||||||
|
if (isError(resolved)) {
|
||||||
|
throw new Error(`FOR ${loopVarName} IN ...: unbekannter Name '${node.name}'`);
|
||||||
|
}
|
||||||
|
node = resolved;
|
||||||
|
}
|
||||||
if (node.kind === 'cell') return [node];
|
if (node.kind === 'cell') return [node];
|
||||||
if (node.kind === 'range') {
|
if (node.kind === 'range') {
|
||||||
return expandRange(node.start, node.end).map(
|
return expandRange(node.start, node.end).map(
|
||||||
@@ -428,6 +457,11 @@ function rangeOrCellToCellNodes(node: Node, loopVarName: string): CellNode[] {
|
|||||||
|
|
||||||
/** Evaluate one function argument into a flat list of values (ranges expand). */
|
/** Evaluate one function argument into a flat list of values (ranges expand). */
|
||||||
function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
|
function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
|
||||||
|
if (node.kind === 'namedref') {
|
||||||
|
const resolved = resolveNamedRef(node, host);
|
||||||
|
if (isError(resolved)) return resolved;
|
||||||
|
return evalArg(resolved, host); // now a plain 'cell'/'range', handled below/by evalScalar
|
||||||
|
}
|
||||||
if (node.kind === 'range') {
|
if (node.kind === 'range') {
|
||||||
const tableId =
|
const tableId =
|
||||||
node.qualifier == null
|
node.qualifier == null
|
||||||
@@ -440,6 +474,22 @@ function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
|
|||||||
return [v];
|
return [v];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a `NamedRefNode` to the concrete `CellNode`/`RangeNode` it stands
|
||||||
|
* for right now (via `host.resolveNamedRange()`), so every other place that
|
||||||
|
* already knows how to evaluate a cell/range (`evalScalar`, `evalArg`,
|
||||||
|
* `rangeOrCellToCellNodes`) can just fall through to that existing logic
|
||||||
|
* instead of duplicating it. `#NAME?` when the name is unknown -- the same
|
||||||
|
* error class an unknown function name gets. */
|
||||||
|
function resolveNamedRef(node: NamedRefNode, host: EngineHost): CellNode | RangeNode | FormulaError {
|
||||||
|
const def = host.resolveNamedRange(node.name);
|
||||||
|
if (!def) return new FormulaError('#NAME?', `unknown name '${node.name}'`);
|
||||||
|
const sheetField = def.sheetQualifier === undefined ? {} : { sheetQualifier: def.sheetQualifier };
|
||||||
|
if (def.start.col === def.end.col && def.start.row === def.end.row) {
|
||||||
|
return { kind: 'cell', qualifier: def.qualifier, ...sheetField, col: def.start.col, row: def.start.row };
|
||||||
|
}
|
||||||
|
return { kind: 'range', qualifier: def.qualifier, ...sheetField, start: def.start, end: def.end };
|
||||||
|
}
|
||||||
|
|
||||||
/** Coerce a cell value to a number for arithmetic, or return an error value. */
|
/** Coerce a cell value to a number for arithmetic, or return an error value. */
|
||||||
function toNumber(v: CellValue): number | FormulaError {
|
function toNumber(v: CellValue): number | FormulaError {
|
||||||
if (isError(v)) return v;
|
if (isError(v)) return v;
|
||||||
@@ -450,3 +500,68 @@ function toNumber(v: CellValue): number | FormulaError {
|
|||||||
const n = Number(trimmed);
|
const n = Number(trimmed);
|
||||||
return Number.isFinite(n) ? n : new FormulaError('#VALUE!', `'${v}' is not a number`);
|
return Number.isFinite(n) ? n : new FormulaError('#VALUE!', `'${v}' is not a number`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Coerce a cell value to text for `&` concatenation, or return an error
|
||||||
|
* value. An empty cell becomes `''` (not `'0'`), matching how it's ignored
|
||||||
|
* everywhere else in this engine rather than treated as a literal number.
|
||||||
|
* A number renders plain (`String(n)`, no thousands separator/locale --
|
||||||
|
* same "locale-neutral" convention as CSV/XLSX export), never `Intl`. */
|
||||||
|
function toText(v: CellValue): string | FormulaError {
|
||||||
|
if (isError(v)) return v;
|
||||||
|
if (v === null) return '';
|
||||||
|
return typeof v === 'string' ? v : String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComparisonOp = '=' | '<>' | '>' | '<' | '>=' | '<=';
|
||||||
|
|
||||||
|
function isComparisonOp(op: string): op is ComparisonOp {
|
||||||
|
return op === '=' || op === '<>' || op === '>' || op === '<' || op === '>=' || op === '<=';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `= <> > < >= <=` on two already-evaluated scalars -- see the module
|
||||||
|
* comment's "Comparisons" bullet for the full rule. An empty cell (`null`)
|
||||||
|
* takes on whichever type the other side is (`0` for a number, `''` for
|
||||||
|
* text), so both `=A1=0` and `=A1=""` work on a genuinely empty `A1`. Text
|
||||||
|
* is matched the same way XLOOKUP matches text (case-insensitive, trimmed)
|
||||||
|
* -- kept as one convention across the engine rather than two. */
|
||||||
|
function compareValues(op: ComparisonOp, l: CellValue, r: CellValue): CellValue {
|
||||||
|
const lIsText = typeof l === 'string';
|
||||||
|
const rIsText = typeof r === 'string';
|
||||||
|
|
||||||
|
// A real number against real (non-empty) text is a genuine type mismatch
|
||||||
|
// -- an empty cell (null) is handled below, taking on whichever type the
|
||||||
|
// OTHER side is, so it never reaches this branch.
|
||||||
|
if ((typeof l === 'number' && rIsText) || (lIsText && typeof r === 'number')) {
|
||||||
|
if (op === '=') return 0;
|
||||||
|
if (op === '<>') return 1;
|
||||||
|
return new FormulaError('#VALUE!', 'text and number are not comparable with > < >= <=');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lIsText || rIsText) {
|
||||||
|
const lt = (l ?? '').toString().trim().toLowerCase();
|
||||||
|
const rt = (r ?? '').toString().trim().toLowerCase();
|
||||||
|
return applyCompare(op, lt, rt);
|
||||||
|
}
|
||||||
|
const ln = l === null ? 0 : (l as number);
|
||||||
|
const rn = r === null ? 0 : (r as number);
|
||||||
|
return applyCompare(op, ln, rn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCompare(op: ComparisonOp, l: number, r: number): CellValue;
|
||||||
|
function applyCompare(op: ComparisonOp, l: string, r: string): CellValue;
|
||||||
|
function applyCompare(op: ComparisonOp, l: number | string, r: number | string): CellValue {
|
||||||
|
switch (op) {
|
||||||
|
case '=':
|
||||||
|
return l === r ? 1 : 0;
|
||||||
|
case '<>':
|
||||||
|
return l !== r ? 1 : 0;
|
||||||
|
case '>':
|
||||||
|
return l > r ? 1 : 0;
|
||||||
|
case '<':
|
||||||
|
return l < r ? 1 : 0;
|
||||||
|
case '>=':
|
||||||
|
return l >= r ? 1 : 0;
|
||||||
|
case '<=':
|
||||||
|
return l <= r ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+12
-1
@@ -56,6 +56,13 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
|
|||||||
return values.length;
|
return values.length;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Plain text join, no separator (unlike Excel's TEXTJOIN). Same rendering
|
||||||
|
// as the `&` operator (toText() in evaluator.ts): a number prints plain
|
||||||
|
// (no locale formatting), an empty cell contributes ''.
|
||||||
|
CONCAT(values) {
|
||||||
|
return values.map((v) => (v === null ? '' : String(v))).join('');
|
||||||
|
},
|
||||||
|
|
||||||
// IF(cond; then; else?) -- evaluator.ts special-cases this name *before*
|
// IF(cond; then; else?) -- evaluator.ts special-cases this name *before*
|
||||||
// ever reaching FUNCTIONS, giving it real short-circuit evaluation (only
|
// ever reaching FUNCTIONS, giving it real short-circuit evaluation (only
|
||||||
// the taken branch is computed, so e.g. IF(B1=0; 0; A1/B1) never runs the
|
// the taken branch is computed, so e.g. IF(B1=0; 0; A1/B1) never runs the
|
||||||
@@ -142,6 +149,8 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
|
|||||||
|
|
||||||
// AVERAGE is an alias of AVG.
|
// AVERAGE is an alias of AVG.
|
||||||
FUNCTIONS.AVERAGE = FUNCTIONS.AVG;
|
FUNCTIONS.AVERAGE = FUNCTIONS.AVG;
|
||||||
|
// CONCATENATE is Excel's classic name for the same thing as CONCAT.
|
||||||
|
FUNCTIONS.CONCATENATE = FUNCTIONS.CONCAT;
|
||||||
|
|
||||||
/** One-line signature hint per built-in, for the formula-autocomplete
|
/** One-line signature hint per built-in, for the formula-autocomplete
|
||||||
* dropdown (see store/document.ts's `functionSuggestions()` and
|
* dropdown (see store/document.ts's `functionSuggestions()` and
|
||||||
@@ -156,7 +165,9 @@ export const BUILTIN_SIGNATURES: ReadonlyArray<{ name: string; hint: string }> =
|
|||||||
{ name: 'MIN', hint: 'MIN(Zahl1; Zahl2; …)' },
|
{ name: 'MIN', hint: 'MIN(Zahl1; Zahl2; …)' },
|
||||||
{ name: 'MAX', hint: 'MAX(Zahl1; Zahl2; …)' },
|
{ name: 'MAX', hint: 'MAX(Zahl1; Zahl2; …)' },
|
||||||
{ name: 'COUNT', hint: 'COUNT(Zahl1; Zahl2; …)' },
|
{ name: 'COUNT', hint: 'COUNT(Zahl1; Zahl2; …)' },
|
||||||
{ name: 'COUNT2', hint: 'COUNT(Text1; Zahl2; …)' },
|
{ name: 'COUNT2', hint: 'COUNT2(Text1; Zahl2; …)' },
|
||||||
|
{ name: 'CONCAT', hint: 'CONCAT(Text1; Text2; …)' },
|
||||||
|
{ name: 'CONCATENATE', hint: 'CONCATENATE(Text1; Text2; …)' },
|
||||||
{ name: 'IF', hint: 'IF(Bedingung; Dann; Sonst?)' },
|
{ name: 'IF', hint: 'IF(Bedingung; Dann; Sonst?)' },
|
||||||
{ name: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' },
|
{ name: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' },
|
||||||
{ name: 'TIME', hint: 'TIME(Std; Min; Sek)' },
|
{ name: 'TIME', hint: 'TIME(Std; Min; Sek)' },
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export {
|
|||||||
type Node,
|
type Node,
|
||||||
type CellNode,
|
type CellNode,
|
||||||
type RangeNode,
|
type RangeNode,
|
||||||
|
type NamedRefNode,
|
||||||
type BlockNode,
|
type BlockNode,
|
||||||
} from './parser';
|
} from './parser';
|
||||||
export { evaluate } from './evaluator';
|
export { evaluate } from './evaluator';
|
||||||
@@ -63,4 +64,5 @@ export {
|
|||||||
type CellValue,
|
type CellValue,
|
||||||
type Scalar,
|
type Scalar,
|
||||||
type EngineHost,
|
type EngineHost,
|
||||||
|
type NamedRangeDef,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type TokenType =
|
|||||||
| 'STAR'
|
| 'STAR'
|
||||||
| 'SLASH'
|
| 'SLASH'
|
||||||
| 'CARET'
|
| 'CARET'
|
||||||
|
| 'AMP' // "&" -- text concatenation
|
||||||
| 'LPAREN'
|
| 'LPAREN'
|
||||||
| 'RPAREN'
|
| 'RPAREN'
|
||||||
| 'SEMICOLON'
|
| 'SEMICOLON'
|
||||||
@@ -48,6 +49,7 @@ const SINGLE: Record<string, TokenType> = {
|
|||||||
'*': 'STAR',
|
'*': 'STAR',
|
||||||
'/': 'SLASH',
|
'/': 'SLASH',
|
||||||
'^': 'CARET',
|
'^': 'CARET',
|
||||||
|
'&': 'AMP',
|
||||||
'(': 'LPAREN',
|
'(': 'LPAREN',
|
||||||
')': 'RPAREN',
|
')': 'RPAREN',
|
||||||
';': 'SEMICOLON',
|
';': 'SEMICOLON',
|
||||||
|
|||||||
+95
-19
@@ -7,7 +7,7 @@
|
|||||||
* formula = "=" expression ;
|
* formula = "=" expression ;
|
||||||
*
|
*
|
||||||
* expression = comparison ;
|
* expression = comparison ;
|
||||||
* comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ;
|
* comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ;
|
||||||
* (* at most one comparison,
|
* (* at most one comparison,
|
||||||
* not chainable like
|
* not chainable like
|
||||||
* "a < b < c"; mainly
|
* "a < b < c"; mainly
|
||||||
@@ -15,6 +15,12 @@
|
|||||||
* function body's IF
|
* function body's IF
|
||||||
* condition, but valid
|
* condition, but valid
|
||||||
* in any formula *)
|
* in any formula *)
|
||||||
|
* concat = additive { "&" additive } ; (* left assoc; text
|
||||||
|
* concatenation, binds
|
||||||
|
* looser than "+ -" but
|
||||||
|
* tighter than a
|
||||||
|
* comparison -- same
|
||||||
|
* slot Excel gives "&" *)
|
||||||
* additive = term { ("+" | "-") term } ; (* left assoc *)
|
* additive = term { ("+" | "-") term } ; (* left assoc *)
|
||||||
* term = unary { ("*" | "/") unary } ; (* left assoc *)
|
* term = unary { ("*" | "/") unary } ; (* left assoc *)
|
||||||
* unary = ("+" | "-") unary
|
* unary = ("+" | "-") unary
|
||||||
@@ -58,15 +64,21 @@
|
|||||||
* see reference() below). *)
|
* see reference() below). *)
|
||||||
* CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
* CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
||||||
*
|
*
|
||||||
* Precedence, lowest to highest: comparison < + - < * / < unary +/- < ^
|
* Precedence, lowest to highest: comparison < & < + - < * / < unary +/- < ^
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
*
|
*
|
||||||
* `primary` also accepts a bare IDENT as a *parameter reference* -- but only
|
* `primary` also accepts a bare IDENT as a *parameter reference* -- but only
|
||||||
* when parseFormula() is called with a `paramNames` list (used to parse a
|
* when parseFormula() is called with a `paramNames` list (used to parse a
|
||||||
* user-defined function's body; see src/engine/userFunctions.ts). Without
|
* user-defined function's body; see src/engine/userFunctions.ts). Without
|
||||||
* that list -- i.e. for every ordinary cell formula -- a bare identifier
|
* that list -- i.e. for every ordinary cell formula -- a bare, unqualified
|
||||||
* that doesn't look like a cell address is still a ParseError, exactly as
|
* IDENT that doesn't look like a cell address is instead a *named-range*
|
||||||
* before.
|
* reference (`NamedRefNode`, see below): the parser has no opinion on
|
||||||
|
* whether the name actually exists (same as it never validates a table
|
||||||
|
* qualifier) -- an unresolvable one surfaces as `#NAME?` only at evaluation
|
||||||
|
* time (see evaluator.ts's `resolveNamedRef()`). A *qualified* non-cell
|
||||||
|
* identifier (`Tabelle!Foo`) still is a `ParseError` -- a named range is
|
||||||
|
* always unqualified, so a qualifier always means "this must be a real
|
||||||
|
* cell".
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -81,6 +93,7 @@ export type Node =
|
|||||||
| FuncNode
|
| FuncNode
|
||||||
| CellNode
|
| CellNode
|
||||||
| RangeNode
|
| RangeNode
|
||||||
|
| NamedRefNode
|
||||||
| ParamNode
|
| ParamNode
|
||||||
| BlockCallNode
|
| BlockCallNode
|
||||||
| OmittedNode;
|
| OmittedNode;
|
||||||
@@ -95,7 +108,7 @@ export interface StringNode {
|
|||||||
}
|
}
|
||||||
export interface BinaryNode {
|
export interface BinaryNode {
|
||||||
kind: 'binary';
|
kind: 'binary';
|
||||||
op: '+' | '-' | '*' | '/' | '^' | '=' | '<>' | '>' | '<' | '>=' | '<=';
|
op: '+' | '-' | '*' | '/' | '^' | '&' | '=' | '<>' | '>' | '<' | '>=' | '<=';
|
||||||
left: Node;
|
left: Node;
|
||||||
right: Node;
|
right: Node;
|
||||||
}
|
}
|
||||||
@@ -131,6 +144,17 @@ export interface RangeNode {
|
|||||||
start: { col: number; row: number };
|
start: { col: number; row: number };
|
||||||
end: { col: number; row: number };
|
end: { col: number; row: number };
|
||||||
}
|
}
|
||||||
|
/** A bare, unqualified name that isn't a param and doesn't look like a cell
|
||||||
|
* -- a candidate reference to a document-wide *named range* (`Umsatz_Q1`
|
||||||
|
* instead of `Umsatz!B2:B13`), created/edited/removed through the "🏷
|
||||||
|
* Namen" UI (see `src/store/types.ts`'s `NamedRange`). The parser doesn't
|
||||||
|
* know the document's named ranges (same as it never validates a table
|
||||||
|
* qualifier) -- resolved live, by name, at evaluation/dependency-collection
|
||||||
|
* time via `EngineHost.resolveNamedRange()`; an unknown name is `#NAME?`. */
|
||||||
|
export interface NamedRefNode {
|
||||||
|
kind: 'namedref';
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
/** A reference to a user-defined function's own parameter -- only produced
|
/** A reference to a user-defined function's own parameter -- only produced
|
||||||
* when parseFormula() is given a `paramNames` list (parsing a function
|
* when parseFormula() is given a `paramNames` list (parsing a function
|
||||||
* body); never appears in an ordinary cell formula's AST. Resolved away by
|
* body); never appears in an ordinary cell formula's AST. Resolved away by
|
||||||
@@ -237,8 +261,19 @@ class Parser {
|
|||||||
* -- a plain expression body (parseFormula()'s normal path) never mutates
|
* -- a plain expression body (parseFormula()'s normal path) never mutates
|
||||||
* this, so its behaviour is completely unaffected. */
|
* this, so its behaviour is completely unaffected. */
|
||||||
private readonly paramNames: Set<string>;
|
private readonly paramNames: Set<string>;
|
||||||
constructor(private readonly toks: Token[], paramNames: readonly string[] = []) {
|
/** Whether a bare, unqualified, non-cell, non-param identifier falls back
|
||||||
|
* to a `NamedRefNode` (an ordinary top-level formula) instead of a hard
|
||||||
|
* `ParseError` (parsing a function body) -- see reference()'s named-range
|
||||||
|
* branch and parseFormula()'s doc comment for why function bodies opt
|
||||||
|
* out of the fallback. */
|
||||||
|
private readonly namedRefFallback: boolean;
|
||||||
|
constructor(
|
||||||
|
private readonly toks: Token[],
|
||||||
|
paramNames: readonly string[] = [],
|
||||||
|
namedRefFallback = true,
|
||||||
|
) {
|
||||||
this.paramNames = new Set(paramNames.map((p) => p.toUpperCase()));
|
this.paramNames = new Set(paramNames.map((p) => p.toUpperCase()));
|
||||||
|
this.namedRefFallback = namedRefFallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
private peek(): Token {
|
private peek(): Token {
|
||||||
@@ -364,11 +399,11 @@ class Parser {
|
|||||||
* parens, assignment RHS, ...) -- wraps `additive` with an optional
|
* parens, assignment RHS, ...) -- wraps `additive` with an optional
|
||||||
* single comparison, the loosest-binding operator. */
|
* single comparison, the loosest-binding operator. */
|
||||||
private expression(): Node {
|
private expression(): Node {
|
||||||
const left = this.additive();
|
const left = this.concat();
|
||||||
const opType = this.comparisonOpType();
|
const opType = this.comparisonOpType();
|
||||||
if (!opType) return left;
|
if (!opType) return left;
|
||||||
this.next();
|
this.next();
|
||||||
return { kind: 'binary', op: opType, left, right: this.additive() };
|
return { kind: 'binary', op: opType, left, right: this.concat() };
|
||||||
}
|
}
|
||||||
|
|
||||||
private comparisonOpType(): BinaryNode['op'] | null {
|
private comparisonOpType(): BinaryNode['op'] | null {
|
||||||
@@ -390,6 +425,19 @@ class Parser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `&` -- text concatenation, left-associative. Binds looser than `+ -`
|
||||||
|
* (so `="a"&1+2` is `"a"&(1+2)` = `"a3"`, not `("a"&1)+2`) but tighter
|
||||||
|
* than a comparison (so `=A1&B1="ab"` is `(A1&B1)="ab"`, not
|
||||||
|
* `A1&(B1="ab")`) -- same slot Excel gives `&`. */
|
||||||
|
private concat(): Node {
|
||||||
|
let left = this.additive();
|
||||||
|
while (this.at('AMP')) {
|
||||||
|
this.next();
|
||||||
|
left = { kind: 'binary', op: '&', left, right: this.additive() };
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
/** The old `expression` rule -- `+`/`-`, left-associative -- renamed once
|
/** The old `expression` rule -- `+`/`-`, left-associative -- renamed once
|
||||||
* `expression` itself gained the comparison wrapper above it. */
|
* `expression` itself gained the comparison wrapper above it. */
|
||||||
private additive(): Node {
|
private additive(): Node {
|
||||||
@@ -536,6 +584,19 @@ class Parser {
|
|||||||
if (qualifier === null && this.paramNames.has(a.value.toUpperCase())) {
|
if (qualifier === null && this.paramNames.has(a.value.toUpperCase())) {
|
||||||
return { kind: 'param', name: a.value };
|
return { kind: 'param', name: a.value };
|
||||||
}
|
}
|
||||||
|
// an unqualified, non-cell-shaped bare name -- a named-range candidate
|
||||||
|
// (see NamedRefNode's doc comment), but only for an ordinary top-level
|
||||||
|
// formula (namedRefFallback); a function body keeps the strict old
|
||||||
|
// behaviour so e.g. a local used before its own declaring statement
|
||||||
|
// still fails immediately at save time instead of silently becoming a
|
||||||
|
// named-range lookup (or worse, one that happens to resolve because a
|
||||||
|
// same-named named range exists elsewhere in the document) -- a caller
|
||||||
|
// can still *pass* a named range as an argument, it just can't be typed
|
||||||
|
// bare inside the body itself. Never a range-start either way: "Name:B2"
|
||||||
|
// isn't supported, so this doesn't fall through to the ":"-range check.
|
||||||
|
if (qualifier === null && !looksLikeCell(a.value) && this.namedRefFallback) {
|
||||||
|
return { kind: 'namedref', 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})`);
|
||||||
}
|
}
|
||||||
@@ -570,7 +631,13 @@ class Parser {
|
|||||||
* `paramNames`, when given, is a user-defined function's parameter list --
|
* `paramNames`, when given, is a user-defined function's parameter list --
|
||||||
* bare identifiers matching one of them parse as a {@link ParamNode} instead
|
* bare identifiers matching one of them parse as a {@link ParamNode} instead
|
||||||
* of requiring cell-address shape. Omit it (the default) for every ordinary
|
* of requiring cell-address shape. Omit it (the default) for every ordinary
|
||||||
* cell formula; behaviour is then identical to before this existed.
|
* cell formula; behaviour is then identical to before this existed, *except*
|
||||||
|
* that a bare, unqualified, non-cell identifier now parses as a
|
||||||
|
* {@link NamedRefNode} (a named-range candidate) instead of a ParseError --
|
||||||
|
* see NamedRefNode's doc comment. That fallback only applies when
|
||||||
|
* `paramNames` is omitted entirely (`undefined`) -- passing it, even as an
|
||||||
|
* empty array, means "this is a function body", where the fallback is
|
||||||
|
* deliberately off (see the Parser class's `namedRefFallback` field).
|
||||||
*/
|
*/
|
||||||
export function parseFormula(src: string, paramNames?: readonly 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;
|
||||||
@@ -581,7 +648,7 @@ export function parseFormula(src: string, paramNames?: readonly 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, paramNames).parse();
|
return new Parser(toks, paramNames, paramNames === undefined).parse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -601,18 +668,27 @@ export function parseFunctionBlock(src: string, paramNames: readonly string[]):
|
|||||||
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, paramNames).parseBlock();
|
return new Parser(toks, paramNames, false).parseBlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Walk an AST and collect every cell/range reference node it contains.
|
/** Walk an AST and collect every cell/range/named-range reference node it
|
||||||
* For a `blockcall`, walks both its (already-resolved) `args` and every
|
* contains. For a `blockcall`, walks both its (already-resolved) `args` and
|
||||||
* expression reachable inside its `block` -- conservatively: a reference
|
* every expression reachable inside its `block` -- conservatively: a
|
||||||
* inside a branch/loop that might not execute at runtime still counts as a
|
* reference inside a branch/loop that might not execute at runtime still
|
||||||
* dependency, same as an ordinary `IF`-style formula in any spreadsheet. */
|
* counts as a dependency, same as an ordinary `IF`-style formula in any
|
||||||
export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []): Array<CellNode | RangeNode> {
|
* spreadsheet. A `NamedRefNode` is collected as-is, unresolved -- this
|
||||||
|
* function is host-agnostic (doesn't know the document's named ranges),
|
||||||
|
* same as it never resolves a table qualifier either; the caller
|
||||||
|
* (`Workbook.precedentsOf`) is the one that turns it into concrete cell
|
||||||
|
* keys via `resolveNamedRange()`. */
|
||||||
|
export function collectRefs(
|
||||||
|
node: Node,
|
||||||
|
out: Array<CellNode | RangeNode | NamedRefNode> = [],
|
||||||
|
): Array<CellNode | RangeNode | NamedRefNode> {
|
||||||
switch (node.kind) {
|
switch (node.kind) {
|
||||||
case 'cell':
|
case 'cell':
|
||||||
case 'range':
|
case 'range':
|
||||||
|
case 'namedref':
|
||||||
out.push(node);
|
out.push(node);
|
||||||
break;
|
break;
|
||||||
case 'binary':
|
case 'binary':
|
||||||
@@ -633,7 +709,7 @@ export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []):
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectRefsInBlock(block: BlockNode, out: Array<CellNode | RangeNode>): void {
|
function collectRefsInBlock(block: BlockNode, out: Array<CellNode | RangeNode | NamedRefNode>): void {
|
||||||
for (const stmt of block.statements) {
|
for (const stmt of block.statements) {
|
||||||
switch (stmt.kind) {
|
switch (stmt.kind) {
|
||||||
case 'assign':
|
case 'assign':
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ export interface CellRef extends Coord {
|
|||||||
tableId: string;
|
tableId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A document-defined name resolving to a fixed cell/range on a specific
|
||||||
|
* table -- fed into {@link Workbook} (see workbook.ts) so it can implement
|
||||||
|
* {@link EngineHost.resolveNamedRange}. Unlike a custom function, there is
|
||||||
|
* no expression/formula of its own, just fixed coordinates -- so there is
|
||||||
|
* nothing that could cyclically reference itself. `tableId` (not a table
|
||||||
|
* *name*) so a named range survives its target table being renamed; the
|
||||||
|
* host resolves it to that table's *current* name on lookup, the same
|
||||||
|
* qualifier a formula's own `Tabelle!Zelle` reference would carry. */
|
||||||
|
export interface NamedRangeDef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tableId: string;
|
||||||
|
start: Coord;
|
||||||
|
end: Coord;
|
||||||
|
}
|
||||||
|
|
||||||
/** Stable string key for a cell across the whole document. */
|
/** Stable string key for a cell across the whole document. */
|
||||||
export function keyOf(tableId: string, col: number, row: number): string {
|
export function keyOf(tableId: string, col: number, row: number): string {
|
||||||
return `${tableId}␟${col}␟${row}`;
|
return `${tableId}␟${col}␟${row}`;
|
||||||
@@ -99,6 +115,22 @@ export interface EngineHost {
|
|||||||
* (`#REF` -- the formula needs an explicit sheet qualifier).
|
* (`#REF` -- the formula needs an explicit sheet qualifier).
|
||||||
*/
|
*/
|
||||||
resolveTableName(name: string, sheetName?: string | null): string | null;
|
resolveTableName(name: string, sheetName?: string | null): string | null;
|
||||||
|
/**
|
||||||
|
* Resolve a *named range* (as written, unqualified, in a formula -- e.g.
|
||||||
|
* `Umsatz_Q1`) to the table-qualified cell/range it currently stands for.
|
||||||
|
* Matching is case-insensitive and tolerant of surrounding whitespace,
|
||||||
|
* same as {@link resolveTableName}. `null` when the name is unknown (or
|
||||||
|
* its target table has since been deleted) -- the caller turns that into
|
||||||
|
* `#NAME?`, the same error class an unknown function name gets.
|
||||||
|
*
|
||||||
|
* Resolved by *name*, live, every time -- never baked into the AST -- so a
|
||||||
|
* named range survives its target table being renamed, the same way a
|
||||||
|
* `Tabelle!Zelle` reference's table name is looked up fresh on every
|
||||||
|
* evaluation rather than resolved once at parse time.
|
||||||
|
*/
|
||||||
|
resolveNamedRange(
|
||||||
|
name: string,
|
||||||
|
): { qualifier: string; sheetQualifier?: string; start: Coord; end: Coord } | null;
|
||||||
/** Dimensions of a table, used for range clamping and #REF detection. */
|
/** Dimensions of a table, used for range clamping and #REF detection. */
|
||||||
tableSize(tableId: string): { cols: number; rows: number } | null;
|
tableSize(tableId: string): { cols: number; rows: number } | null;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ function expand(
|
|||||||
case 'string':
|
case 'string':
|
||||||
case 'cell':
|
case 'cell':
|
||||||
case 'range':
|
case 'range':
|
||||||
|
case 'namedref':
|
||||||
case 'param':
|
case 'param':
|
||||||
case 'omitted':
|
case 'omitted':
|
||||||
return node; // leaves -- nothing to expand
|
return node; // leaves -- nothing to expand
|
||||||
@@ -180,6 +181,7 @@ export function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
|
|||||||
case 'string':
|
case 'string':
|
||||||
case 'cell':
|
case 'cell':
|
||||||
case 'range':
|
case 'range':
|
||||||
|
case 'namedref':
|
||||||
case 'omitted':
|
case 'omitted':
|
||||||
return node;
|
return node;
|
||||||
case 'unary':
|
case 'unary':
|
||||||
@@ -293,16 +295,22 @@ export function declaredBlockNames(block: BlockNode, out: string[] = []): string
|
|||||||
|
|
||||||
/** The first FOR loop, anywhere in the block (including nested inside
|
/** The first FOR loop, anywhere in the block (including nested inside
|
||||||
* another FOR/IF), whose source isn't (syntactically) a parameter, a
|
* another FOR/IF), whose source isn't (syntactically) a parameter, a
|
||||||
* single cell, or a range -- i.e. can never resolve to a concrete list of
|
* single cell, a range, or a named-range reference -- i.e. can never
|
||||||
* cells no matter what the caller passes. Checked at save time so this is
|
* resolve to a concrete list of cells no matter what the caller passes.
|
||||||
* caught immediately, not just on the first real call; the evaluator's
|
* Checked at save time so this is caught immediately, not just on the
|
||||||
|
* first real call; the evaluator's
|
||||||
* interpreter re-checks the same thing on the *substituted* source at call
|
* interpreter re-checks the same thing on the *substituted* source at call
|
||||||
* time regardless (needed either way, since a parameter's shape isn't
|
* time regardless (needed either way, since a parameter's shape isn't
|
||||||
* known until then) as the actual enforcement. */
|
* known until then) as the actual enforcement. */
|
||||||
export function findInvalidForSource(block: BlockNode): ForNode | null {
|
export function findInvalidForSource(block: BlockNode): ForNode | null {
|
||||||
for (const stmt of block.statements) {
|
for (const stmt of block.statements) {
|
||||||
if (stmt.kind === 'for') {
|
if (stmt.kind === 'for') {
|
||||||
if (stmt.source.kind !== 'param' && stmt.source.kind !== 'cell' && stmt.source.kind !== 'range') {
|
if (
|
||||||
|
stmt.source.kind !== 'param' &&
|
||||||
|
stmt.source.kind !== 'cell' &&
|
||||||
|
stmt.source.kind !== 'range' &&
|
||||||
|
stmt.source.kind !== 'namedref'
|
||||||
|
) {
|
||||||
return stmt;
|
return stmt;
|
||||||
}
|
}
|
||||||
const nested = findInvalidForSource(stmt.body);
|
const nested = findInvalidForSource(stmt.body);
|
||||||
|
|||||||
+31
-5
@@ -31,6 +31,7 @@ import {
|
|||||||
parseKey,
|
parseKey,
|
||||||
type CellValue,
|
type CellValue,
|
||||||
type EngineHost,
|
type EngineHost,
|
||||||
|
type NamedRangeDef,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { evaluate } from './evaluator';
|
import { evaluate } from './evaluator';
|
||||||
|
|
||||||
@@ -81,10 +82,17 @@ export class Workbook implements EngineHost {
|
|||||||
private readonly values = new Map<string, CellValue>();
|
private readonly values = new Map<string, CellValue>();
|
||||||
/** user-defined function library for this document, name.toUpperCase() -> def. */
|
/** user-defined function library for this document, name.toUpperCase() -> def. */
|
||||||
private readonly functionIndex: Map<string, FunctionDef>;
|
private readonly functionIndex: Map<string, FunctionDef>;
|
||||||
|
/** document-wide named ranges (see NamedRangeDef's doc comment). */
|
||||||
|
private readonly namedRanges: NamedRangeDef[];
|
||||||
|
|
||||||
constructor(tables: TableModel[], functionDefs: FunctionDef[] = []) {
|
constructor(
|
||||||
|
tables: TableModel[],
|
||||||
|
functionDefs: FunctionDef[] = [],
|
||||||
|
namedRanges: NamedRangeDef[] = [],
|
||||||
|
) {
|
||||||
this.tables = tables;
|
this.tables = tables;
|
||||||
this.functionIndex = buildFunctionIndex(functionDefs);
|
this.functionIndex = buildFunctionIndex(functionDefs);
|
||||||
|
this.namedRanges = namedRanges;
|
||||||
this.rebuild();
|
this.rebuild();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +120,17 @@ export class Workbook implements EngineHost {
|
|||||||
return null; // still ambiguous -> #REF, needs an explicit sheet qualifier
|
return null; // still ambiguous -> #REF, needs an explicit sheet qualifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolveNamedRange(
|
||||||
|
name: string,
|
||||||
|
): { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } } | null {
|
||||||
|
const needle = name.trim().toLowerCase();
|
||||||
|
const def = this.namedRanges.find((d) => d.name.trim().toLowerCase() === needle);
|
||||||
|
if (!def) return null;
|
||||||
|
const table = this.tableById(def.tableId);
|
||||||
|
if (!table) return null; // target table was since deleted -- #NAME?, same as a stale table rename
|
||||||
|
return { qualifier: table.name, sheetQualifier: table.sheetName, start: def.start, end: def.end };
|
||||||
|
}
|
||||||
|
|
||||||
tableSize(tableId: string): { cols: number; rows: number } | null {
|
tableSize(tableId: string): { cols: number; rows: number } | null {
|
||||||
const t = this.tableById(tableId);
|
const t = this.tableById(tableId);
|
||||||
return t ? { cols: t.cols, rows: t.rows } : null;
|
return t ? { cols: t.cols, rows: t.rows } : null;
|
||||||
@@ -201,13 +220,20 @@ export class Workbook implements EngineHost {
|
|||||||
this.homeTableId = tableId;
|
this.homeTableId = tableId;
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const ref of collectRefs(ast)) {
|
for (const ref of collectRefs(ast)) {
|
||||||
|
// a named range isn't itself a qualifier/coords pair -- resolve it to
|
||||||
|
// one first (same shape as a 'cell'/'range' ref), then fall through to
|
||||||
|
// the identical edge-wiring below.
|
||||||
|
const resolved = ref.kind === 'namedref' ? this.resolveNamedRange(ref.name) : ref;
|
||||||
|
if (!resolved) continue; // unknown name -> no edge; evaluator emits #NAME?
|
||||||
const target =
|
const target =
|
||||||
ref.qualifier == null ? tableId : this.resolveTableName(ref.qualifier, ref.sheetQualifier ?? null);
|
resolved.qualifier == null
|
||||||
|
? tableId
|
||||||
|
: this.resolveTableName(resolved.qualifier, resolved.sheetQualifier ?? null);
|
||||||
if (!target) continue; // unknown table -> no edge; evaluator emits #REF
|
if (!target) continue; // unknown table -> no edge; evaluator emits #REF
|
||||||
if (ref.kind === 'cell') {
|
if ('col' in resolved) {
|
||||||
out.push(keyOf(target, ref.col, ref.row));
|
out.push(keyOf(target, resolved.col, resolved.row));
|
||||||
} else {
|
} else {
|
||||||
for (const c of expandRange(ref.start, ref.end)) {
|
for (const c of expandRange(resolved.start, resolved.end)) {
|
||||||
out.push(keyOf(target, c.col, c.row));
|
out.push(keyOf(target, c.col, c.row));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
CellCoord,
|
||||||
CellFormat,
|
CellFormat,
|
||||||
ChartObject,
|
ChartObject,
|
||||||
CustomFunction,
|
CustomFunction,
|
||||||
DocumentState,
|
DocumentState,
|
||||||
HAlign,
|
HAlign,
|
||||||
|
NamedRange,
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetObject,
|
SheetObject,
|
||||||
TableObject,
|
TableObject,
|
||||||
@@ -58,6 +60,46 @@ function normaliseCustomFunctions(x: unknown): CustomFunction[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCellCoord(x: unknown): x is CellCoord {
|
||||||
|
return (
|
||||||
|
!!x &&
|
||||||
|
typeof x === 'object' &&
|
||||||
|
typeof (x as CellCoord).col === 'number' &&
|
||||||
|
typeof (x as CellCoord).row === 'number'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Defaults to `[]` -- absent in every document saved before this feature
|
||||||
|
* existed. Drops a malformed entry, and any entry whose `tableId` doesn't
|
||||||
|
* match a real table *in this document* (a dangling reference -- e.g. a
|
||||||
|
* hand-edited file, or a table that no longer exists) -- rather than
|
||||||
|
* leaving it to resolve to `#NAME?` forever; the live "delete a table"
|
||||||
|
* path (`document.ts`) does leave such entries in place, for consistency
|
||||||
|
* with how a deleted table's ordinary formula references are never
|
||||||
|
* auto-pruned either -- this is stricter only because *importing* JSON is
|
||||||
|
* already a validating boundary for everything else in the document. */
|
||||||
|
function normaliseNamedRanges(x: unknown, tableIds: ReadonlySet<string>): NamedRange[] {
|
||||||
|
if (!Array.isArray(x)) return [];
|
||||||
|
const out: NamedRange[] = [];
|
||||||
|
for (const r of x) {
|
||||||
|
if (
|
||||||
|
!r ||
|
||||||
|
typeof r !== 'object' ||
|
||||||
|
typeof (r as { id?: unknown }).id !== 'string' ||
|
||||||
|
typeof (r as { name?: unknown }).name !== 'string' ||
|
||||||
|
typeof (r as { tableId?: unknown }).tableId !== 'string' ||
|
||||||
|
!isCellCoord((r as { start?: unknown }).start) ||
|
||||||
|
!isCellCoord((r as { end?: unknown }).end)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rec = r as NamedRange;
|
||||||
|
if (!tableIds.has(rec.tableId)) continue;
|
||||||
|
out.push({ id: rec.id, name: rec.name, tableId: rec.tableId, start: { ...rec.start }, end: { ...rec.end } });
|
||||||
|
}
|
||||||
|
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;
|
||||||
@@ -293,6 +335,7 @@ export function validateDocument(x: unknown): DocumentState {
|
|||||||
|
|
||||||
const sheetIds = new Set<string>();
|
const sheetIds = new Set<string>();
|
||||||
const objectIds = new Set<string>();
|
const objectIds = new Set<string>();
|
||||||
|
const tableIds = new Set<string>();
|
||||||
for (const s of doc.sheets) {
|
for (const s of doc.sheets) {
|
||||||
if (!s || typeof s.id !== 'string' || typeof s.name !== 'string' || !Array.isArray(s.objects))
|
if (!s || typeof s.id !== 'string' || typeof s.name !== 'string' || !Array.isArray(s.objects))
|
||||||
throw new JsonImportError('Mindestens ein Blatt ist fehlerhaft.');
|
throw new JsonImportError('Mindestens ein Blatt ist fehlerhaft.');
|
||||||
@@ -303,6 +346,7 @@ export function validateDocument(x: unknown): DocumentState {
|
|||||||
throw new JsonImportError('Mindestens ein Objekt ist fehlerhaft.');
|
throw new JsonImportError('Mindestens ein Objekt ist fehlerhaft.');
|
||||||
if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.');
|
if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.');
|
||||||
objectIds.add(o.id);
|
objectIds.add(o.id);
|
||||||
|
if (isTableObject(o)) tableIds.add(o.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof doc.activeSheetId !== 'string' || !sheetIds.has(doc.activeSheetId))
|
if (typeof doc.activeSheetId !== 'string' || !sheetIds.has(doc.activeSheetId))
|
||||||
@@ -330,6 +374,7 @@ export function validateDocument(x: unknown): DocumentState {
|
|||||||
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),
|
customFunctions: normaliseCustomFunctions(doc.customFunctions),
|
||||||
|
namedRanges: normaliseNamedRanges(doc.namedRanges, tableIds),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,6 +438,7 @@ export function migrateToV2(x: unknown): DocumentState {
|
|||||||
locale: DEFAULT_LOCALE,
|
locale: DEFAULT_LOCALE,
|
||||||
csvDelimiter: ',',
|
csvDelimiter: ',',
|
||||||
customFunctions: [],
|
customFunctions: [],
|
||||||
|
namedRanges: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -871,6 +871,138 @@ describe('custom functions (document-level library)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('named ranges (document-level names for a fixed cell/range)', () => {
|
||||||
|
it('addNamedRange stores the definition and a formula elsewhere resolves it unqualified', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'B2', '42');
|
||||||
|
|
||||||
|
const result = s.addNamedRange('Preis', id, coords('B2'), coords('B2'));
|
||||||
|
expect(typeof result).toBe('string'); // the new range's id, not { error }
|
||||||
|
expect(s.doc.namedRanges.find((r) => r.name === 'Preis')).toMatchObject({ tableId: id });
|
||||||
|
|
||||||
|
setCell(s, name, 'A1', '=Preis + 1');
|
||||||
|
expect(val(s, name, 'A1')).toBe(43);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a multi-cell named range works as a range function argument', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'B2', '10');
|
||||||
|
setCell(s, name, 'B3', '20');
|
||||||
|
s.addNamedRange('Bereich', id, coords('B2'), coords('B3'));
|
||||||
|
setCell(s, name, 'A1', '=SUM(Bereich)');
|
||||||
|
expect(val(s, name, 'A1')).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a name colliding with a built-in function', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const result = s.addNamedRange('SUM', id, coords('B2'), coords('B2')) as { error: string };
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a name colliding with an existing custom function', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
s.addCustomFunction('MARGE', ['x'], 'x');
|
||||||
|
const result = s.addNamedRange('MARGE', id, coords('B2'), coords('B2')) as { error: string };
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a name colliding with another named range (case-insensitive)', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
s.addNamedRange('Preis', id, coords('B2'), coords('B2'));
|
||||||
|
const result = s.addNamedRange('preis', id, coords('C2'), coords('C2')) as { error: string };
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a name that looks like a cell address', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const result = s.addNamedRange('B2', id, coords('C2'), coords('C2')) as { error: string };
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateNamedRange changes name/coords and existing formulas re-resolve', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'B2', '10');
|
||||||
|
setCell(s, name, 'C2', '99');
|
||||||
|
const rid = s.addNamedRange('Preis', id, coords('B2'), coords('B2')) as string;
|
||||||
|
setCell(s, name, 'A1', '=Preis');
|
||||||
|
expect(val(s, name, 'A1')).toBe(10);
|
||||||
|
|
||||||
|
s.updateNamedRange(rid, 'Preis', id, coords('C2'), coords('C2'));
|
||||||
|
expect(val(s, name, 'A1')).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteNamedRange removes the definition; existing formulas become #NAME?', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
const before = s.doc.namedRanges.length; // the seed already ships one ("Umsatz_Q1")
|
||||||
|
setCell(s, name, 'B2', '10');
|
||||||
|
const rid = s.addNamedRange('Preis', id, coords('B2'), coords('B2')) as string;
|
||||||
|
setCell(s, name, 'A1', '=Preis');
|
||||||
|
expect(val(s, name, 'A1')).toBe(10);
|
||||||
|
|
||||||
|
s.deleteNamedRange(rid);
|
||||||
|
expect(s.doc.namedRanges).toHaveLength(before);
|
||||||
|
expect(s.doc.namedRanges.find((r) => r.id === rid)).toBeUndefined();
|
||||||
|
expect(isError(val(s, name, 'A1'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives its target table being renamed', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'B2', '10');
|
||||||
|
s.addNamedRange('Preis', id, coords('B2'), coords('B2'));
|
||||||
|
setCell(s, name, 'A1', '=Preis');
|
||||||
|
expect(val(s, name, 'A1')).toBe(10);
|
||||||
|
|
||||||
|
s.renameObject(id, 'Verkauf');
|
||||||
|
expect(val(s, 'Verkauf', 'A1')).toBe(10); // still resolves, no #NAME?/#REF
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo/redo covers a named-range change', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
vi.advanceTimersByTime(600); // let addTable settle into its own history step first
|
||||||
|
const before = s.doc.namedRanges.length; // the seed already ships one ("Umsatz_Q1")
|
||||||
|
|
||||||
|
s.addNamedRange('Preis', id, coords('B2'), coords('B2'));
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
expect(s.doc.namedRanges).toHaveLength(before + 1);
|
||||||
|
|
||||||
|
s.undo();
|
||||||
|
expect(s.doc.namedRanges).toHaveLength(before);
|
||||||
|
s.redo();
|
||||||
|
expect(s.doc.namedRanges).toHaveLength(before + 1);
|
||||||
|
expect(s.doc.namedRanges.find((r) => r.name === 'Preis')).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JSON export/import round-trips namedRanges losslessly', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
s.addNamedRange('Preis', id, coords('B2'), coords('B2'));
|
||||||
|
const before = JSON.stringify(s.doc.namedRanges);
|
||||||
|
s.replaceDocument(parseDocument(serializeDocument(s.doc)));
|
||||||
|
expect(JSON.stringify(s.doc.namedRanges)).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('functionSuggestions (formula autocomplete)', () => {
|
describe('functionSuggestions (formula autocomplete)', () => {
|
||||||
it('filters built-ins by prefix, case-insensitively', () => {
|
it('filters built-ins by prefix, case-insensitively', () => {
|
||||||
const s = fresh();
|
const s = fresh();
|
||||||
|
|||||||
+101
-2
@@ -39,6 +39,7 @@ import {
|
|||||||
type TableModel,
|
type TableModel,
|
||||||
type Token,
|
type Token,
|
||||||
type FunctionDef,
|
type FunctionDef,
|
||||||
|
type NamedRangeDef,
|
||||||
} from '../engine';
|
} from '../engine';
|
||||||
import {
|
import {
|
||||||
formatValue,
|
formatValue,
|
||||||
@@ -104,6 +105,16 @@ function toFunctionDefs(doc: DocumentState): FunctionDef[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toNamedRangeDefs(doc: DocumentState): NamedRangeDef[] {
|
||||||
|
return doc.namedRanges.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
tableId: r.tableId,
|
||||||
|
start: r.start,
|
||||||
|
end: r.end,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** 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 };
|
||||||
|
|
||||||
@@ -210,7 +221,7 @@ function loadInitial(): DocumentState {
|
|||||||
|
|
||||||
function createStore() {
|
function createStore() {
|
||||||
const doc = reactive<DocumentState>(loadInitial());
|
const doc = reactive<DocumentState>(loadInitial());
|
||||||
let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
|
let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc));
|
||||||
const recalcTick = ref(0);
|
const recalcTick = ref(0);
|
||||||
const bump = () => (recalcTick.value += 1);
|
const bump = () => (recalcTick.value += 1);
|
||||||
|
|
||||||
@@ -230,7 +241,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), toFunctionDefs(doc));
|
wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc));
|
||||||
bump();
|
bump();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +291,7 @@ function createStore() {
|
|||||||
locale: d.locale,
|
locale: d.locale,
|
||||||
csvDelimiter: d.csvDelimiter,
|
csvDelimiter: d.csvDelimiter,
|
||||||
customFunctions: d.customFunctions,
|
customFunctions: d.customFunctions,
|
||||||
|
namedRanges: d.namedRanges,
|
||||||
});
|
});
|
||||||
let historySnapshot = snapshot();
|
let historySnapshot = snapshot();
|
||||||
let historyFingerprint = contentFingerprint(historySnapshot);
|
let historyFingerprint = contentFingerprint(historySnapshot);
|
||||||
@@ -865,6 +877,89 @@ function createStore() {
|
|||||||
syncWorkbook(); // affected calls become #NAME? on rebuild, same as a deleted table
|
syncWorkbook(); // affected calls become #NAME? on rebuild, same as a deleted table
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- named ranges (document-level names for a fixed cell/range) -------
|
||||||
|
// Created/edited/removed exclusively through the "🏷 Namen" UI. Unlike a
|
||||||
|
// custom function there is no expression of its own -- just fixed
|
||||||
|
// coordinates on a specific table -- so there's nothing that could
|
||||||
|
// cyclically reference itself, and no "does every path RETURN" check
|
||||||
|
// either. `tableId` (not the table's name) is what's stored, so a named
|
||||||
|
// range survives its target table being *renamed* (see
|
||||||
|
// engine/workbook.ts's `resolveNamedRange`); only an outright *deletion*
|
||||||
|
// of that table leaves it dangling -> #NAME? on the next rebuild, same
|
||||||
|
// "nothing is auto-rewritten" convention as a plain formula reference to a
|
||||||
|
// deleted table.
|
||||||
|
function validateNamedRange(
|
||||||
|
name: string,
|
||||||
|
tableId: string,
|
||||||
|
start: CellCoord,
|
||||||
|
end: CellCoord,
|
||||||
|
ignoreId: string | null,
|
||||||
|
): string | null {
|
||||||
|
const cleanName = name.trim();
|
||||||
|
if (!IDENT_RE.test(cleanName)) {
|
||||||
|
return `"${name}" ist kein gültiger Name.`;
|
||||||
|
}
|
||||||
|
if (looksLikeCell(cleanName)) {
|
||||||
|
return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Name nicht erlaubt.`;
|
||||||
|
}
|
||||||
|
if (RESERVED_NAMES.has(cleanName.toUpperCase())) {
|
||||||
|
return `"${cleanName}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Name verwendet werden.`;
|
||||||
|
}
|
||||||
|
if (hasFunction(cleanName)) {
|
||||||
|
return `"${cleanName}" ist bereits eine eingebaute Funktion und kann nicht überschrieben werden.`;
|
||||||
|
}
|
||||||
|
const fnCollision = doc.customFunctions.find(
|
||||||
|
(f) => f.name.toLowerCase() === cleanName.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (fnCollision) return `Es gibt bereits eine Funktion namens "${fnCollision.name}".`;
|
||||||
|
const rangeCollision = doc.namedRanges.find(
|
||||||
|
(r) => r.id !== ignoreId && r.name.toLowerCase() === cleanName.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (rangeCollision) return `Es gibt bereits einen Namen "${rangeCollision.name}".`;
|
||||||
|
if (!tableById(tableId)) return 'Tabelle nicht gefunden.';
|
||||||
|
if (start.col < 0 || start.row < 0 || end.col < 0 || end.row < 0) {
|
||||||
|
return 'Ungültiger Bereich.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNamedRange(
|
||||||
|
name: string,
|
||||||
|
tableId: string,
|
||||||
|
start: CellCoord,
|
||||||
|
end: CellCoord,
|
||||||
|
): string | { error: string } {
|
||||||
|
const error = validateNamedRange(name, tableId, start, end, null);
|
||||||
|
if (error) return { error };
|
||||||
|
const id = uid('nr');
|
||||||
|
doc.namedRanges.push({ id, name: name.trim(), tableId, start: { ...start }, end: { ...end } });
|
||||||
|
syncWorkbook();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNamedRange(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
tableId: string,
|
||||||
|
start: CellCoord,
|
||||||
|
end: CellCoord,
|
||||||
|
): void | { error: string } {
|
||||||
|
const r = doc.namedRanges.find((x) => x.id === id);
|
||||||
|
if (!r) return { error: 'Name nicht gefunden.' };
|
||||||
|
const error = validateNamedRange(name, tableId, start, end, id);
|
||||||
|
if (error) return { error };
|
||||||
|
r.name = name.trim();
|
||||||
|
r.tableId = tableId;
|
||||||
|
r.start = { ...start };
|
||||||
|
r.end = { ...end };
|
||||||
|
syncWorkbook();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNamedRange(id: string) {
|
||||||
|
doc.namedRanges = doc.namedRanges.filter((r) => r.id !== id);
|
||||||
|
syncWorkbook(); // affected formulas 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;
|
||||||
@@ -1616,6 +1711,7 @@ function createStore() {
|
|||||||
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 ?? [];
|
doc.customFunctions = next.customFunctions ?? [];
|
||||||
|
doc.namedRanges = next.namedRanges ?? [];
|
||||||
syncWorkbook();
|
syncWorkbook();
|
||||||
}
|
}
|
||||||
/** Loading a different document outright (JSON import "replace", "reset to
|
/** Loading a different document outright (JSON import "replace", "reset to
|
||||||
@@ -1710,6 +1806,9 @@ function createStore() {
|
|||||||
addCustomFunction,
|
addCustomFunction,
|
||||||
updateCustomFunction,
|
updateCustomFunction,
|
||||||
deleteCustomFunction,
|
deleteCustomFunction,
|
||||||
|
addNamedRange,
|
||||||
|
updateNamedRange,
|
||||||
|
deleteNamedRange,
|
||||||
resizeChart,
|
resizeChart,
|
||||||
bringToFront,
|
bringToFront,
|
||||||
setChartView,
|
setChartView,
|
||||||
|
|||||||
@@ -173,6 +173,25 @@ export interface CustomFunction {
|
|||||||
kind: 'expression' | 'block';
|
kind: 'expression' | 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A document-wide name that stands for a fixed cell/range on a specific
|
||||||
|
* table (`Umsatz_Q1` instead of `Umsatz!B2:B13`), usable unqualified in any
|
||||||
|
* ordinary top-level formula (not inside a custom function's own body --
|
||||||
|
* see engine/parser.ts's `namedRefFallback`). Created, edited and removed
|
||||||
|
* exclusively through the "🏷 Namen" UI. Unlike a `CustomFunction`, there is
|
||||||
|
* no expression to evaluate, just fixed coordinates -- so no cycle
|
||||||
|
* detection is needed. `tableId` (not the table's *name*) so a named range
|
||||||
|
* survives its target table being renamed -- see
|
||||||
|
* engine/workbook.ts's `resolveNamedRange`. */
|
||||||
|
export interface NamedRange {
|
||||||
|
id: string;
|
||||||
|
/** document-unique, compared case-insensitively; never a built-in/custom
|
||||||
|
* function name, never shaped like a cell reference (A1, $B2, ...). */
|
||||||
|
name: string;
|
||||||
|
tableId: string;
|
||||||
|
start: CellCoord;
|
||||||
|
end: CellCoord;
|
||||||
|
}
|
||||||
|
|
||||||
/** One entry in the formula-autocomplete dropdown (see document.ts's
|
/** One entry in the formula-autocomplete dropdown (see document.ts's
|
||||||
* `functionSuggestions()`). */
|
* `functionSuggestions()`). */
|
||||||
export interface FnSuggestion {
|
export interface FnSuggestion {
|
||||||
@@ -191,4 +210,5 @@ export interface DocumentState {
|
|||||||
locale: NumberLocale;
|
locale: NumberLocale;
|
||||||
csvDelimiter: ',' | ';';
|
csvDelimiter: ',' | ';';
|
||||||
customFunctions: CustomFunction[];
|
customFunctions: CustomFunction[];
|
||||||
|
namedRanges: NamedRange[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user