diff --git a/README.md b/README.md index 880f2a7..492dffb 100644 --- a/README.md +++ b/README.md @@ -206,9 +206,11 @@ eingebauter Funktionsname: `=MARGE(Umsatz!B2; Umsatz!C2)`. Name/Parameter, ein nicht parsender Formelkörper, und ein **Funktions- Zyklus** (A ruft B, B ruft A, auch über mehrere Stufen) — wird abgelehnt, bevor je eine `#ERR`-Zelle entsteht. -- **Umbenennen/Löschen** aktualisiert **keine** bestehenden Formeltexte — - genau wie beim Umbenennen/Löschen einer Tabelle. Zellen, die die - betroffene Funktion aufrufen, zeigen danach `#NAME?`. +- **Umbenennen/Löschen** aktualisiert **keine** bestehenden Formeltexte. + Zellen, die die betroffene Funktion aufrufen, zeigen danach `#NAME?`. + Anders als bei einer **Tabelle** (s. u. „Referenzen bleiben nach dem + Umbenennen konsistent") — eine Funktion hat keine Referenz-Syntax, die + sich automatisch umschreiben ließe, nur ihren Aufruf-Namen. - Ein Aufruf wird **beim Parsen der aufrufenden Zelle in ihre AST eingesetzt** (Inlining, keine Laufzeit-Closures) — siehe [`src/engine/userFunctions.ts`](src/engine/userFunctions.ts) und die @@ -765,6 +767,27 @@ Die Formel-**Point-Mode**-Einfügung (s. o.) erkennt eine mehrdeutige Zieltabelle selbst und fügt automatisch die volle 3-teilige Form ein, statt einer Formel, die versehentlich die falsche Tabelle träfe. +**Referenzen bleiben nach dem Umbenennen konsistent.** Wird eine Tabelle +umbenannt (Doppelklick auf ihren Namen in der Kopfleiste), schreibt der +Store **dokumentweit jede** `Tabelle!Zelle`-/`Tabelle::Zelle`-Referenz auf +sie um — egal in welcher anderen Tabelle sie steht, egal ob 2- oder +3-teilige Form (bei der 3-teiligen Form wird nur der Tabellen-Teil +ersetzt, der Blatt-Teil bleibt unangetastet), egal ob Einzelzelle oder +Bereich. Nur die betroffene Qualifier-Textspanne wird ersetzt (nötigenfalls +mit `'...'` gequotet, wenn der neue Name Leerzeichen/Sonderzeichen +enthält) — der Rest der Formel, inklusive Whitespace, bleibt byte-identisch, +dieselbe Technik wie beim dokumentweiten Umschreiben durch Zeilen-/ +Spalten-Einfügen (s. u.). Umbenennen + Umschreiben zählen zusammen als +**ein** Undo-Schritt. Damit bleibt eine Formel nach dem Umbenennen exakt +so gültig wie vorher — ohne dieses Umschreiben würde jede Formel, die noch +den alten Namen nennt, beim nächsten Neuberechnen `#REF` zeigen (der +Tabellenname existiert für sie dann schlicht nicht mehr). Bewusst +**begrenzt auf Tabellenzellen** — Makro-Aktionen adressieren eine Tabelle +über einen zur Laufzeit aufgelösten Text-String (s. „Makros" oben), nicht +über eine Formel-Qualifier-Syntax, und ein benutzerdefinierter +Funktionskörper wird ebenfalls nicht durchsucht (dieselbe, bereits +bestehende Begrenzung wie beim dokumentweiten Zeilen-/Spalten-Umschreiben). + **`$`-Bezüge sperren** (relativ/gemischt/absolut): `A1` (relativ) vs. `$A1` / `A$1` (Spalte bzw. Zeile gesperrt) vs. `$A$1` (beide gesperrt). Für die Auswertung selbst ist `$` irrelevant — sie ist rein positionsbasiert. Es zählt @@ -1133,6 +1156,16 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, Bereich, `$`-Sperren bleiben wirkungslos (anders als beim Kopieren), unveränderte Referenzen bleiben byte-identisch, Undo/Redo, Schutz gegen Löschen der letzten verbleibenden Zeile/Spalte. +- **Tabellen-Umbenennung schreibt Referenzen um** — ein `Tabelle!Zelle`- + und ein `Tabelle::Zelle`-Bezug aus einer *anderen* Tabelle werden beide + umgeschrieben und liefern danach weiterhin denselben Wert (kein `#REF`), + ein Bereichsbezug behält seinen Bereich (nur der Qualifier ändert sich), + bei der 3-teiligen `Blatt!Tabelle::Zelle`-Form wird ausschließlich der + Tabellen-Teil ersetzt, ein neuer Name mit Leerzeichen wird korrekt + gequotet, eine Formel, die eine *andere*, nicht umbenannte Tabelle + referenziert, bleibt byte-identisch, ein redundanter Selbst-Qualifier + innerhalb der umbenannten Tabelle selbst wird ebenfalls erfasst, + Umbenennen + Umschreiben zählen als ein Undo-Schritt. - **Abhängigkeiten erkunden** (`graphPrecedents`/`graphDependents` in `workbook.spec.ts`, `cellGraphSelf`/`cellGraphNeighbors`/ `openDependencyGraph`/`closeDependencyGraph` in `document.spec.ts`) — @@ -1314,10 +1347,11 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, - **Benutzerdefinierte Funktionen** (Phase 1, siehe oben) bewusst ohne: Live-Vorschau des Ergebnisses im Bearbeiten-Dialog, variadische Parameter/ Default-Werte, und automatisches Nachführen bestehender Aufrufe bei - Umbenennung (Verhalten entspricht bewusst dem einer Tabellen-Umbenennung, - s. o.) — der Funktionsname selbst wird beim Formeltippen aber inzwischen - vervollständigt (s. o. „Funktions-Autovervollständigung"), auch für eigene - Funktionen. + Umbenennung — anders als bei einer Tabelle inzwischen (s. o.) bewusst + nicht nachgezogen, da eine Funktion nur einen Aufruf-Namen hat, keine + umschreibbare Referenz-Syntax. Der Funktionsname selbst wird beim + Formeltippen aber inzwischen vervollständigt (s. o. „Funktions- + Autovervollständigung"), auch für eigene Funktionen. - **Funktions-Autovervollständigung** bewusst ohne: Zell-/Tabellennamen- Vorschläge (dafür existiert der Point-Mode, s. o.), Vervollständigung, die auch Text *nach* dem Cursor berücksichtigt (nur der Text bis zum Cursor diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index c9d2515..208e047 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -1244,6 +1244,129 @@ describe('insertRow/deleteRow/insertColumn/deleteColumn (mid-grid, not just edge }); }); +describe('renaming a table rewrites every other formula\'s qualifier (document-wide, not just names re-resolving)', () => { + const rawInput = (s: DocumentStore, table: string, a1: string) => { + const { col, row } = coords(a1); + return s.cellInput(tableId(s, table), col, row); + }; + + it('a plain "Alt!B2" reference in ANOTHER table becomes "Neu!B2" and keeps evaluating correctly', () => { + const s = fresh(); + const src = s.addTable(s.doc.activeSheetId); + const srcName = s.tableById(src)!.name; + const dst = s.addTable(s.doc.activeSheetId); + const dstName = s.tableById(dst)!.name; + setCell(s, srcName, 'B2', '10'); + setCell(s, dstName, 'A1', `=${srcName}!B2`); + expect(val(s, dstName, 'A1')).toBe(10); + + s.renameObject(src, 'Neu'); + expect(rawInput(s, dstName, 'A1')).toBe('=Neu!B2'); + expect(val(s, dstName, 'A1')).toBe(10); // still resolves -- no #REF + }); + + it('the "::" qualifier form is rewritten too', () => { + const s = fresh(); + const src = s.addTable(s.doc.activeSheetId); + const srcName = s.tableById(src)!.name; + const dst = s.addTable(s.doc.activeSheetId); + const dstName = s.tableById(dst)!.name; + setCell(s, dstName, 'A1', `=${srcName}::B2+1`); + + s.renameObject(src, 'Neu'); + expect(rawInput(s, dstName, 'A1')).toBe('=Neu::B2+1'); + }); + + it('a range reference keeps its range, only the qualifier changes', () => { + const s = fresh(); + const src = s.addTable(s.doc.activeSheetId); + const srcName = s.tableById(src)!.name; + const dst = s.addTable(s.doc.activeSheetId); + const dstName = s.tableById(dst)!.name; + setCell(s, dstName, 'A1', `=SUM(${srcName}!B2:B6)`); + + s.renameObject(src, 'Neu'); + expect(rawInput(s, dstName, 'A1')).toBe('=SUM(Neu!B2:B6)'); + }); + + it('only the TABLE part of a 3-part "Blatt!Tabelle::Zelle" reference is rewritten, the sheet stays', () => { + const s = fresh(); + const sheet2 = s.addSheet('Zweites'); + const src = s.addTable(sheet2); + const srcName = s.tableById(src)!.name; + s.setActiveSheet(s.doc.sheets[0].id); + const dst = s.addTable(s.doc.sheets[0].id); + const dstName = s.tableById(dst)!.name; + const sheet2Name = s.sheetById(sheet2)!.name; + setCell(s, dstName, 'A1', `=${sheet2Name}!${srcName}::B2`); + + s.renameObject(src, 'Neu'); + expect(rawInput(s, dstName, 'A1')).toBe(`=${sheet2Name}!Neu::B2`); + }); + + it('renaming to a name that needs quoting produces a properly quoted qualifier', () => { + const s = fresh(); + const src = s.addTable(s.doc.activeSheetId); + const srcName = s.tableById(src)!.name; + const dst = s.addTable(s.doc.activeSheetId); + const dstName = s.tableById(dst)!.name; + setCell(s, dstName, 'A1', `=${srcName}!B2`); + + s.renameObject(src, 'Neuer Name'); + expect(rawInput(s, dstName, 'A1')).toBe("='Neuer Name'!B2"); + }); + + it('a formula referencing a DIFFERENT, un-renamed table stays byte-identical', () => { + const s = fresh(); + const src = s.addTable(s.doc.activeSheetId); + const other = s.addTable(s.doc.activeSheetId); + const otherName = s.tableById(other)!.name; + const dst = s.addTable(s.doc.activeSheetId); + const dstName = s.tableById(dst)!.name; + const untouched = `=${otherName}!B2 + 1`; + setCell(s, dstName, 'A1', untouched); + + s.renameObject(src, 'Neu'); + expect(rawInput(s, dstName, 'A1')).toBe(untouched); + }); + + it('a redundant self-qualified reference inside the renamed table itself is rewritten too', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'B2', '10'); + setCell(s, name, 'A1', `=${name}!B2`); // redundant explicit self-qualifier + + s.renameObject(id, 'Neu'); + expect(rawInput(s, 'Neu', 'A1')).toBe('=Neu!B2'); + expect(val(s, 'Neu', 'A1')).toBe(10); + }); + + it('the rewrite and the rename coalesce into a single undo step', () => { + vi.useFakeTimers(); + try { + const s = fresh(); + const src = s.addTable(s.doc.activeSheetId); + const srcName = s.tableById(src)!.name; + const dst = s.addTable(s.doc.activeSheetId); + const dstName = s.tableById(dst)!.name; + setCell(s, dstName, 'A1', `=${srcName}!B2`); + vi.advanceTimersByTime(600); // let the setup settle first + + s.renameObject(src, 'Neu'); + vi.advanceTimersByTime(600); + expect(rawInput(s, dstName, 'A1')).toBe('=Neu!B2'); + + s.undo(); // one Ctrl+Z undoes BOTH the rename and the ref rewrite together + expect(s.tableById(src)!.name).toBe(srcName); + expect(rawInput(s, dstName, 'A1')).toBe(`=${srcName}!B2`); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } + }); +}); + describe('dependency-graph view (cellGraphSelf/cellGraphNeighbors)', () => { it('cellGraphSelf describes a formula cell: label, raw text, value, table/sheet name', () => { const s = fresh(); diff --git a/src/store/document.ts b/src/store/document.ts index 2714473..036087c 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -413,6 +413,113 @@ function rewriteRefsForStructuralChange( return '=' + out; } +/** `'` needs quoting whenever a name isn't a bare identifier (spaces, + * punctuation, ...) -- shared by the table- and sheet-qualifier cases. + * Module-level (not a store method) since it's pure and both + * `rewriteRefsForRename` below and the store's own `qualifyRef` need it. */ +function quoteQualifier(name: string): string { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`; +} + +/** + * Rewrite every reference to `targetTableId` inside one formula so its + * qualifier text uses `newName` instead of whatever name it was written + * with -- called when a table is renamed (see `renameObject`), so a + * formula elsewhere that still says `AlterName!B2` keeps pointing at the + * SAME table instead of becoming `#REF` the next time the workbook + * rebuilds ("Referenzen in Formeln müssen immer konsistent sein"). + * + * Same token-splice technique and qualifier-detection shape as + * `rewriteRefsForStructuralChange` above (only the qualifier token's own + * text is touched, everything else -- including whitespace and the cell/ + * range part -- stays byte-identical), but simpler: no coordinate + * shifting, and only the TABLE-name token is ever replaced -- a 3-part + * `Blatt!Tabelle::Zelle` form keeps its own sheet qualifier untouched, + * only `Tabelle` becomes `newName`. `homeTableId` is the formula's own + * table, needed the same way as in the structural-change rewriter, to + * resolve a same-name-on-two-sheets ambiguity via the real + * `wb.resolveTableName` rules rather than a naive string match. + */ +function rewriteRefsForRename( + raw: string, + homeTableId: string, + targetTableId: string, + newName: string, + wb: Workbook, +): string { + if (!raw.startsWith('=')) return raw; + const body = raw.slice(1); + let toks: Token[]; + try { + toks = tokenize(body); + } catch { + return raw; // unparsable -- leave as-is, the evaluator already surfaces #ERR + } + + const savedHome = wb.homeTableId; + wb.homeTableId = homeTableId; + const resolve = (name: string, sheetName: string | null): string | null => + wb.resolveTableName(name, sheetName); + + interface Repl { + pos: number; + end: number; + text: string; + } + const repls: Repl[] = []; + + let i = 0; + while (i < toks.length && toks[i].type !== 'EOF') { + const tok = toks[i]; + let cellIdx = -1; + let resolvedTableId: string | null = null; + let qualTok: Token | null = null; + + if ((tok.type === 'IDENT' || tok.type === 'QUOTED') && toks[i + 1]) { + if (toks[i + 1].type === 'BANG' && looksLikeSheetThenTable(toks, i + 1)) { + qualTok = toks[i + 2]; // the TABLE token of the 3-part form -- toks[i]/tok is the sheet, left alone + resolvedTableId = resolve(qualTok.value, tok.value); + cellIdx = i + 4; // consumed: IDENT/QUOTED "!" IDENT/QUOTED "::" + } else if (toks[i + 1].type === 'BANG' || toks[i + 1].type === 'DBLCOLON') { + qualTok = tok; + resolvedTableId = resolve(tok.value, null); + cellIdx = i + 2; + } + } + if (cellIdx === -1) { + i++; // a bare, unqualified cell never needs rewriting on rename -- it's already "this table" + continue; + } + + const cellTokA = toks[cellIdx]; + if (!cellTokA || cellTokA.type !== 'IDENT' || !looksLikeCell(cellTokA.value)) { + i++; // qualifier not actually followed by a cell -- malformed, leave alone + continue; + } + const isRangeStart = toks[cellIdx + 1]?.type === 'COLON'; + const cellTokB = isRangeStart ? toks[cellIdx + 2] : undefined; + const validRange = !!cellTokB && cellTokB.type === 'IDENT' && looksLikeCell(cellTokB.value); + + if (resolvedTableId === targetTableId && qualTok) { + repls.push({ pos: qualTok.pos, end: qualTok.pos + qualTok.value.length, text: quoteQualifier(newName) }); + } + + i = validRange ? cellIdx + 3 : cellIdx + 1; + } + + wb.homeTableId = savedHome; + if (repls.length === 0) return raw; + repls.sort((x, y) => x.pos - y.pos); + let out = ''; + let cursor = 0; + for (const r of repls) { + out += body.slice(cursor, r.pos) + r.text; + cursor = r.end; + } + out += body.slice(cursor); + return '=' + out; +} + function loadInitial(): DocumentState { try { const raw = localStorage.getItem(STORAGE_KEY); @@ -927,7 +1034,15 @@ function createStore() { .filter((t) => t.id !== objId) .map((t) => t.name.toLowerCase()), ); - o.name = others.has(clean.toLowerCase()) ? uniqueName(clean, others) : clean; + const newName = others.has(clean.toLowerCase()) ? uniqueName(clean, others) : clean; + // Rewrite every OTHER formula's qualifier BEFORE renaming -- wb still + // resolves the old name at this point, and rewriteRefsForRename needs + // that to identify which references actually point at this table. + // "Referenzen in Formeln müssen immer konsistent sein" (user request): + // without this, a formula elsewhere still saying the old name would + // silently become #REF the moment the workbook rebuilds below. + if (newName !== o.name) renameDocumentRefs(objId, newName); + o.name = newName; syncWorkbook(); // formulas referencing the old/new name must re-resolve } else { o.name = clean; @@ -1860,6 +1975,30 @@ function createStore() { } } + /** Same driver shape as `rewriteDocumentRefs` above, for a table rename + * (see `rewriteRefsForRename`'s own doc comment) -- every OTHER table's + * cells are walked too, not just the renamed table's own, since a + * qualified reference to it can live anywhere in the document. Scoped + * to table cells only, same as the structural-change rewriter (macro + * bodies address a table by a plain string, not a formula qualifier, and + * are resolved fresh at each run anyway -- see `resolveMacroTableRef` -- + * so they need no rewriting; a custom function's own body is likewise + * left alone, matching the existing, established scope of the sibling + * row/col-insert/delete rewrite this mirrors). Called BEFORE the + * object's `.name` is actually changed, so `wb` (not yet rebuilt) still + * resolves the OLD name to `targetTableId`. */ + function renameDocumentRefs(targetTableId: string, newName: string) { + for (const s of doc.sheets) { + for (const o of s.objects) { + if (o.kind !== 'table') continue; + for (const [key, raw] of Object.entries(o.cells)) { + const next = rewriteRefsForRename(raw, o.id, targetTableId, newName, wb); + if (next !== raw) o.cells[key] = next; + } + } + } + } + /** Re-key every entry of a "col,row" map, shifting the given axis at/after * `at` by `delta` (dropping entries whose axis value === `at` when * `delta` is -1 -- that row/col's own cells are gone). Shared by @@ -2360,11 +2499,6 @@ function createStore() { .map((f) => ({ name: f.name, hint: `${f.name}(${f.params.join('; ')})`, kind: 'custom' })); return [...builtins, ...custom].sort((a, b) => a.name.localeCompare(b.name)); } - /** `'` needs quoting whenever a name isn't a bare identifier (spaces, - * punctuation, ...) -- shared by the table- and sheet-qualifier cases. */ - function quoteQualifier(name: string): string { - return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`; - } function qualifyRef(homeTableId: string, targetTableId: string, body: string): string { if (targetTableId === homeTableId) return body; const target = tableById(targetTableId);