Tabellen-Umbenennung schreibt Referenzen dokumentweit um

Bisher speicherte eine Formel den Tabellennamen nur als Text im
Qualifier ("Umsatz!B2") – beim Umbenennen der Tabelle wurde nirgendwo
danach gesucht, sodass jede andere Formel, die noch den alten Namen
nannte, beim nächsten Neuberechnen #REF zeigte.

renameObject() ruft jetzt vor der eigentlichen Umbenennung eine neue
renameDocumentRefs()/rewriteRefsForRename() auf, die dieselbe
Token-Splice-Technik wiederverwendet, die für das dokumentweite
Umschreiben bei Zeilen-/Spalten-Einfügen bereits existiert: jede Formel
im Dokument wird tokenisiert, jeder Qualifier über die echte
wb.resolveTableName-Auflösung (nicht per String-Vergleich) auf die
umbenannte Tabelle geprüft, und nur die Tabellen-Namens-Textspanne
ersetzt – bei der 3-teiligen "Blatt!Tabelle::Zelle"-Form bleibt der
Blatt-Teil unangetastet, ein neuer Name mit Leerzeichen wird korrekt
gequotet. Umbenennen + Umschreiben zählen als ein Undo-Schritt.

Bewusst auf Tabellenzellen begrenzt, wie schon beim Zeilen-/Spalten-
Umschreiben (keine Makro-Aktionen, keine Funktionskörper).

Live per Playwright verifiziert: "Umsatz" per Doppelklick zu "Verkauf"
umbenannt, =SUM(Umsatz!B2:E2) in einer anderen Tabelle wurde automatisch
zu =SUM(Verkauf!B2:E2), Wert blieb unverändert (kein #REF).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-09 12:10:23 +02:00
co-authored by Claude Sonnet 5
parent 3ae3d81066
commit 47c99bbeb6
3 changed files with 304 additions and 13 deletions
+41 -7
View File
@@ -206,9 +206,11 @@ eingebauter Funktionsname: `=MARGE(Umsatz!B2; Umsatz!C2)`.
Name/Parameter, ein nicht parsender Formelkörper, und ein **Funktions- Name/Parameter, ein nicht parsender Formelkörper, und ein **Funktions-
Zyklus** (A ruft B, B ruft A, auch über mehrere Stufen) — wird abgelehnt, Zyklus** (A ruft B, B ruft A, auch über mehrere Stufen) — wird abgelehnt,
bevor je eine `#ERR`-Zelle entsteht. bevor je eine `#ERR`-Zelle entsteht.
- **Umbenennen/Löschen** aktualisiert **keine** bestehenden Formeltexte — - **Umbenennen/Löschen** aktualisiert **keine** bestehenden Formeltexte.
genau wie beim Umbenennen/Löschen einer Tabelle. Zellen, die die Zellen, die die betroffene Funktion aufrufen, zeigen danach `#NAME?`.
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** - Ein Aufruf wird **beim Parsen der aufrufenden Zelle in ihre AST eingesetzt**
(Inlining, keine Laufzeit-Closures) — siehe (Inlining, keine Laufzeit-Closures) — siehe
[`src/engine/userFunctions.ts`](src/engine/userFunctions.ts) und die [`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 Zieltabelle selbst und fügt automatisch die volle 3-teilige Form ein, statt
einer Formel, die versehentlich die falsche Tabelle träfe. 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` / **`$`-Bezüge sperren** (relativ/gemischt/absolut): `A1` (relativ) vs. `$A1` /
`A$1` (Spalte bzw. Zeile gesperrt) vs. `$A$1` (beide gesperrt). Für die `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 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), Bereich, `$`-Sperren bleiben wirkungslos (anders als beim Kopieren),
unveränderte Referenzen bleiben byte-identisch, Undo/Redo, Schutz gegen unveränderte Referenzen bleiben byte-identisch, Undo/Redo, Schutz gegen
Löschen der letzten verbleibenden Zeile/Spalte. 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 - **Abhängigkeiten erkunden** (`graphPrecedents`/`graphDependents` in
`workbook.spec.ts`, `cellGraphSelf`/`cellGraphNeighbors`/ `workbook.spec.ts`, `cellGraphSelf`/`cellGraphNeighbors`/
`openDependencyGraph`/`closeDependencyGraph` in `document.spec.ts`) — `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: - **Benutzerdefinierte Funktionen** (Phase 1, siehe oben) bewusst ohne:
Live-Vorschau des Ergebnisses im Bearbeiten-Dialog, variadische Parameter/ Live-Vorschau des Ergebnisses im Bearbeiten-Dialog, variadische Parameter/
Default-Werte, und automatisches Nachführen bestehender Aufrufe bei Default-Werte, und automatisches Nachführen bestehender Aufrufe bei
Umbenennung (Verhalten entspricht bewusst dem einer Tabellen-Umbenennung, Umbenennung — anders als bei einer Tabelle inzwischen (s. o.) bewusst
s. o.) — der Funktionsname selbst wird beim Formeltippen aber inzwischen nicht nachgezogen, da eine Funktion nur einen Aufruf-Namen hat, keine
vervollständigt (s. o. „Funktions-Autovervollständigung"), auch für eigene umschreibbare Referenz-Syntax. Der Funktionsname selbst wird beim
Funktionen. Formeltippen aber inzwischen vervollständigt (s. o. „Funktions-
Autovervollständigung"), auch für eigene Funktionen.
- **Funktions-Autovervollständigung** bewusst ohne: Zell-/Tabellennamen- - **Funktions-Autovervollständigung** bewusst ohne: Zell-/Tabellennamen-
Vorschläge (dafür existiert der Point-Mode, s. o.), Vervollständigung, die 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 auch Text *nach* dem Cursor berücksichtigt (nur der Text bis zum Cursor
+123
View File
@@ -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)', () => { describe('dependency-graph view (cellGraphSelf/cellGraphNeighbors)', () => {
it('cellGraphSelf describes a formula cell: label, raw text, value, table/sheet name', () => { it('cellGraphSelf describes a formula cell: label, raw text, value, table/sheet name', () => {
const s = fresh(); const s = fresh();
+140 -6
View File
@@ -413,6 +413,113 @@ function rewriteRefsForStructuralChange(
return '=' + out; 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 { function loadInitial(): DocumentState {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
@@ -927,7 +1034,15 @@ function createStore() {
.filter((t) => t.id !== objId) .filter((t) => t.id !== objId)
.map((t) => t.name.toLowerCase()), .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 syncWorkbook(); // formulas referencing the old/new name must re-resolve
} else { } else {
o.name = clean; 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 /** 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 * `at` by `delta` (dropping entries whose axis value === `at` when
* `delta` is -1 -- that row/col's own cells are gone). Shared by * `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' })); .map((f) => ({ name: f.name, hint: `${f.name}(${f.params.join('; ')})`, kind: 'custom' }));
return [...builtins, ...custom].sort((a, b) => a.name.localeCompare(b.name)); 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 { function qualifyRef(homeTableId: string, targetTableId: string, body: string): string {
if (targetTableId === homeTableId) return body; if (targetTableId === homeTableId) return body;
const target = tableById(targetTableId); const target = tableById(targetTableId);