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:
@@ -1402,6 +1402,215 @@ describe('functionSuggestions (formula autocomplete)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('macros (document-level, explicitly-triggered write scripts)', () => {
|
||||
it('addMacro stores the definition; a bare (unqualified) cell reference is rejected at save time', () => {
|
||||
const s = fresh();
|
||||
const bad = s.addMacro('M', 'SETVALUE(A1; 1)') as { error: string };
|
||||
expect(bad.error).toBeTruthy();
|
||||
expect(s.doc.macros).toHaveLength(0);
|
||||
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
const good = s.addMacro('M', `SETVALUE(${name}::A1; 1)`);
|
||||
expect(typeof good).toBe('string');
|
||||
expect(s.doc.macros).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects a name colliding with another macro (case-insensitive)', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
s.addMacro('Setup', `SETVALUE(${name}::A1; 1)`);
|
||||
const result = s.addMacro('SETUP', `SETVALUE(${name}::A1; 2)`) as { error: string };
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(s.doc.macros).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects one of the 11 write-action names used as a local variable', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
const result = s.addMacro('M', `SETVALUE = 1 SETVALUE(${name}::A1; SETVALUE)`) as { error: string };
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects a FOR source or SETVALUE target that is a computed expression, not a real reference', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
expect((s.addMacro('M1', `SETVALUE(1+1; 1)`) as { error: string }).error).toBeTruthy();
|
||||
expect(
|
||||
(s.addMacro('M2', `FOR c IN 1+1 DO SETVALUE(${name}::A1; 0) END`) as { error: string }).error,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('runMacro: SETVALUE writes a cell, reported as a success with a default message', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
const macroId = s.addMacro('Setzen', `SETVALUE(${name}::A1; 42)`) as string;
|
||||
|
||||
const result = s.runMacro(macroId);
|
||||
expect(result).toEqual({ ok: true, message: 'Makro ausgeführt.' });
|
||||
expect(s.cellInput(id, 0, 0)).toBe('42');
|
||||
});
|
||||
|
||||
it('runMacro: a RETURNed string becomes the success message instead of the default', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1) RETURN "fertig"`) as string;
|
||||
expect(s.runMacro(macroId)).toEqual({ ok: true, message: 'fertig' });
|
||||
});
|
||||
|
||||
it('runMacro: SETVALUE can write a live formula (a value starting with "=")', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'A1', '3');
|
||||
setCell(s, name, 'A2', '4');
|
||||
const macroId = s.addMacro('M', `SETVALUE(${name}::A3; "=" & ${name}::A1 & "+" & ${name}::A2)`) as string;
|
||||
expect(s.runMacro(macroId).ok).toBe(true);
|
||||
expect(s.cellInput(id, 0, 2)).toBe('=3+4');
|
||||
expect(val(s, name, 'A3')).toBe(7);
|
||||
});
|
||||
|
||||
it('runMacro: INSERTROW performs the same document-wide reference rewrite as the store primitive', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'A1', '10');
|
||||
setCell(s, name, 'A2', '20');
|
||||
setCell(s, name, 'B1', `=${name}::A1+${name}::A2`);
|
||||
const macroId = s.addMacro('Zeile', `INSERTROW("${name}"; 0)`) as string;
|
||||
|
||||
expect(s.runMacro(macroId).ok).toBe(true);
|
||||
expect(s.tableById(id)!.rows).toBe(13); // seed default 12 + 1
|
||||
expect(s.cellInput(id, 0, 1)).toBe('10'); // A1's content slid down to A2
|
||||
expect(s.cellInput(id, 1, 1)).toBe(`=${name}::A2+${name}::A3`); // the SUM formula's own refs shifted too
|
||||
});
|
||||
|
||||
it('runMacro: a structural action mid-run does not leave later reads/writes stale (the thin-host-proxy fix)', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'A1', '5');
|
||||
// SETVALUE writes B1, then INSERTROW at 0 shifts everything down one row
|
||||
// (B1's write becomes B2), then a second SETVALUE writes C1 based on a
|
||||
// READ of A1 (now at A2, post-insert) -- if the interpreter's host were
|
||||
// a stale cached Workbook reference from before the INSERTROW, this
|
||||
// read would silently return an empty/wrong value instead of 5.
|
||||
const macroId = s.addMacro(
|
||||
'M',
|
||||
`SETVALUE(${name}::B1; 1) INSERTROW("${name}"; 0) SETVALUE(${name}::C1; ${name}::A2 + 100)`,
|
||||
) as string;
|
||||
|
||||
expect(s.runMacro(macroId)).toEqual({ ok: true, message: 'Makro ausgeführt.' });
|
||||
expect(s.cellInput(id, 1, 1)).toBe('1'); // B1's write, shifted to B2 by the insert
|
||||
expect(val(s, name, 'C1')).toBe(105); // reads A2 (=5, shifted from A1) fresh, post-insert
|
||||
});
|
||||
|
||||
it('runMacro: ADDTABLE/RENAMETABLE/DELETETABLE round-trip', () => {
|
||||
const s = fresh();
|
||||
const sheetId = s.doc.activeSheetId;
|
||||
const sheetName = s.sheetById(sheetId)!.name;
|
||||
const macroId = s.addMacro(
|
||||
'M',
|
||||
`ADDTABLE("${sheetName}"; "MakroTabelle") RENAMETABLE("MakroTabelle"; "Umbenannt") DELETETABLE("Umbenannt")`,
|
||||
) as string;
|
||||
const before = s.tablesOf(sheetId).length;
|
||||
|
||||
expect(s.runMacro(macroId).ok).toBe(true);
|
||||
expect(s.tablesOf(sheetId).length).toBe(before); // added then deleted -- net zero
|
||||
});
|
||||
|
||||
it('runMacro: ADDSHEET/RENAMESHEET/DELETESHEET round-trip', () => {
|
||||
const s = fresh();
|
||||
const before = s.doc.sheets.length;
|
||||
const macroId = s.addMacro(
|
||||
'M',
|
||||
'ADDSHEET("MakroBlatt") RENAMESHEET("MakroBlatt"; "Umbenannt") DELETESHEET("Umbenannt")',
|
||||
) as string;
|
||||
|
||||
expect(s.runMacro(macroId).ok).toBe(true);
|
||||
expect(s.doc.sheets.length).toBe(before);
|
||||
});
|
||||
|
||||
it('runMacro: DELETESHEET refuses the last sheet, surfaced as a real failure not a silent no-op', () => {
|
||||
const s = fresh();
|
||||
// collapse to exactly one sheet first
|
||||
while (s.doc.sheets.length > 1) s.deleteSheet(s.doc.sheets[s.doc.sheets.length - 1].id);
|
||||
const onlySheetName = s.doc.sheets[0].name;
|
||||
const macroId = s.addMacro('M', `DELETESHEET("${onlySheetName}")`) as string;
|
||||
|
||||
const result = s.runMacro(macroId);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(s.doc.sheets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('runMacro: an unknown table reference fails cleanly, no throw escapes', () => {
|
||||
const s = fresh();
|
||||
const macroId = s.addMacro('M', 'INSERTROW("Nirgendwo"; 0)') as string;
|
||||
const result = s.runMacro(macroId);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toMatch(/unbekannte Tabelle/);
|
||||
});
|
||||
|
||||
it('runMacro: an unknown macro id fails cleanly', () => {
|
||||
const s = fresh();
|
||||
expect(s.runMacro('does-not-exist').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('updateMacro changes the body; deleteMacro removes it', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1)`) as string;
|
||||
|
||||
s.updateMacro(macroId, 'M', `SETVALUE(${name}::A1; 2)`);
|
||||
s.runMacro(macroId);
|
||||
expect(s.cellInput(id, 0, 0)).toBe('2');
|
||||
|
||||
s.deleteMacro(macroId);
|
||||
expect(s.doc.macros).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a whole synchronous macro run (SETVALUE + a structural action) coalesces into exactly one undo step', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1) INSERTROW("${name}"; 0)`) as string;
|
||||
vi.advanceTimersByTime(600); // the addTable + addMacro setup settles first
|
||||
|
||||
s.runMacro(macroId);
|
||||
vi.advanceTimersByTime(600);
|
||||
expect(s.cellInput(id, 0, 1)).toBe('1'); // shifted down by the insert
|
||||
expect(s.tableById(id)!.rows).toBe(13);
|
||||
|
||||
s.undo(); // one Ctrl+Z undoes the ENTIRE macro run
|
||||
expect(s.cellInput(id, 0, 0)).toBe('');
|
||||
expect(s.cellInput(id, 0, 1)).toBe('');
|
||||
expect(s.tableById(id)!.rows).toBe(12);
|
||||
} finally {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('JSON export/import round-trips macros byte-for-byte', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
s.addMacro('Meine Aktion', `SETVALUE(${name}::A1; 1)`);
|
||||
|
||||
const json = serializeDocument(s.doc);
|
||||
const reimported = parseDocument(json);
|
||||
expect(reimported.macros).toEqual(s.doc.macros);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom functions: "block" kind (FOR/IF/RETURN "code")', () => {
|
||||
const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -193,6 +193,32 @@ export interface NamedRange {
|
||||
end: CellCoord;
|
||||
}
|
||||
|
||||
/** A document-wide, explicitly-triggered script -- NOT callable from any
|
||||
* cell formula (deliberately a separate type from `CustomFunction`, not a
|
||||
* third `kind`, so that's impossible by construction, not just
|
||||
* convention). Can freely read AND WRITE cells, rows/columns and tables/
|
||||
* sheets across the whole document (`engine/types.ts`'s `MacroWriteHost`
|
||||
* vocabulary: `SETVALUE`/`INSERTROW`/`ADDTABLE`/`ADDSHEET`/...), unlike an
|
||||
* ordinary formula -- safe only because it runs OUTSIDE the passive
|
||||
* recalculation graph, triggered by a click (the "▶ Ausführen" button in
|
||||
* the Makros panel, or a canvas `ButtonObject`), never automatically. See
|
||||
* `document.ts`'s `runMacro`/`validateMacro` and README's "Makros"
|
||||
* section. Takes no parameters (a click carries no arguments) and can
|
||||
* never call another macro (no such action exists) -- both deliberate,
|
||||
* documented v1 scope cuts. */
|
||||
export interface Macro {
|
||||
id: string;
|
||||
/** document-unique, compared case-insensitively; never a built-in/custom
|
||||
* function/action name. */
|
||||
name: string;
|
||||
/** raw statement-sequence text (assignments, FOR/IF/RETURN, and
|
||||
* write-action calls), no leading "=" -- parsed via
|
||||
* `engine/parser.ts`'s `parseMacroBlock`. Every cell/range reference
|
||||
* inside must be qualified with a table name (`Umsatz::A1`, never bare
|
||||
* `A1`) -- a macro has no "home table", see `validateMacro`. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** One entry in the formula-autocomplete dropdown (see document.ts's
|
||||
* `functionSuggestions()`). */
|
||||
export interface FnSuggestion {
|
||||
@@ -234,4 +260,5 @@ export interface DocumentState {
|
||||
csvDelimiter: ',' | ';';
|
||||
customFunctions: CustomFunction[];
|
||||
namedRanges: NamedRange[];
|
||||
macros: Macro[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user