Makros: Engine- und Store-Fundament (Zellen/Zeilen/Spalten/Tabellen/Blätter per Skript)
Erster von zwei Commits (UI folgt separat): neue, explizit ausgelöste Makro-Sprache — dieselbe block-mode-Grammatik wie bei "Mit Schleife"- Funktionen (FOR/IF/RETURN), plus ein neues Schreib-Vokabular (SETVALUE, INSERTROW/DELETEROW/INSERTCOL/DELETECOL, ADDTABLE/RENAMETABLE/DELETETABLE, ADDSHEET/RENAMESHEET/DELETESHEET), adressiert mit der bestehenden Blatt!Tabelle::Zelle-Syntax. Bewusst außerhalb der normalen Neuberechnung: ein Makro läuft nie aus einer Zellformel heraus, sondern nur explizit getriggert (store.runMacro), und mutiert ausschließlich über die bereits vorhandenen Store-Primitiven (setCellInput, insertRow, addTable, addSheet, …) — Workbook/EngineHost/ DependencyGraph bleiben unverändert rein lesend. Ein ganzer synchroner Makro-Lauf wird automatisch zu einem einzigen Undo-Schritt. 50 neue Tests (Engine + Store), inkl. eines gezielten Regressionstests für die zentrale Korrektheits-Fallgrube: eine strukturelle Aktion mitten im Lauf ersetzt die Workbook-Instanz komplett, jeder Host-Zugriff muss daher frisch aufgelöst werden statt eine Referenz zu cachen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,12 +27,18 @@ import {
|
||||
parseA1,
|
||||
parseFormula,
|
||||
parseFunctionBlock,
|
||||
parseMacroBlock,
|
||||
collectRefsInBlock,
|
||||
hasFunction,
|
||||
collectCalledFunctionNames,
|
||||
collectCalledFunctionNamesInBlock,
|
||||
declaredBlockNames,
|
||||
findInvalidForSource,
|
||||
findInvalidActionTarget,
|
||||
blockAlwaysReturns,
|
||||
buildFunctionIndex,
|
||||
expandMacroBlock,
|
||||
runMacroActions,
|
||||
parseDateTimeLiteral,
|
||||
BUILTIN_SIGNATURES,
|
||||
type CellRef,
|
||||
@@ -41,6 +47,8 @@ import {
|
||||
type Token,
|
||||
type FunctionDef,
|
||||
type NamedRangeDef,
|
||||
type MacroWriteHost,
|
||||
type EngineHost,
|
||||
} from '../engine';
|
||||
import {
|
||||
formatValue,
|
||||
@@ -57,6 +65,7 @@ import type {
|
||||
CustomFunction,
|
||||
DocumentState,
|
||||
FnSuggestion,
|
||||
Macro,
|
||||
Sheet,
|
||||
SelectionState,
|
||||
SheetObject,
|
||||
@@ -498,6 +507,7 @@ function createStore() {
|
||||
csvDelimiter: d.csvDelimiter,
|
||||
customFunctions: d.customFunctions,
|
||||
namedRanges: d.namedRanges,
|
||||
macros: d.macros,
|
||||
});
|
||||
let historySnapshot = snapshot();
|
||||
let historyFingerprint = contentFingerprint(historySnapshot);
|
||||
@@ -626,6 +636,12 @@ function createStore() {
|
||||
|
||||
// ---- lookups --------------------------------------------------
|
||||
const sheetById = (id: string): Sheet | undefined => doc.sheets.find((s) => s.id === id);
|
||||
/** Case-insensitive/trimmed lookup by *name* -- unlike `sheetById`, needed
|
||||
* for a macro's plain-string sheet references (`ADDSHEET`, `RENAMESHEET`,
|
||||
* `DELETESHEET`; see `runMacro` below), which address a sheet the same
|
||||
* way a formula's own qualifier addresses a table: by name, not id. */
|
||||
const sheetByName = (name: string): Sheet | undefined =>
|
||||
doc.sheets.find((s) => s.name.trim().toLowerCase() === name.trim().toLowerCase());
|
||||
const activeSheet = computed(() => sheetById(doc.activeSheetId) ?? doc.sheets[0]);
|
||||
|
||||
function objectById(id: string | null): SheetObject | undefined {
|
||||
@@ -930,6 +946,27 @@ function createStore() {
|
||||
* later switched to "Mit Schleife"). */
|
||||
const RESERVED_NAMES = new Set(['FOR', 'IN', 'DO', 'RETURN']);
|
||||
|
||||
/** The macro write-action vocabulary (see engine/types.ts's
|
||||
* `MacroWriteHost` and evaluator.ts's `execAction`) -- reserved against
|
||||
* use as a macro-local variable/loop-variable name, same reasoning as
|
||||
* `RESERVED_NAMES`: `SETVALUE = 5` would parse under the grammar (an
|
||||
* ordinary assignment, no ambiguity) but reads as if it might be calling
|
||||
* the action, so it's rejected at save time rather than left as a
|
||||
* confusing-but-technically-legal trap. */
|
||||
const MACRO_ACTION_NAMES = new Set([
|
||||
'SETVALUE',
|
||||
'INSERTROW',
|
||||
'DELETEROW',
|
||||
'INSERTCOL',
|
||||
'DELETECOL',
|
||||
'ADDTABLE',
|
||||
'RENAMETABLE',
|
||||
'DELETETABLE',
|
||||
'ADDSHEET',
|
||||
'RENAMESHEET',
|
||||
'DELETESHEET',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Full validation for add/update: name & parameter shape, no collision with
|
||||
* a built-in or another custom function, the body parses (as an
|
||||
@@ -1166,6 +1203,192 @@ function createStore() {
|
||||
syncWorkbook(); // affected formulas become #NAME? on rebuild, same as a deleted table
|
||||
}
|
||||
|
||||
// ---- macros (document-level, explicitly-triggered write scripts) ------
|
||||
// Created/edited/removed exclusively through the "▶ Makros" UI, run via
|
||||
// that panel's own "▶ Ausführen" button or a canvas button object's click
|
||||
// -- NEVER callable from a cell formula (see store/types.ts's `Macro` doc
|
||||
// comment for why this is a separate type from `CustomFunction`, not a
|
||||
// third `kind`, so that's impossible by construction). Unlike a custom
|
||||
// function's body, every cell/range reference in a macro must be
|
||||
// qualified with a table name (`Umsatz::A1`, never bare `A1`) -- a macro
|
||||
// has no "home table" the way a formula cell does.
|
||||
function validateMacro(name: string, body: string, ignoreId: string | null): string | null {
|
||||
const cleanName = name.trim();
|
||||
if (!IDENT_RE.test(cleanName)) {
|
||||
return `"${name}" ist kein gültiger Makroname.`;
|
||||
}
|
||||
if (looksLikeCell(cleanName)) {
|
||||
return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Makroname nicht erlaubt.`;
|
||||
}
|
||||
const collision = doc.macros.find(
|
||||
(m) => m.id !== ignoreId && m.name.toLowerCase() === cleanName.toLowerCase(),
|
||||
);
|
||||
if (collision) return `Es gibt bereits ein Makro namens "${collision.name}".`;
|
||||
|
||||
let block;
|
||||
try {
|
||||
block = parseMacroBlock(body);
|
||||
} catch (e) {
|
||||
return `Makro ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
||||
}
|
||||
const localNames = declaredBlockNames(block);
|
||||
for (const n of localNames) {
|
||||
if (!IDENT_RE.test(n)) return `"${n}" ist kein gültiger Name.`;
|
||||
if (looksLikeCell(n)) return `"${n}" sieht wie ein Zellbezug aus und ist als Name nicht erlaubt.`;
|
||||
if (RESERVED_NAMES.has(n.toUpperCase())) {
|
||||
return `"${n}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Name verwendet werden.`;
|
||||
}
|
||||
if (MACRO_ACTION_NAMES.has(n.toUpperCase())) {
|
||||
return `"${n}" ist der Name einer Makro-Aktion und kann nicht als Name verwendet werden.`;
|
||||
}
|
||||
}
|
||||
const badFor = findInvalidForSource(block);
|
||||
if (badFor) {
|
||||
return `FOR ${badFor.varName} IN ...: die Quelle muss eine Zelle oder ein Bereich sein, kein berechneter Ausdruck.`;
|
||||
}
|
||||
const badAction = findInvalidActionTarget(block);
|
||||
if (badAction) {
|
||||
return `${badAction.name}: das erste Argument muss ein Zell- oder Bereichsbezug sein, kein berechneter Ausdruck.`;
|
||||
}
|
||||
for (const ref of collectRefsInBlock(block)) {
|
||||
if ((ref.kind === 'cell' || ref.kind === 'range') && ref.qualifier === null) {
|
||||
return 'Zellbezüge in Makros müssen die Tabelle nennen, z. B. Umsatz::A1.';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addMacro(name: string, body: string): string | { error: string } {
|
||||
const error = validateMacro(name, body, null);
|
||||
if (error) return { error };
|
||||
const id = uid('mac');
|
||||
const macro: Macro = { id, name: name.trim(), body: body.startsWith('=') ? body.slice(1) : body };
|
||||
doc.macros.push(macro);
|
||||
bump();
|
||||
return id;
|
||||
}
|
||||
|
||||
function updateMacro(id: string, name: string, body: string): void | { error: string } {
|
||||
const m = doc.macros.find((x) => x.id === id);
|
||||
if (!m) return { error: 'Makro nicht gefunden.' };
|
||||
const error = validateMacro(name, body, id);
|
||||
if (error) return { error };
|
||||
m.name = name.trim();
|
||||
m.body = body.startsWith('=') ? body.slice(1) : body;
|
||||
bump();
|
||||
}
|
||||
|
||||
function deleteMacro(id: string) {
|
||||
doc.macros = doc.macros.filter((m) => m.id !== id);
|
||||
bump();
|
||||
}
|
||||
|
||||
/** Resolve a macro action's plain "Tabelle" / "Blatt!Tabelle" string
|
||||
* argument the same way a formula's own qualifier resolves -- via the
|
||||
* live `wb.resolveTableName`. Reads the outer (closure-scoped) `wb`
|
||||
* directly, so it always sees whatever `syncWorkbook()` most recently
|
||||
* assigned -- critical when an earlier action in the SAME macro run was
|
||||
* structural (see `runMacro`'s own doc comment). Throws a clear error
|
||||
* when the table doesn't exist, instead of the primitives' own silent
|
||||
* no-op on an unknown id. */
|
||||
function resolveMacroTableRef(ref: string): string {
|
||||
const bang = ref.indexOf('!');
|
||||
const table = bang === -1 ? ref : ref.slice(bang + 1);
|
||||
const sheet = bang === -1 ? null : ref.slice(0, bang);
|
||||
const tableId = wb.resolveTableName(table, sheet);
|
||||
if (!tableId) throw new Error(`unbekannte Tabelle '${ref}'`);
|
||||
return tableId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a macro synchronously to completion (see store/types.ts's `Macro`
|
||||
* doc comment for the "why explicit trigger, not recalc-embedded" design)
|
||||
* -- a full JS-synchronous run automatically becomes exactly ONE undo
|
||||
* step, for free, via the existing debounced-fingerprint history (no
|
||||
* transaction machinery needed, see `commitHistoryStep`'s doc comment).
|
||||
*
|
||||
* Both hosts handed to the interpreter re-read the outer `wb` on every
|
||||
* call rather than capturing it once at the top -- required correctness,
|
||||
* not a style choice: a structural action (INSERTROW, ADDTABLE, ...)
|
||||
* calls `syncWorkbook()`, which reassigns `wb` to a brand-new `Workbook`
|
||||
* instance; a cached reference from before that point would silently go
|
||||
* stale for every read for the rest of the run.
|
||||
*/
|
||||
function runMacro(macroId: string): { ok: boolean; message: string } {
|
||||
const macro = doc.macros.find((m) => m.id === macroId);
|
||||
if (!macro) return { ok: false, message: 'Makro nicht gefunden.' };
|
||||
|
||||
const macroHost: EngineHost = {
|
||||
// Never actually consulted: validateMacro rejects every unqualified
|
||||
// cell/range reference at save time, so evalScalar's 'cell' case
|
||||
// never needs "this table" for a macro. A getter (not a fixed empty
|
||||
// string) purely so this object has the exact same "always reads
|
||||
// fresh" shape as every other field here.
|
||||
get homeTableId() {
|
||||
return '';
|
||||
},
|
||||
resolveTableName: (name, sheetName) => wb.resolveTableName(name, sheetName ?? null),
|
||||
resolveNamedRange: (name) => wb.resolveNamedRange(name),
|
||||
tableSize: (id) => wb.tableSize(id),
|
||||
getComputed: (tableId, col, row) => wb.getComputed(tableId, col, row),
|
||||
};
|
||||
|
||||
const writeHost: MacroWriteHost = {
|
||||
setValue(cells, text) {
|
||||
for (const c of cells) setCellInput(c.tableId, c.col, c.row, text);
|
||||
},
|
||||
insertRow(tableRef, at) {
|
||||
insertRow(resolveMacroTableRef(tableRef), at);
|
||||
},
|
||||
deleteRow(tableRef, at) {
|
||||
deleteRow(resolveMacroTableRef(tableRef), at);
|
||||
},
|
||||
insertCol(tableRef, at) {
|
||||
insertColumn(resolveMacroTableRef(tableRef), at);
|
||||
},
|
||||
deleteCol(tableRef, at) {
|
||||
deleteColumn(resolveMacroTableRef(tableRef), at);
|
||||
},
|
||||
addTable(sheetRef, name) {
|
||||
const sheet = sheetByName(sheetRef);
|
||||
if (!sheet) throw new Error(`unbekanntes Blatt '${sheetRef}'`);
|
||||
renameObject(addTable(sheet.id), name);
|
||||
},
|
||||
renameTable(tableRef, newName) {
|
||||
renameObject(resolveMacroTableRef(tableRef), newName);
|
||||
},
|
||||
deleteTable(tableRef) {
|
||||
deleteObject(resolveMacroTableRef(tableRef));
|
||||
},
|
||||
addSheet(name) {
|
||||
addSheet(name);
|
||||
},
|
||||
renameSheet(sheetRef, newName) {
|
||||
const sheet = sheetByName(sheetRef);
|
||||
if (!sheet) throw new Error(`unbekanntes Blatt '${sheetRef}'`);
|
||||
renameSheet(sheet.id, newName);
|
||||
},
|
||||
deleteSheet(sheetRef) {
|
||||
const sheet = sheetByName(sheetRef);
|
||||
if (!sheet) throw new Error(`unbekanntes Blatt '${sheetRef}'`);
|
||||
// deleteSheet() itself silently no-ops on the last sheet -- surface
|
||||
// that as a real failure here instead of a misleading success toast.
|
||||
if (doc.sheets.length <= 1) throw new Error('das letzte Blatt kann nicht gelöscht werden');
|
||||
deleteSheet(sheet.id);
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const block = parseMacroBlock(macro.body);
|
||||
const index = buildFunctionIndex(toFunctionDefs(doc));
|
||||
const expanded = expandMacroBlock(block, index);
|
||||
const result = runMacroActions(expanded, macroHost, writeHost);
|
||||
return { ok: true, message: typeof result === 'string' ? result : 'Makro ausgeführt.' };
|
||||
} catch (e) {
|
||||
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
function moveObject(objId: string, x: number, y: number) {
|
||||
const o = objectById(objId);
|
||||
if (!o) return;
|
||||
@@ -2109,6 +2332,7 @@ function createStore() {
|
||||
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
|
||||
doc.customFunctions = next.customFunctions ?? [];
|
||||
doc.namedRanges = next.namedRanges ?? [];
|
||||
doc.macros = next.macros ?? [];
|
||||
syncWorkbook();
|
||||
}
|
||||
/** Loading a different document outright (JSON import "replace", "reset to
|
||||
@@ -2206,6 +2430,11 @@ function createStore() {
|
||||
addNamedRange,
|
||||
updateNamedRange,
|
||||
deleteNamedRange,
|
||||
// macros
|
||||
addMacro,
|
||||
updateMacro,
|
||||
deleteMacro,
|
||||
runMacro,
|
||||
resizeChart,
|
||||
bringToFront,
|
||||
setChartView,
|
||||
|
||||
Reference in New Issue
Block a user