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:
|
||||
[`src/engine/README.md`](src/engine/README.md)s „Schleifen"-Abschnitt.
|
||||
|
||||
#### SUMIF/COUNTIF/AVERAGEIF nachbauen
|
||||
|
||||
Es gibt keine eingebauten `SUMIF`/`COUNTIF`/`AVERAGEIF` — aber die **„Mit
|
||||
Schleife"**-Funktionen reichen aus, um die **selbstbezügliche** Form
|
||||
nachzubauen: Kriterium und Aggregation laufen über **denselben** Bereich
|
||||
(„Summe/Anzahl/Mittelwert aller Werte in `bereich`, die eine Bedingung
|
||||
erfüllen"). Das deckt den häufigsten Alltagsfall ab — z. B. „Summe aller
|
||||
Umsätze über 100" oder „Anzahl der Zeilen mit Region = Nord", solange
|
||||
Kriterium und zu summierender Wert in derselben Zelle stehen.
|
||||
|
||||
```
|
||||
summe = 0
|
||||
FOR x IN bereich DO
|
||||
IF x > schwelle THEN
|
||||
summe = summe + x
|
||||
END
|
||||
END
|
||||
RETURN summe
|
||||
```
|
||||
|
||||
aufgerufen als `=SUMME_WENN(Umsatz!B2:B13; 100)` — das Excel-Äquivalent zu
|
||||
`SUMIF(Umsatz!B2:B13; ">100")` mit gleichem Kriterien- und Summenbereich.
|
||||
`COUNTIF`/`AVERAGEIF` folgen demselben Muster, nur mit `zaehler = zaehler + 1`
|
||||
statt `summe = summe + x`, bzw. am Ende `RETURN summe / zaehler` (mit
|
||||
`IF zaehler = 0 THEN RETURN 0 END`-Guard gegen `#DIV/0!`).
|
||||
|
||||
Seit dem Fix der Vergleichsoperatoren (s. u. „Formelgrammatik") funktioniert
|
||||
das Kriterium auch mit **Text** — case-insensitiv und getrimmt, wie bei
|
||||
`XLOOKUP`:
|
||||
|
||||
```
|
||||
anzahl = 0
|
||||
FOR zeile IN bereich DO
|
||||
IF zeile = kriterium THEN
|
||||
anzahl = anzahl + 1
|
||||
END
|
||||
END
|
||||
RETURN anzahl
|
||||
```
|
||||
|
||||
aufgerufen als `=ANZAHL_WENN(Umsatz!A2:A13; "Nord")` — zählt alle Zellen in
|
||||
`Umsatz!A2:A13`, deren Text „Nord" ist (unabhängig von Groß-/Kleinschreibung
|
||||
und Leerzeichen).
|
||||
|
||||
**Grenze, die (noch) bleibt:** ein Excel-`SUMIF(Kriterienbereich; Kriterium;
|
||||
Summenbereich)` mit **getrennten** Bereichen (Kriterium in Spalte A, zu
|
||||
summierender Wert in Spalte B) ist damit **nicht** nachbaubar — die Sprache
|
||||
hat kein `OFFSET`/`INDEX`-Äquivalent, um „die Zelle an derselben Position im
|
||||
anderen Bereich" zu adressieren; eine `FOR`-Schleife erreicht nur den
|
||||
Bereich, über den sie selbst iteriert. Siehe „Bekannte Grenzen" unten.
|
||||
|
||||
### Benannte Bereiche
|
||||
|
||||
Der Button **„🏷" (Benannte Bereiche)** in der Symbolleiste, direkt neben
|
||||
**„𝑓 Funktionen"**, öffnet die einzige Stelle, an der dokumentweite Namen für
|
||||
eine feste Zelle/einen festen Bereich angelegt, bearbeitet und gelöscht
|
||||
werden — z. B. `Umsatz_Q1` statt `Umsatz!B2:B6`. Danach in **jeder**
|
||||
Top-Level-Formel unqualifiziert nutzbar, ohne Tabellen-Präfix:
|
||||
`=SUM(Umsatz_Q1)` oder `=Umsatz_Q1 + 1` für eine einzelne Zelle. Das
|
||||
vorbefüllte Dokument bringt `Umsatz_Q1` (→ `Umsatz!B2:B6`) bereits als
|
||||
Beispiel mit.
|
||||
|
||||
- **Liste** zeigt jeden Namen als `Name → Tabelle (Blatt)!Bereich` — mit
|
||||
**Bearb.** und **🗑** wie bei den Funktionen.
|
||||
- **Formular**: Name, eine Tabelle (Auswahl über alle Blätter hinweg) und
|
||||
zwei Zellfelder (Start/Ende als A1-Notation, z. B. `B2` und `B6`); ein
|
||||
leeres zweites Feld bedeutet „nur die erste Zelle". **„+ Neuer Name"**
|
||||
übernimmt automatisch die aktuelle Zellauswahl (Tabelle + Bereich) als
|
||||
Vorbelegung, wenn eine Zelle/ein Bereich gerade markiert ist — analog zu
|
||||
Excels „Namen aus Auswahl erstellen".
|
||||
- **Validierung beim Speichern**: gültiger Name (keine Zahl/Zellform wie
|
||||
`A1`), kein reserviertes Schlüsselwort (`FOR`/`IN`/`DO`/`RETURN`), keine
|
||||
Kollision mit einem eingebauten Namen, einer benutzerdefinierten Funktion
|
||||
oder einem anderen benannten Bereich (case-insensitiv). **Kein
|
||||
Zyklus-Check nötig** — anders als eine benutzerdefinierte Funktion hat ein
|
||||
benannter Bereich keinen eigenen Ausdruck, nur feste Koordinaten, kann sich
|
||||
also nie selbst referenzieren.
|
||||
- **Überlebt eine Tabellen-Umbenennung.** Intern wird die Tabellen-*ID*
|
||||
gespeichert, nicht ihr Name — anders als ein normaler `Tabelle!Zelle`-Bezug
|
||||
im Formeltext (der bricht bei Umbenennung, wie eh und je) wird ein
|
||||
benannter Bereich beim Nachschlagen live auf den *aktuellen* Tabellennamen
|
||||
aufgelöst. Wird die Tabelle dagegen **gelöscht**, bleibt der Name bestehen
|
||||
und zeigt in Formeln danach `#NAME?` — „nichts wird automatisch
|
||||
umgeschrieben", dieselbe Konvention wie beim Löschen einer benutzerdefinierten
|
||||
Funktion oder Tabelle.
|
||||
- Vollständig **undo-/redo-fähig** und Teil des JSON-Exports/-Imports.
|
||||
|
||||
**Bewusst ohne** (Stand jetzt):
|
||||
- **Nicht innerhalb einer benutzerdefinierten Funktion nutzbar** — im
|
||||
Funktionskörper selbst bleibt ein unqualifizierter, nicht-zellförmiger
|
||||
Name ein harter Parse-Fehler, genau wie vor diesem Feature. Grund: sonst
|
||||
würde z. B. eine lokale Variable, die versehentlich vor ihrer eigenen
|
||||
Zuweisung verwendet wird, nicht mehr sofort beim Speichern auffallen,
|
||||
sondern erst zur Laufzeit als `#NAME?` — oder, schlimmer, sie würde
|
||||
still passend zu einem gleichnamigen benannten Bereich irgendwo im
|
||||
Dokument auflösen. Ein **Aufrufer** kann einen benannten Bereich aber
|
||||
ganz normal als Argument übergeben (`=MEINEFUNKTION(Umsatz_Q1)`) — die
|
||||
Einschränkung gilt nur fürs *Eintippen des bloßen Namens* im Funktionskörper
|
||||
selbst.
|
||||
- **Keine eigene Formel/kein Ausdruck** — nur eine feste Zelle/ein fester
|
||||
Bereich, kein benannter *berechneter* Wert (dafür gibt es weiterhin
|
||||
benutzerdefinierte Funktionen ohne Parameter, s. o.).
|
||||
- **Nicht in der Funktions-Autovervollständigung** — das Dropdown beim
|
||||
Tippen nach `=` schlägt bisher nur Funktionsnamen vor, keine benannten
|
||||
Bereiche.
|
||||
- **Kein „Bereich" als `Name:B2`** — ein benannter Bereich ist immer die
|
||||
ganze, vordefinierte Adresse; er kann nicht als Startpunkt einer neu
|
||||
getippten `:`-Bereichsangabe kombiniert werden.
|
||||
|
||||
### Zell-Formatierung
|
||||
|
||||
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
|
||||
@@ -327,7 +436,7 @@ fixierter Kopfzeile und fixierter Spalte A** angelegt, um das Feature zu zeigen.
|
||||
formula = "=" expression ;
|
||||
|
||||
expression = comparison ;
|
||||
comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchstens
|
||||
comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ; (* höchstens
|
||||
ein Vergleich, nicht
|
||||
verkettbar wie
|
||||
"a < b < c"; vor allem
|
||||
@@ -335,6 +444,12 @@ comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchsten
|
||||
„Mit Schleife"-Code
|
||||
(s. o.), aber in jeder
|
||||
Formel nutzbar *)
|
||||
concat = additive { "&" additive } ; (* links-assoziativ; Text-
|
||||
Verkettung, bindet
|
||||
schwächer als "+ -",
|
||||
stärker als ein
|
||||
Vergleich -- derselbe
|
||||
Platz wie bei Excel *)
|
||||
additive = term { ("+" | "-") term } ; (* links-assoziativ *)
|
||||
term = unary { ("*" | "/") unary } ; (* links-assoziativ *)
|
||||
unary = ("+" | "-") unary
|
||||
@@ -342,10 +457,19 @@ unary = ("+" | "-") unary
|
||||
power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet
|
||||
stärker als unäres Minus,
|
||||
d.h. -2^2 = -(2^2) = -4 *)
|
||||
primary = NUMBER | STRING | "(" expression ")" | funcall | reference ;
|
||||
primary = NUMBER | STRING | "(" expression ")" | funcall | reference | namedref ;
|
||||
|
||||
funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
|
||||
|
||||
namedref = IDENT ; (* ein unqualifizierter,
|
||||
nicht zellförmiger,
|
||||
nicht in `(` mündender
|
||||
Bezeichner -- ein
|
||||
benannter-Bereich-
|
||||
Kandidat, s. u.; nur
|
||||
außerhalb eines
|
||||
Funktionskörpers *)
|
||||
|
||||
reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
|
||||
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname *)
|
||||
sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt,
|
||||
@@ -362,9 +486,38 @@ QUOTED = /' ( [^'] | '' )* '/ ; (* Name mit Leerzeichen *)
|
||||
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
||||
```
|
||||
|
||||
Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `+ -` < `* /` <
|
||||
Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` <
|
||||
unäres `+ -` < `^`.
|
||||
|
||||
**Vergleiche** sind numerisch, wenn beide Seiten Zahlen sind (leere Zelle
|
||||
zählt als `0`), und **text**uell — case-insensitiv, getrimmt, wie bei
|
||||
`XLOOKUP` (s. u.) —, wenn beide Seiten Text sind (leere Zelle zählt dann als
|
||||
`''`). Eine echte Zahl gegen echten Text: `=`/`<>` antworten eindeutig
|
||||
falsch/wahr, `> < >= <=` ergeben `#VALUE!` (kein sinnvoller Vergleich über
|
||||
Typgrenzen). Nützlich z. B. für `=A1="Nord"` oder als Kriterium einer
|
||||
selbstgebauten `COUNTIF`/`SUMIF`-Funktion (s. „SUMIF/COUNTIF/AVERAGEIF
|
||||
nachbauen" oben).
|
||||
|
||||
**`&` verkettet als Text**, z. B. `=A1&" "&B1` oder `="Herr "&Name`. Eine Zahl
|
||||
wird dabei ohne Locale-Formatierung dargestellt (`5`, nicht `5,00`), eine
|
||||
leere Zelle trägt `''` bei (nicht `'0'`). Bindet schwächer als `+`/`-`
|
||||
(`="a"&1+2` = `"a3"`, nicht `"a1"+2`), aber stärker als ein Vergleich
|
||||
(`=A1&B1="ab"` prüft das *Ergebnis* der Verkettung, nicht `B1="ab"` allein).
|
||||
`CONCAT(Text1; Text2; …)`/`CONCATENATE(...)` machen dasselbe als Funktion,
|
||||
ohne Operator-Präzedenz zu beachten — nützlich mit vielen Teilen oder
|
||||
innerhalb eines „Mit Schleife"-Codes.
|
||||
|
||||
Ein unqualifizierter Bezeichner, der weder eine Zelle noch (mit
|
||||
`paramNames`) ein Parameter ist, parst als **`namedref`** — ein Kandidat für
|
||||
einen **benannten Bereich** (s. „Benannte Bereiche" oben), erst zur
|
||||
Auswertung/Abhängigkeits-Ermittlung gegen das Dokument aufgelöst (genau wie
|
||||
ein Tabellen-Qualifier nie beim Parsen selbst geprüft wird). Ein unbekannter
|
||||
Name ergibt `#NAME?`. **Nur außerhalb eines Funktionskörpers** — mit
|
||||
`paramNames` bleibt ein unqualifizierter, nicht-zellförmiger Bezeichner ein
|
||||
harter Parse-Fehler wie zuvor (s. „Benannte Bereiche"s „Bewusst ohne"). Ein
|
||||
*qualifizierter* nicht-zellförmiger Bezeichner (`Tabelle!Foo`) ist immer ein
|
||||
Parse-Fehler — ein benannter Bereich ist nie qualifiziert.
|
||||
|
||||
Der (innere) Qualifier ist der **Tabellenname**. `!` und der Numbers-Stil `::`
|
||||
sind dabei austauschbar und **case-/whitespace-tolerant**:
|
||||
`Kosten!A2` = `'Kosten'!A2` = `kosten!a2` = `Kosten::A2`.
|
||||
@@ -411,10 +564,10 @@ Vorschlag (fügt `NAME(` ein, bereit für die Argumente), `Esc` schließt nur
|
||||
das Dropdown, ohne die Bearbeitung selbst abzubrechen.
|
||||
|
||||
**Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`),
|
||||
`MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)`, `DATE(Jahr; Monat;
|
||||
Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`, `XLOOKUP(Suchwert;
|
||||
Suchbereich; Rückgabebereich; [WennNichtGefunden]; [Vergleichsmodus];
|
||||
[Suchmodus])` — plus alle über **„𝑓 Funktionen"** angelegten
|
||||
`MIN`, `MAX`, `COUNT`, `CONCAT` (Alias `CONCATENATE`), `IF(Bedingung; Dann;
|
||||
Sonst?)`, `DATE(Jahr; Monat; Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`,
|
||||
`XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden];
|
||||
[Vergleichsmodus]; [Suchmodus])` — plus alle über **„𝑓 Funktionen"** angelegten
|
||||
benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben),
|
||||
die sich für den Aufrufer identisch verhalten. Argumente gemischt:
|
||||
Einzelzellen, Bereiche (lokal oder `Name!…`), Zahl-Literale — z. B.
|
||||
@@ -712,7 +865,23 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
||||
Funktionsänderung, voller JSON-Round-Trip, `customFunctions: []` bei
|
||||
v1→v2-Migration.
|
||||
- **Vergleichsoperatoren** — alle sechs (`= <> > < >= <=`), Präzedenz
|
||||
unterhalb `+ -`, nicht verkettbar (`a < b < c` ist ein Parse-Fehler).
|
||||
unterhalb `+ -`, nicht verkettbar (`a < b < c` ist ein Parse-Fehler);
|
||||
numerischer Vergleich, Text-Vergleich (case-insensitiv/getrimmt), leere
|
||||
Zelle nimmt den Typ der Gegenseite an, Zahl-gegen-Text-Mismatch
|
||||
(`=`/`<>` eindeutig, `> < >= <=` → `#VALUE!`).
|
||||
- **`&`-Verkettung** — Präzedenz zwischen `+ -` und einem Vergleich,
|
||||
links-assoziativ, Zahl rendert locale-neutral, leere Zelle trägt `''` bei,
|
||||
Fehler propagieren; `CONCAT`/`CONCATENATE` als Funktions-Äquivalent.
|
||||
- **Benannte Bereiche** — Auflösung als Einzelzelle/Bereich (Skalar- und
|
||||
Funktionsargument-Position), case-insensitives/getrimmtes Namens-Matching,
|
||||
Abhängigkeitsgraph-Kante (Neuberechnung bei Änderung der Zielzelle),
|
||||
Überleben einer Tabellen-Umbenennung, `#NAME?` bei unbekanntem Namen und
|
||||
bei gelöschter Zieltabelle, `addNamedRange`/`updateNamedRange`/
|
||||
`deleteNamedRange`-Validierung (Kollision mit Built-in/eigener
|
||||
Funktion/anderem Namen, Zellform), Undo/Redo, JSON-Round-Trip, Nutzung als
|
||||
`FOR`-Quelle in einer benutzerdefinierten Funktion (als übergebenes
|
||||
Argument), harter Parse-Fehler statt Fallback innerhalb eines
|
||||
Funktionskörpers selbst.
|
||||
- **„Mit Schleife"-Code** — Parser-Ebene (`parseFunctionBlock`): ein
|
||||
`FOR...DO...END` mit mehreren Anweisungen, mehrere unabhängige Schleifen
|
||||
nacheinander, `IF...THEN...END` und `IF...THEN...ELSE...END`, `IF`
|
||||
@@ -784,9 +953,12 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
||||
reaktiv), **kein** echter Web Worker.
|
||||
- **xlsx-Formel-Übernahme** ist heuristisch (Separator-Swap unter Beachtung von
|
||||
Quotes); exotische Excel-Formeln fallen auf den Wert zurück.
|
||||
- Kein Text-Verkettungsoperator, keine benannten Bereiche (nur
|
||||
benutzerdefinierte *Funktionen*, keine benannten *Zellen/Bereiche*), kein
|
||||
Snapping der Canvas-Objekte.
|
||||
- Kein Snapping der Canvas-Objekte.
|
||||
- **Benannte Bereiche** bewusst ohne: Nutzung innerhalb einer
|
||||
benutzerdefinierten Funktion (ein Aufrufer kann aber einen benannten
|
||||
Bereich als Argument übergeben), einen eigenen Ausdruck/berechneten Wert
|
||||
(nur feste Koordinaten), Funktions-Autovervollständigungs-Vorschläge, und
|
||||
`Name:B2` als kombinierten Bereichsstart — s. „Benannte Bereiche" oben.
|
||||
- **Datum & Zeit**: die automatische Erkennung beim Eintippen erfasst nur ISO-
|
||||
und Punkt-Daten, absichtlich **nicht** Schrägstrich-Daten (Tag-zuerst vs.
|
||||
Monat-zuerst ist über die unterstützten Locales nicht eindeutig auflösbar,
|
||||
@@ -813,12 +985,19 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
||||
zählt — wie in den meisten Editoren), Live-Update der Vorschlagsliste bei
|
||||
reiner Cursor-Bewegung ohne Tippen.
|
||||
- **„Mit Schleife"-Code** bewusst ohne: verkettete Vergleiche (`a < b < c`,
|
||||
nur ein Vergleich pro Ausdruck), Text-Vergleich (Vergleichsoperatoren
|
||||
bleiben numerisch), `AND`/`OR`/`NOT` für zusammengesetzte Bedingungen,
|
||||
`BREAK`/`CONTINUE` (nur `RETURN` als Kontrollfluss-Ausstieg), echtes
|
||||
Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes sichtbar).
|
||||
Verschachtelte Schleifen/`IF`s und `RETURN` an beliebiger Stelle sind
|
||||
dagegen unterstützt (s. o.).
|
||||
nur ein Vergleich pro Ausdruck), `AND`/`OR`/`NOT` für zusammengesetzte
|
||||
Bedingungen, `BREAK`/`CONTINUE` (nur `RETURN` als Kontrollfluss-Ausstieg),
|
||||
echtes Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes
|
||||
sichtbar). Verschachtelte Schleifen/`IF`s und `RETURN` an beliebiger
|
||||
Stelle sind dagegen unterstützt (s. o.), ebenso Text-Vergleich in `IF`-
|
||||
Bedingungen (s. „SUMIF/COUNTIF/AVERAGEIF nachbauen" oben).
|
||||
- **Keine Positions-Adressierung zwischen zwei Bereichen** (kein
|
||||
`OFFSET`/`INDEX`-Äquivalent) — eine `FOR`-Schleife kann nur den Bereich
|
||||
erreichen, über den sie selbst iteriert, nicht „die Zelle an derselben
|
||||
Position in einem anderen Bereich". Ein klassisches dreiargumentiges
|
||||
`SUMIF(Kriterienbereich; Kriterium; Summenbereich)` mit **getrennten**
|
||||
Bereichen ist deshalb nicht nachbaubar; nur die selbstbezügliche Form
|
||||
(Kriterium und Aggregation auf demselben Bereich) geht (s. o.).
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTheme } from '../composables/useTheme';
|
||||
import { useToasts } from '../composables/useToasts';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
import CustomFunctionsModal from './CustomFunctionsModal.vue';
|
||||
import NamedRangesModal from './NamedRangesModal.vue';
|
||||
import TheLogo from './TheLogo.vue';
|
||||
import { WICOM, WICOM_SOCIAL } from '../config/brand';
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ const toasts = useToasts();
|
||||
const fileMenuOpen = ref(false);
|
||||
const aboutOpen = ref(false);
|
||||
const functionsOpen = ref(false);
|
||||
const namedRangesOpen = ref(false);
|
||||
const busy = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -249,6 +251,14 @@ function onReset() {
|
||||
>
|
||||
𝑓
|
||||
</button>
|
||||
<button
|
||||
class="btn accent icon-btn"
|
||||
title="Benannte Bereiche"
|
||||
aria-label="Benannte Bereiche"
|
||||
@click="namedRangesOpen = true"
|
||||
>
|
||||
🏷
|
||||
</button>
|
||||
|
||||
<div class="spacer" />
|
||||
|
||||
@@ -343,6 +353,7 @@ function onReset() {
|
||||
|
||||
<!-- Benutzerdefinierte Funktionen -->
|
||||
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
|
||||
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
|
||||
|
||||
<!-- Über / Impressum -->
|
||||
<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',
|
||||
csvDelimiter: ',',
|
||||
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 ;
|
||||
|
||||
expression = comparison ;
|
||||
comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchstens
|
||||
comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ; (* höchstens
|
||||
ein Vergleich, nicht
|
||||
verkettbar; Ergebnis
|
||||
1/0 (siehe Auswertungs-
|
||||
modell unten) *)
|
||||
concat = additive { "&" additive } ; (* links-assoziativ; Text-
|
||||
Verkettung, zwischen
|
||||
Vergleich und "+ -" *)
|
||||
additive = term { ("+" | "-") term } ; (* links-assoziativ; die
|
||||
alte "expression"-Regel,
|
||||
bevor Vergleiche
|
||||
@@ -54,10 +57,16 @@ primary = NUMBER
|
||||
| STRING
|
||||
| "(" expression ")"
|
||||
| funcall
|
||||
| reference ;
|
||||
| reference
|
||||
| namedref ;
|
||||
|
||||
funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
|
||||
|
||||
namedref = IDENT ; (* unqualifizierter, nicht zellförmiger
|
||||
Bezeichner, kein Parameter -- ein
|
||||
benannter-Bereich-Kandidat (s. u.),
|
||||
nur außerhalb eines Funktionskörpers *)
|
||||
|
||||
reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
|
||||
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname; "!" und der
|
||||
Numbers-Stil "::" sind
|
||||
@@ -78,7 +87,7 @@ QUOTED = /' ( [^'] | '' )* '/ (* Tabellenname mit Leer-/Sonderzeichen *
|
||||
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`
|
||||
(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
|
||||
eine Zelle aussieht) zusätzlich geprüft wird — ein Treffer erzeugt statt eines
|
||||
`ParseError` einen `ParamNode`. Nur beim Parsen eines **Funktionskörpers**
|
||||
verwendet (`userFunctions.ts`); ohne das Argument ist das Verhalten exakt wie
|
||||
zuvor, jeder bare Bezeichner ohne Zellform bleibt ein `ParseError`.
|
||||
verwendet (`userFunctions.ts`).
|
||||
|
||||
Ohne das Argument (`paramNames === undefined`, jede gewöhnliche
|
||||
Top-Level-Zellformel) hat sich das Verhalten für einen bare Bezeichner ohne
|
||||
Zellform seit **benannten Bereichen** geändert: er parst jetzt als
|
||||
`NamedRefNode` (Feld `namedRefFallback` am `Parser`, s. u. „Benannte
|
||||
Bereiche") statt als `ParseError`. **Mit** `paramNames` — auch als leeres
|
||||
Array, d. h. innerhalb eines Funktionskörpers — bleibt das alte, strikte
|
||||
Verhalten exakt erhalten: ein bare, nicht-zellförmiger, nicht-Parameter-Name
|
||||
ist weiterhin ein harter `ParseError`. Grund: eine lokale/Schleifen-Variable,
|
||||
die vor ihrer eigenen deklarierenden Anweisung verwendet wird (`x = x + 1`),
|
||||
soll sofort beim Speichern scheitern, statt sich still zu einem
|
||||
gleichnamigen benannten Bereich im Dokument aufzulösen (oder erst zur
|
||||
Laufzeit als `#NAME?` aufzufallen).
|
||||
|
||||
## Blatt-Qualifier ("Blatt!Tabelle::Zelle")
|
||||
|
||||
@@ -169,6 +190,54 @@ unbekannter Funktionsname – eingebaut oder benutzerdefiniert – bleibt exakt
|
||||
derselbe Pfad wie zuvor: der `FuncNode` bleibt unverändert stehen,
|
||||
`evaluate()`s `getFunction()` findet ihn nicht → `#NAME?`.
|
||||
|
||||
## Benannte Bereiche
|
||||
|
||||
Anders als eine benutzerdefinierte Funktion wird ein `NamedRefNode`
|
||||
**nicht** beim Parsen aufgelöst/inlined, sondern **live, bei jeder
|
||||
Auswertung**, gegen `EngineHost.resolveNamedRange(name)` – dieselbe
|
||||
"Name wird nicht in die AST gebacken"-Philosophie wie ein Tabellen-Qualifier
|
||||
(auch der wird nie zur Parse-Zeit zu einer Tabellen-*ID* aufgelöst). Grund:
|
||||
so übersteht ein benannter Bereich eine spätere Umbenennung *seiner*
|
||||
Zieltabelle, ohne dass irgendeine gespeicherte Formel neu geparst werden
|
||||
müsste.
|
||||
|
||||
- `types.ts#NamedRangeDef` (`{id, name, tableId, start, end}`) wird dem
|
||||
`Workbook`-Konstruktor als dritter Parameter mitgegeben (analog zu den
|
||||
`FunctionDef`s); `store/document.ts#toNamedRangeDefs` mappt
|
||||
`DocumentState.namedRanges` dorthin.
|
||||
- `workbook.ts#Workbook.resolveNamedRange(name)` sucht case-insensitiv/
|
||||
getrimmt (dieselbe Konvention wie `resolveTableName`), löst `tableId` über
|
||||
`tableById()` in den **aktuellen** Tabellennamen auf und gibt
|
||||
`{qualifier, sheetQualifier, start, end}` zurück – exakt die Form, die ein
|
||||
`CellNode`/`RangeNode` sonst auch trägt. Zieltabelle nicht mehr vorhanden
|
||||
→ `null` → `#NAME?` (dieselbe Fehlerklasse wie ein unbekannter Name).
|
||||
- `evaluator.ts#resolveNamedRef()` baut aus diesem Ergebnis einen
|
||||
synthetischen `CellNode` (1×1-Bereich) oder `RangeNode` und lässt jeden
|
||||
Aufrufer (`evalScalar`s `case 'namedref'`, `evalArg`,
|
||||
`rangeOrCellToCellNodes` für eine `FOR`-Quelle) in die **bereits
|
||||
vorhandene** Zell-/Bereichs-Logik durchfallen – keine Duplikation.
|
||||
- `parser.ts#collectRefs` sammelt einen `NamedRefNode` unaufgelöst (der
|
||||
Parser kennt das Dokument nicht); `workbook.ts#precedentsOf` löst ihn
|
||||
dann über `resolveNamedRange` in konkrete Zell-Keys für den
|
||||
Abhängigkeitsgraphen auf – unbekannter Name → keine Kante (keine
|
||||
Neuberechnung nötig, `evaluate()` liefert ohnehin `#NAME?`).
|
||||
- **Grammatik-Änderung, die das erst ermöglicht:** ein unqualifizierter,
|
||||
nicht zellförmiger, nicht Parameter-Bezeichner ist jetzt ein `NamedRefNode`
|
||||
statt eines `ParseError` – aber **nur außerhalb eines Funktionskörpers**
|
||||
(`Parser`s `namedRefFallback`-Flag, `true` nur wenn `parseFormula()` ganz
|
||||
ohne `paramNames`-Argument aufgerufen wird). Ein Aufrufer kann trotzdem
|
||||
einen benannten Bereich als Argument an eine Funktion übergeben – der
|
||||
`NamedRefNode` steht dann in `node.args`, wird über
|
||||
`expand()`/`substitute()` (beide bekamen dafür einen `case 'namedref':
|
||||
return node;`-Leaf, wie `cell`/`range`) an die passende Parameterstelle im
|
||||
Funktionskörper substituiert, und trifft dort z. B. als `FOR`-Quelle auf
|
||||
`rangeOrCellToCellNodes`, das ihn ganz normal auflöst.
|
||||
`userFunctions.ts#findInvalidForSource` erlaubt `namedref` deshalb
|
||||
ausdrücklich als gültige `FOR`-Quellenform (neben `param`/`cell`/`range`).
|
||||
- **Kein Zyklus-Check.** Ein benannter Bereich hat keinen eigenen Ausdruck,
|
||||
nur feste Koordinaten – er kann sich nicht selbst referenzieren, anders
|
||||
als eine benutzerdefinierte Funktion.
|
||||
|
||||
## Schleifen ("Mit Schleife"-Funktionen, `kind: 'block'`)
|
||||
|
||||
Eine zweite, eigene Grammatik nur für Funktionskörper mit `kind: 'block'`
|
||||
@@ -302,14 +371,35 @@ dagegen erlaubt), `blockAlwaysReturns` sowie die Zyklus-Prüfung.
|
||||
|
||||
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
|
||||
zahlenförmige Zeichenkette wird gecastet, jede andere ergibt `#VALUE!`.
|
||||
- **Vergleiche sind ebenfalls numerisch** (`toNumber()`-Koerzierung wie bei
|
||||
`+ - * /`), Ergebnis `1` (wahr) oder `0` (falsch); genutzt vor allem in
|
||||
einer `IF`-Bedingung eines „Mit Schleife"-Funktionskörpers (s. o.), aber in
|
||||
jeder Formel gültig.
|
||||
- **Vergleiche** (`= <> > < >= <=`), Ergebnis `1` (wahr) oder `0` (falsch);
|
||||
genutzt vor allem in einer `IF`-Bedingung eines „Mit Schleife"-
|
||||
Funktionskörpers (s. o.), aber in jeder Formel gültig. Zwei Zahlen (oder
|
||||
eine leere Zelle, die als `0` zählt) vergleichen **numerisch**
|
||||
(`toNumber()`-Koerzierung wie bei `+ - * /`); zwei Texte (oder eine leere
|
||||
Zelle, die dann als `''` zählt) vergleichen **text**uell –
|
||||
case-insensitiv und getrimmt, dieselbe Konvention wie `XLOOKUP`s
|
||||
Text-Matching (s. u.), bewusst als eine einzige Regel im Motor statt
|
||||
zweier verschiedener. Eine leere Zelle nimmt dabei immer den Typ der
|
||||
**anderen** Seite an, sodass sowohl `=A1=0` als auch `=A1=""` auf einer
|
||||
echt leeren `A1` funktionieren. Eine echte (nicht-leere) Zahl gegen
|
||||
echten Text ist ein **Typ-Mismatch**, kein Crossmatch: `=`/`<>`
|
||||
antworten eindeutig falsch/wahr, `> < >= <=` haben keine sinnvolle
|
||||
Antwort und ergeben `#VALUE!` (siehe `evaluator.ts`s `compareValues()`).
|
||||
- **`&` verkettet als Text** (`toText()`): eine Zahl rendert `String(n)`
|
||||
ohne Locale-Formatierung (kein `Intl`, dieselbe „locale-neutral"-
|
||||
Konvention wie der CSV-/XLSX-Export), eine leere Zelle trägt `''` bei
|
||||
(nicht `'0'`). Präzedenz zwischen `+ -` und einem Vergleich (s. o. EBNF),
|
||||
links-assoziativ. `CONCAT`/`CONCATENATE(Text1; Text2; …)` sind das
|
||||
Funktions-Äquivalent (`functions.ts`), ohne Operator-Präzedenz.
|
||||
- **Fehler propagieren unverändert** durch Operatoren und Funktionen (der erste
|
||||
gefundene Fehler gewinnt).
|
||||
- Ein **Bereich** darf nur als Funktionsargument stehen. In Skalar-Position
|
||||
ergibt er `#VALUE!`.
|
||||
- Ein **`NamedRefNode`** (benannter Bereich) löst live gegen
|
||||
`host.resolveNamedRange()` auf und verhält sich danach exakt wie die
|
||||
Zelle/der Bereich, die er dort ergibt (inkl. der Skalar-Regel oben für
|
||||
einen mehrzelligen Treffer). Unbekannter Name → `#NAME?`. Details:
|
||||
„Benannte Bereiche" oben.
|
||||
- `SUM/MIN/MAX` über „keine Zahlen“ → `0`; `AVG` über „keine Zahlen“ → `#DIV/0!`.
|
||||
- `COUNT` zählt nur echte Zahlen – leere Zellen und Text werden nicht gezählt.
|
||||
- `^` = `Math.pow`, rechts-assoziativ: `=2^3^2` → `2^(3^2)` = `512`.
|
||||
|
||||
@@ -3,10 +3,15 @@ import { parseFormula } from '../parser';
|
||||
import { evaluate } from '../evaluator';
|
||||
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(
|
||||
data: Record<string, Record<string, CellValue>>,
|
||||
home = 'S1',
|
||||
namedRanges: Record<string, NamedRangeShape> = {},
|
||||
): EngineHost {
|
||||
const sizes: Record<string, { cols: number; rows: number }> = {
|
||||
S1: { cols: 10, rows: 10 },
|
||||
@@ -21,6 +26,7 @@ function makeHost(
|
||||
return {
|
||||
homeTableId: home,
|
||||
resolveTableName: idFor,
|
||||
resolveNamedRange: (name) => namedRanges[name.trim().toLowerCase()] ?? null,
|
||||
tableSize: (id) => sizes[id] ?? null,
|
||||
getComputed(tableId, col, row) {
|
||||
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', () => {
|
||||
const textHost = makeHost({
|
||||
S1: {
|
||||
|
||||
@@ -18,12 +18,13 @@ describe('lexer', () => {
|
||||
});
|
||||
|
||||
it('tokenizes operators and punctuation', () => {
|
||||
expect(types('+-*/^():;!')).toEqual([
|
||||
expect(types('+-*/^&():;!')).toEqual([
|
||||
'PLUS',
|
||||
'MINUS',
|
||||
'STAR',
|
||||
'SLASH',
|
||||
'CARET',
|
||||
'AMP',
|
||||
'LPAREN',
|
||||
'RPAREN',
|
||||
'COLON',
|
||||
@@ -66,7 +67,7 @@ describe('lexer', () => {
|
||||
});
|
||||
|
||||
it('throws on an unexpected character', () => {
|
||||
expect(() => tokenize('A1 & B2')).toThrow();
|
||||
expect(() => tokenize('A1 @ B2')).toThrow();
|
||||
});
|
||||
|
||||
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})`;
|
||||
case 'param':
|
||||
return `param(${n.name})`;
|
||||
case 'namedref':
|
||||
return `namedref(${n.name})`;
|
||||
case 'blockcall':
|
||||
// never appears in freshly-parsed input (see parser.ts's comment on
|
||||
// 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))',
|
||||
);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@@ -176,9 +187,13 @@ describe('parseFormula(src, paramNames) -- user-defined function bodies', () =>
|
||||
expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('without paramNames, ordinary cell-formula behaviour is unchanged', () => {
|
||||
// same string, no param list -- "revenue" doesn't look like a cell -> still throws
|
||||
expect(() => parseFormula('=revenue')).toThrow(ParseError);
|
||||
it('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, but
|
||||
// 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', () => {
|
||||
@@ -198,8 +213,32 @@ describe('parser errors', () => {
|
||||
it('throws on an unbalanced paren', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', B9: '=DOUBLESUM(A1:A2)' });
|
||||
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', () => {
|
||||
it('evaluates precedents before dependents (chain across sheets)', () => {
|
||||
const wb = fixture();
|
||||
|
||||
+136
-21
@@ -11,10 +11,22 @@
|
||||
* ----------------
|
||||
* - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking
|
||||
* 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).
|
||||
* - A range may only appear as a function argument. Used in scalar position it
|
||||
* 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?`.
|
||||
* - Division by zero -> `#DIV/0!`.
|
||||
*
|
||||
@@ -27,7 +39,7 @@
|
||||
* 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 { expandRange } from './refs';
|
||||
import { substitute } from './userFunctions';
|
||||
@@ -58,6 +70,20 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
|
||||
if (isError(r)) return 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));
|
||||
if (isError(l)) return l;
|
||||
const r = toNumber(evalScalar(node.right, host));
|
||||
@@ -71,18 +97,6 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
|
||||
return l * r;
|
||||
case '/':
|
||||
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}`);
|
||||
}
|
||||
@@ -100,6 +114,12 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
|
||||
// A bare range can't collapse to a scalar.
|
||||
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': {
|
||||
// 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
|
||||
@@ -390,7 +410,7 @@ function execStatement(
|
||||
}
|
||||
case 'for': {
|
||||
const source = substitute(stmt.source, env);
|
||||
const cells = rangeOrCellToCellNodes(source, stmt.varName);
|
||||
const cells = rangeOrCellToCellNodes(source, stmt.varName, host);
|
||||
for (const cell of cells) {
|
||||
budget.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
|
||||
* environment, must be a single cell or a range -- never a computed value
|
||||
* (mirrors "a range may only appear as a function argument" for ordinary
|
||||
* formulas). Expands a range to its individual cells via `expandRange`
|
||||
* (the same helper `evalArg` uses for a range function argument), carrying
|
||||
* the same table/sheet qualifier through to each. */
|
||||
function rangeOrCellToCellNodes(node: Node, loopVarName: string): CellNode[] {
|
||||
* environment, must be a single cell, a range, or a named range -- never a
|
||||
* computed value (mirrors "a range may only appear as a function argument"
|
||||
* for ordinary formulas). Expands a range to its individual cells via
|
||||
* `expandRange` (the same helper `evalArg` uses for a range function
|
||||
* argument), carrying the same table/sheet qualifier through to each. A
|
||||
* 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 === 'range') {
|
||||
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). */
|
||||
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') {
|
||||
const tableId =
|
||||
node.qualifier == null
|
||||
@@ -440,6 +474,22 @@ function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
|
||||
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. */
|
||||
function toNumber(v: CellValue): number | FormulaError {
|
||||
if (isError(v)) return v;
|
||||
@@ -450,3 +500,68 @@ function toNumber(v: CellValue): number | FormulaError {
|
||||
const n = Number(trimmed);
|
||||
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;
|
||||
},
|
||||
|
||||
// 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*
|
||||
// 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
|
||||
@@ -142,6 +149,8 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
|
||||
|
||||
// AVERAGE is an alias of 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
|
||||
* 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: 'MAX', hint: 'MAX(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: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' },
|
||||
{ name: 'TIME', hint: 'TIME(Std; Min; Sek)' },
|
||||
|
||||
@@ -14,6 +14,7 @@ export {
|
||||
type Node,
|
||||
type CellNode,
|
||||
type RangeNode,
|
||||
type NamedRefNode,
|
||||
type BlockNode,
|
||||
} from './parser';
|
||||
export { evaluate } from './evaluator';
|
||||
@@ -63,4 +64,5 @@ export {
|
||||
type CellValue,
|
||||
type Scalar,
|
||||
type EngineHost,
|
||||
type NamedRangeDef,
|
||||
} from './types';
|
||||
|
||||
@@ -18,6 +18,7 @@ export type TokenType =
|
||||
| 'STAR'
|
||||
| 'SLASH'
|
||||
| 'CARET'
|
||||
| 'AMP' // "&" -- text concatenation
|
||||
| 'LPAREN'
|
||||
| 'RPAREN'
|
||||
| 'SEMICOLON'
|
||||
@@ -48,6 +49,7 @@ const SINGLE: Record<string, TokenType> = {
|
||||
'*': 'STAR',
|
||||
'/': 'SLASH',
|
||||
'^': 'CARET',
|
||||
'&': 'AMP',
|
||||
'(': 'LPAREN',
|
||||
')': 'RPAREN',
|
||||
';': 'SEMICOLON',
|
||||
|
||||
+95
-19
@@ -7,7 +7,7 @@
|
||||
* formula = "=" expression ;
|
||||
*
|
||||
* expression = comparison ;
|
||||
* comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ;
|
||||
* comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ;
|
||||
* (* at most one comparison,
|
||||
* not chainable like
|
||||
* "a < b < c"; mainly
|
||||
@@ -15,6 +15,12 @@
|
||||
* function body's IF
|
||||
* condition, but valid
|
||||
* 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 *)
|
||||
* term = unary { ("*" | "/") unary } ; (* left assoc *)
|
||||
* unary = ("+" | "-") unary
|
||||
@@ -58,15 +64,21 @@
|
||||
* see reference() below). *)
|
||||
* 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
|
||||
* when parseFormula() is called with a `paramNames` list (used to parse a
|
||||
* user-defined function's body; see src/engine/userFunctions.ts). Without
|
||||
* that list -- i.e. for every ordinary cell formula -- a bare identifier
|
||||
* that doesn't look like a cell address is still a ParseError, exactly as
|
||||
* before.
|
||||
* that list -- i.e. for every ordinary cell formula -- a bare, unqualified
|
||||
* IDENT that doesn't look like a cell address is instead a *named-range*
|
||||
* 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
|
||||
| CellNode
|
||||
| RangeNode
|
||||
| NamedRefNode
|
||||
| ParamNode
|
||||
| BlockCallNode
|
||||
| OmittedNode;
|
||||
@@ -95,7 +108,7 @@ export interface StringNode {
|
||||
}
|
||||
export interface BinaryNode {
|
||||
kind: 'binary';
|
||||
op: '+' | '-' | '*' | '/' | '^' | '=' | '<>' | '>' | '<' | '>=' | '<=';
|
||||
op: '+' | '-' | '*' | '/' | '^' | '&' | '=' | '<>' | '>' | '<' | '>=' | '<=';
|
||||
left: Node;
|
||||
right: Node;
|
||||
}
|
||||
@@ -131,6 +144,17 @@ export interface RangeNode {
|
||||
start: { 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
|
||||
* when parseFormula() is given a `paramNames` list (parsing a function
|
||||
* 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
|
||||
* this, so its behaviour is completely unaffected. */
|
||||
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.namedRefFallback = namedRefFallback;
|
||||
}
|
||||
|
||||
private peek(): Token {
|
||||
@@ -364,11 +399,11 @@ class Parser {
|
||||
* parens, assignment RHS, ...) -- wraps `additive` with an optional
|
||||
* single comparison, the loosest-binding operator. */
|
||||
private expression(): Node {
|
||||
const left = this.additive();
|
||||
const left = this.concat();
|
||||
const opType = this.comparisonOpType();
|
||||
if (!opType) return left;
|
||||
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 {
|
||||
@@ -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
|
||||
* `expression` itself gained the comparison wrapper above it. */
|
||||
private additive(): Node {
|
||||
@@ -536,6 +584,19 @@ class Parser {
|
||||
if (qualifier === null && this.paramNames.has(a.value.toUpperCase())) {
|
||||
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)) {
|
||||
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 --
|
||||
* bare identifiers matching one of them parse as a {@link ParamNode} instead
|
||||
* of requiring cell-address shape. Omit it (the default) for every ordinary
|
||||
* cell formula; behaviour is then identical to before this existed.
|
||||
* 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 {
|
||||
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);
|
||||
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);
|
||||
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.
|
||||
* For a `blockcall`, walks both its (already-resolved) `args` and every
|
||||
* expression reachable inside its `block` -- conservatively: a reference
|
||||
* inside a branch/loop that might not execute at runtime still counts as a
|
||||
* dependency, same as an ordinary `IF`-style formula in any spreadsheet. */
|
||||
export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []): Array<CellNode | RangeNode> {
|
||||
/** Walk an AST and collect every cell/range/named-range reference node it
|
||||
* contains. For a `blockcall`, walks both its (already-resolved) `args` and
|
||||
* every expression reachable inside its `block` -- conservatively: a
|
||||
* reference inside a branch/loop that might not execute at runtime still
|
||||
* counts as a dependency, same as an ordinary `IF`-style formula in any
|
||||
* 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) {
|
||||
case 'cell':
|
||||
case 'range':
|
||||
case 'namedref':
|
||||
out.push(node);
|
||||
break;
|
||||
case 'binary':
|
||||
@@ -633,7 +709,7 @@ export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []):
|
||||
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) {
|
||||
switch (stmt.kind) {
|
||||
case 'assign':
|
||||
|
||||
@@ -58,6 +58,22 @@ export interface CellRef extends Coord {
|
||||
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. */
|
||||
export function keyOf(tableId: string, col: number, row: number): string {
|
||||
return `${tableId}␟${col}␟${row}`;
|
||||
@@ -99,6 +115,22 @@ export interface EngineHost {
|
||||
* (`#REF` -- the formula needs an explicit sheet qualifier).
|
||||
*/
|
||||
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. */
|
||||
tableSize(tableId: string): { cols: number; rows: number } | null;
|
||||
/**
|
||||
|
||||
@@ -93,6 +93,7 @@ function expand(
|
||||
case 'string':
|
||||
case 'cell':
|
||||
case 'range':
|
||||
case 'namedref':
|
||||
case 'param':
|
||||
case 'omitted':
|
||||
return node; // leaves -- nothing to expand
|
||||
@@ -180,6 +181,7 @@ export function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
|
||||
case 'string':
|
||||
case 'cell':
|
||||
case 'range':
|
||||
case 'namedref':
|
||||
case 'omitted':
|
||||
return node;
|
||||
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
|
||||
* 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
|
||||
* cells no matter what the caller passes. Checked at save time so this is
|
||||
* caught immediately, not just on the first real call; the evaluator's
|
||||
* single cell, a range, or a named-range reference -- i.e. can never
|
||||
* resolve to a concrete list of cells no matter what the caller passes.
|
||||
* 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
|
||||
* time regardless (needed either way, since a parameter's shape isn't
|
||||
* known until then) as the actual enforcement. */
|
||||
export function findInvalidForSource(block: BlockNode): ForNode | null {
|
||||
for (const stmt of block.statements) {
|
||||
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;
|
||||
}
|
||||
const nested = findInvalidForSource(stmt.body);
|
||||
|
||||
+31
-5
@@ -31,6 +31,7 @@ import {
|
||||
parseKey,
|
||||
type CellValue,
|
||||
type EngineHost,
|
||||
type NamedRangeDef,
|
||||
} from './types';
|
||||
import { evaluate } from './evaluator';
|
||||
|
||||
@@ -81,10 +82,17 @@ export class Workbook implements EngineHost {
|
||||
private readonly values = new Map<string, CellValue>();
|
||||
/** user-defined function library for this document, name.toUpperCase() -> def. */
|
||||
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.functionIndex = buildFunctionIndex(functionDefs);
|
||||
this.namedRanges = namedRanges;
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
@@ -112,6 +120,17 @@ export class Workbook implements EngineHost {
|
||||
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 {
|
||||
const t = this.tableById(tableId);
|
||||
return t ? { cols: t.cols, rows: t.rows } : null;
|
||||
@@ -201,13 +220,20 @@ export class Workbook implements EngineHost {
|
||||
this.homeTableId = tableId;
|
||||
const out: string[] = [];
|
||||
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 =
|
||||
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 (ref.kind === 'cell') {
|
||||
out.push(keyOf(target, ref.col, ref.row));
|
||||
if ('col' in resolved) {
|
||||
out.push(keyOf(target, resolved.col, resolved.row));
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
CellCoord,
|
||||
CellFormat,
|
||||
ChartObject,
|
||||
CustomFunction,
|
||||
DocumentState,
|
||||
HAlign,
|
||||
NamedRange,
|
||||
Sheet,
|
||||
SheetObject,
|
||||
TableObject,
|
||||
@@ -58,6 +60,46 @@ function normaliseCustomFunctions(x: unknown): CustomFunction[] {
|
||||
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;
|
||||
function colorOrUndef(x: unknown): string | 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 objectIds = new Set<string>();
|
||||
const tableIds = new Set<string>();
|
||||
for (const s of doc.sheets) {
|
||||
if (!s || typeof s.id !== 'string' || typeof s.name !== 'string' || !Array.isArray(s.objects))
|
||||
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.');
|
||||
if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.');
|
||||
objectIds.add(o.id);
|
||||
if (isTableObject(o)) tableIds.add(o.id);
|
||||
}
|
||||
}
|
||||
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,
|
||||
csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',',
|
||||
customFunctions: normaliseCustomFunctions(doc.customFunctions),
|
||||
namedRanges: normaliseNamedRanges(doc.namedRanges, tableIds),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -393,6 +438,7 @@ export function migrateToV2(x: unknown): DocumentState {
|
||||
locale: DEFAULT_LOCALE,
|
||||
csvDelimiter: ',',
|
||||
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)', () => {
|
||||
it('filters built-ins by prefix, case-insensitively', () => {
|
||||
const s = fresh();
|
||||
|
||||
+101
-2
@@ -39,6 +39,7 @@ import {
|
||||
type TableModel,
|
||||
type Token,
|
||||
type FunctionDef,
|
||||
type NamedRangeDef,
|
||||
} from '../engine';
|
||||
import {
|
||||
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. */
|
||||
type Rect = { c0: number; c1: number; r0: number; r1: number };
|
||||
|
||||
@@ -210,7 +221,7 @@ function loadInitial(): DocumentState {
|
||||
|
||||
function createStore() {
|
||||
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 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));
|
||||
|
||||
function syncWorkbook() {
|
||||
wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
|
||||
wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc));
|
||||
bump();
|
||||
}
|
||||
|
||||
@@ -280,6 +291,7 @@ function createStore() {
|
||||
locale: d.locale,
|
||||
csvDelimiter: d.csvDelimiter,
|
||||
customFunctions: d.customFunctions,
|
||||
namedRanges: d.namedRanges,
|
||||
});
|
||||
let historySnapshot = snapshot();
|
||||
let historyFingerprint = contentFingerprint(historySnapshot);
|
||||
@@ -865,6 +877,89 @@ function createStore() {
|
||||
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) {
|
||||
const o = objectById(objId);
|
||||
if (!o) return;
|
||||
@@ -1616,6 +1711,7 @@ function createStore() {
|
||||
doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE;
|
||||
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
|
||||
doc.customFunctions = next.customFunctions ?? [];
|
||||
doc.namedRanges = next.namedRanges ?? [];
|
||||
syncWorkbook();
|
||||
}
|
||||
/** Loading a different document outright (JSON import "replace", "reset to
|
||||
@@ -1710,6 +1806,9 @@ function createStore() {
|
||||
addCustomFunction,
|
||||
updateCustomFunction,
|
||||
deleteCustomFunction,
|
||||
addNamedRange,
|
||||
updateNamedRange,
|
||||
deleteNamedRange,
|
||||
resizeChart,
|
||||
bringToFront,
|
||||
setChartView,
|
||||
|
||||
@@ -173,6 +173,25 @@ export interface CustomFunction {
|
||||
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
|
||||
* `functionSuggestions()`). */
|
||||
export interface FnSuggestion {
|
||||
@@ -191,4 +210,5 @@ export interface DocumentState {
|
||||
locale: NumberLocale;
|
||||
csvDelimiter: ',' | ';';
|
||||
customFunctions: CustomFunction[];
|
||||
namedRanges: NamedRange[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user