Makros: GET/POST-Anfragen mit JSON-Ergebnis und FOR-Iteration
Erweitert die Makro-Sprache um Netzwerkzugriff: "x = GET(url)"/ "x = POST(url; body)" binden die geparste JSON-Antwort an eine Variable; FOR item IN x DO ... END iteriert, wenn x ein JSON-Array ist, über dessen Elemente; JSONGET(wert; pfad) liest ein Feld heraus (punktgetrennt, Zahl = Array-Index) und funktioniert – anders als GET/POST – überall in einem Ausdruck verschachtelt, nicht nur als ganze Zuweisung. Da ein echter Netzwerkaufruf nicht synchron laufen kann, ohne den Tab einzufrieren, bekommt der Makro-Interpreter einen separaten asynchronen Zweig (execBlockAsync/execStatementAsync); der bestehende synchrone Interpreter, den auch gewöhnliche "Mit Schleife"-Funktionen mitten in der synchronen Neuberechnung nutzen, bleibt unangetastet. Netzwerkaufrufe werden pro Makro einmalig mit Zielname bestätigt (dauerhaft gemerkt); die bestehende "ein Lauf = ein Undo-Schritt"-Garantie, die bislang auf Synchronität beruhte, wird jetzt explizit wiederhergestellt statt sich weiter auf die Zeit-Koaleszenz zu verlassen. Zwei echte Lücken erst beim eigenen Live-Test gefunden und behoben: (1) JSONGET direkt in eine Aktion verschachtelt (statt erst einer Variable zugewiesen) scheiterte zunächst an #NAME?; (2) JSONGET(...) direkt als FOR-Quelle (ohne Zwischenvariable) scheiterte sowohl an der Speicher-Zeit-Prüfung als auch zur Laufzeit, da substitute() einen Funktionsaufruf nie auswertet. Live gegen eine neue same-origin-Fixture (public/test-fixtures/sample.json) verifiziert: echter GET, echter Bestätigungsdialog, FOR/JSONGET über die echte Antwort, kein erneuter Dialog beim zweiten Lauf. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -777,6 +777,17 @@ describe('custom functions (document-level library)', () => {
|
||||
expect(s.doc.customFunctions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a body calling GET/POST/JSONGET -- macro-only, in both an expression-kind and a block-kind function', () => {
|
||||
const s = fresh();
|
||||
for (const name of ['GET', 'POST', 'JSONGET']) {
|
||||
const expr = s.addCustomFunction('F1', ['x'], `${name}(x)`, 'expression') as { error: string };
|
||||
expect(expr.error).toBeTruthy();
|
||||
const block = s.addCustomFunction('F2', ['x'], `r = ${name}(x) RETURN r`, 'block') as { error: string };
|
||||
expect(block.error).toBeTruthy();
|
||||
}
|
||||
expect(s.doc.customFunctions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a name colliding with another custom function (case-insensitive)', () => {
|
||||
const s = fresh();
|
||||
s.addCustomFunction('Marge', ['x'], 'x');
|
||||
@@ -1444,38 +1455,49 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('runMacro: SETVALUE writes a cell, reported as a success with a default message', () => {
|
||||
it('accepts a JSONGET(...) call used directly as a FOR source (not first bound to a variable) -- found live: this needed its own fix in findInvalidForSource, distinct from the ordinary cell/range/param shape check', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
const result = s.addMacro(
|
||||
'M',
|
||||
`r = GET("https://x") FOR item IN JSONGET(r; "items") DO SETVALUE(${name}::A1; JSONGET(item; "name")) END`,
|
||||
);
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('runMacro: SETVALUE writes a cell, reported as a success with a default message', async () => {
|
||||
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);
|
||||
const result = await 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', () => {
|
||||
it('runMacro: a RETURNed string becomes the success message instead of the default', async () => {
|
||||
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' });
|
||||
await expect(s.runMacro(macroId)).resolves.toEqual({ ok: true, message: 'fertig' });
|
||||
});
|
||||
|
||||
it('runMacro: SETVALUE can write a live formula (a value starting with "=")', () => {
|
||||
it('runMacro: SETVALUE can write a live formula (a value starting with "=")', async () => {
|
||||
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((await 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', () => {
|
||||
it('runMacro: INSERTROW performs the same document-wide reference rewrite as the store primitive', async () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
@@ -1484,13 +1506,13 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
|
||||
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((await 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)', () => {
|
||||
it('runMacro: a structural action mid-run does not leave later reads/writes stale (the thin-host-proxy fix)', async () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
@@ -1505,12 +1527,12 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
|
||||
`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.' });
|
||||
await expect(s.runMacro(macroId)).resolves.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', () => {
|
||||
it('runMacro: ADDTABLE/RENAMETABLE/DELETETABLE round-trip', async () => {
|
||||
const s = fresh();
|
||||
const sheetId = s.doc.activeSheetId;
|
||||
const sheetName = s.sheetById(sheetId)!.name;
|
||||
@@ -1520,11 +1542,11 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
|
||||
) as string;
|
||||
const before = s.tablesOf(sheetId).length;
|
||||
|
||||
expect(s.runMacro(macroId).ok).toBe(true);
|
||||
expect((await s.runMacro(macroId)).ok).toBe(true);
|
||||
expect(s.tablesOf(sheetId).length).toBe(before); // added then deleted -- net zero
|
||||
});
|
||||
|
||||
it('runMacro: ADDTABLE on a DIFFERENT sheet does not yank the active sheet/selection there (found live: addTable() itself navigates, a macro must undo just that incidental side effect)', () => {
|
||||
it('runMacro: ADDTABLE on a DIFFERENT sheet does not yank the active sheet/selection there (found live: addTable() itself navigates, a macro must undo just that incidental side effect)', async () => {
|
||||
const s = fresh();
|
||||
const startSheetId = s.doc.activeSheetId;
|
||||
const startSelection = s.doc.selection;
|
||||
@@ -1533,14 +1555,14 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
|
||||
expect(s.doc.activeSheetId).toBe(startSheetId);
|
||||
|
||||
const macroId = s.addMacro('M', 'ADDTABLE("Ziel"; "Neu")') as string;
|
||||
expect(s.runMacro(macroId).ok).toBe(true);
|
||||
expect((await s.runMacro(macroId)).ok).toBe(true);
|
||||
|
||||
expect(s.doc.activeSheetId).toBe(startSheetId); // NOT switched to "Ziel"
|
||||
expect(s.doc.selection).toEqual(startSelection);
|
||||
expect(s.tablesOf(otherSheetId).map((t) => t.name)).toContain('Neu'); // the mutation itself still happened
|
||||
});
|
||||
|
||||
it('runMacro: ADDSHEET/RENAMESHEET/DELETESHEET round-trip', () => {
|
||||
it('runMacro: ADDSHEET/RENAMESHEET/DELETESHEET round-trip', async () => {
|
||||
const s = fresh();
|
||||
const before = s.doc.sheets.length;
|
||||
const macroId = s.addMacro(
|
||||
@@ -1548,50 +1570,50 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
|
||||
'ADDSHEET("MakroBlatt") RENAMESHEET("MakroBlatt"; "Umbenannt") DELETESHEET("Umbenannt")',
|
||||
) as string;
|
||||
|
||||
expect(s.runMacro(macroId).ok).toBe(true);
|
||||
expect((await 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', () => {
|
||||
it('runMacro: DELETESHEET refuses the last sheet, surfaced as a real failure not a silent no-op', async () => {
|
||||
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);
|
||||
const result = await 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', () => {
|
||||
it('runMacro: an unknown table reference fails cleanly, no throw escapes', async () => {
|
||||
const s = fresh();
|
||||
const macroId = s.addMacro('M', 'INSERTROW("Nirgendwo"; 0)') as string;
|
||||
const result = s.runMacro(macroId);
|
||||
const result = await s.runMacro(macroId);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toMatch(/unbekannte Tabelle/);
|
||||
});
|
||||
|
||||
it('runMacro: an unknown macro id fails cleanly', () => {
|
||||
it('runMacro: an unknown macro id fails cleanly', async () => {
|
||||
const s = fresh();
|
||||
expect(s.runMacro('does-not-exist').ok).toBe(false);
|
||||
expect((await s.runMacro('does-not-exist')).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('updateMacro changes the body; deleteMacro removes it', () => {
|
||||
it('updateMacro changes the body; deleteMacro removes it', async () => {
|
||||
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);
|
||||
await 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', () => {
|
||||
it('a whole synchronous macro run (SETVALUE + a structural action) coalesces into exactly one undo step', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const s = fresh();
|
||||
@@ -1600,7 +1622,7 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
|
||||
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);
|
||||
await 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);
|
||||
@@ -1615,6 +1637,119 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET/POST: confirmation gate + persistence, and the undo-invariant fix', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('confirms the first GET to a new host, persists it, and does not re-prompt for a second run to the same host', async () => {
|
||||
const confirmMock = vi.fn(() => true);
|
||||
vi.stubGlobal('confirm', confirmMock);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ ok: true, json: async () => ({ v: 1 }) })),
|
||||
);
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
const macroId = s.addMacro('M', `r = GET("https://api.example.com/x") SETVALUE(${name}::A1; JSONGET(r; "v"))`) as string;
|
||||
|
||||
expect((await s.runMacro(macroId)).ok).toBe(true);
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1);
|
||||
expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual(['api.example.com']);
|
||||
|
||||
expect((await s.runMacro(macroId)).ok).toBe(true);
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1); // NOT called again -- already approved
|
||||
});
|
||||
|
||||
it('a different host on the same macro prompts again', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => 1 })));
|
||||
const s = fresh();
|
||||
const macroId = s.addMacro('M', 'a = GET("https://one.example.com") b = GET("https://two.example.com")') as string;
|
||||
|
||||
expect((await s.runMacro(macroId)).ok).toBe(true);
|
||||
expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual([
|
||||
'one.example.com',
|
||||
'two.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('declining the confirmation aborts the macro and does NOT persist the host', async () => {
|
||||
const confirmMock = vi.fn(() => false);
|
||||
vi.stubGlobal('confirm', confirmMock);
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
const s = fresh();
|
||||
const macroId = s.addMacro('M', 'r = GET("https://api.example.com/x")') as string;
|
||||
|
||||
const result = await s.runMacro(macroId);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toMatch(/abgelehnt/);
|
||||
expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('a non-2xx response aborts the macro cleanly', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ ok: false, status: 500, statusText: 'Internal Server Error' })),
|
||||
);
|
||||
const s = fresh();
|
||||
const macroId = s.addMacro('M', 'r = GET("https://api.example.com/x")') as string;
|
||||
|
||||
const result = await s.runMacro(macroId);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toMatch(/500/);
|
||||
});
|
||||
|
||||
it('the mandatory test: SETVALUE -> a slow GET -> SETVALUE still produces exactly one undo step, proving the macroRunActive fix', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
// longer than HISTORY_COALESCE_MS (150ms) -- without the
|
||||
// macroRunActive fix, the debounced auto-commit would fire
|
||||
// *during* this wait and split the run into two undo steps
|
||||
setTimeout(() => resolve({ ok: true, json: async () => 7 }), 300),
|
||||
),
|
||||
),
|
||||
);
|
||||
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) r = GET("https://api.example.com/x") SETVALUE(${name}::A2; r)`,
|
||||
) as string;
|
||||
vi.advanceTimersByTime(600); // let the addTable + addMacro setup settle into its OWN step first
|
||||
|
||||
const runPromise = s.runMacro(macroId);
|
||||
await vi.advanceTimersByTimeAsync(300); // let the mocked fetch's internal setTimeout resolve
|
||||
await runPromise;
|
||||
expect(s.cellInput(id, 0, 0)).toBe('1');
|
||||
expect(s.cellInput(id, 0, 1)).toBe('7');
|
||||
|
||||
// If the run had split into two undo steps (the bug this fix
|
||||
// prevents), this single undo() would only revert the *second*
|
||||
// SETVALUE, leaving A1 still '1' -- a direct, unambiguous proof of
|
||||
// "one run = one step" without needing to peek at undoStack.length.
|
||||
// (The table itself survives too, proving it settled as its own
|
||||
// separate step beforehand, not bundled into the macro's step.)
|
||||
s.undo();
|
||||
expect(s.tableById(id)).toBeDefined();
|
||||
expect(s.cellInput(id, 0, 0)).toBe('');
|
||||
expect(s.cellInput(id, 0, 1)).toBe('');
|
||||
} 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);
|
||||
@@ -1625,6 +1760,23 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
|
||||
const reimported = parseDocument(json);
|
||||
expect(reimported.macros).toEqual(s.doc.macros);
|
||||
});
|
||||
|
||||
it('JSON export/import round-trips approvedHosts too', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => 1 })));
|
||||
try {
|
||||
const s = fresh();
|
||||
const macroId = s.addMacro('M', 'r = GET("https://api.example.com/x")') as string;
|
||||
await s.runMacro(macroId);
|
||||
expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual(['api.example.com']);
|
||||
|
||||
const json = serializeDocument(s.doc);
|
||||
const reimported = parseDocument(json);
|
||||
expect(reimported.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual(['api.example.com']);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom functions: "block" kind (FOR/IF/RETURN "code")', () => {
|
||||
|
||||
+104
-13
@@ -48,6 +48,7 @@ import {
|
||||
type FunctionDef,
|
||||
type NamedRangeDef,
|
||||
type MacroWriteHost,
|
||||
type MacroNetworkHost,
|
||||
type EngineHost,
|
||||
} from '../engine';
|
||||
import {
|
||||
@@ -527,6 +528,18 @@ function createStore() {
|
||||
// below must not treat that as "a new edit" (which would immediately wipe
|
||||
// the very redoStack/undoStack entry undo()/redo() just pushed).
|
||||
let applyingHistory = false;
|
||||
// true for the duration of one runMacro() call (set/cleared there) -- a
|
||||
// macro with a GET/POST can genuinely take longer than HISTORY_COALESCE_MS
|
||||
// between two mutations (a real network round-trip), which would otherwise
|
||||
// let the debounced commit below fire *during* the run and split one
|
||||
// macro into two+ undo steps, silently breaking the documented "one
|
||||
// synchronous run = one undo step" guarantee (see README's "Makros"
|
||||
// section). While this is true, the watch below still tracks
|
||||
// historyPending/redoStack (so a bound Undo/Redo button stays accurate
|
||||
// mid-run) but never *schedules* commitHistoryStep -- runMacro() calls it
|
||||
// exactly once itself, in its `finally`, regardless of how long the
|
||||
// run's awaits took.
|
||||
let macroRunActive = false;
|
||||
|
||||
function commitHistoryStep() {
|
||||
clearTimeout(historyTimer);
|
||||
@@ -556,6 +569,7 @@ function createStore() {
|
||||
historyPending.value = dirty;
|
||||
if (!dirty) return; // pure selection/UI change, or settled back to a no-op
|
||||
clearTimeout(historyTimer);
|
||||
if (macroRunActive) return; // runMacro() commits exactly once itself, see its own comment above
|
||||
historyTimer = setTimeout(commitHistoryStep, HISTORY_COALESCE_MS);
|
||||
},
|
||||
// sync: historyPending/redoStack must be accurate the instant a mutation
|
||||
@@ -1006,6 +1020,12 @@ function createStore() {
|
||||
'DELETESHEET',
|
||||
]);
|
||||
|
||||
/** GET/POST/JSONGET (see evaluator.ts's `execStatementAsync`) -- reserved
|
||||
* against use as a macro-local variable name, same reasoning as
|
||||
* `MACRO_ACTION_NAMES`. Also used by `validateCustomFunction` to reject
|
||||
* these three names as unreachable outside a macro (see there). */
|
||||
const MACRO_NETWORK_NAMES = new Set(['GET', 'POST', 'JSONGET']);
|
||||
|
||||
/**
|
||||
* Full validation for add/update: name & parameter shape, no collision with
|
||||
* a built-in or another custom function, the body parses (as an
|
||||
@@ -1103,6 +1123,21 @@ function createStore() {
|
||||
ownCalls = collectCalledFunctionNames(ast);
|
||||
}
|
||||
|
||||
// GET/POST/JSONGET are never registered as built-ins (see runMacro's
|
||||
// MACRO_NETWORK_NAMES/execStatementAsync in evaluator.ts) -- calling
|
||||
// one here would already just become #NAME? at evaluation time, same
|
||||
// as any other unknown function. This check exists only to catch it
|
||||
// immediately with a clear message, mirroring how the 11 write actions
|
||||
// aren't separately validated here either (parser-level containment
|
||||
// already makes ActionNode unreachable from a function body) -- for
|
||||
// GET/POST/JSONGET there's no parser-level gate (they're ordinary
|
||||
// FuncNode calls), so a save-time name check is the cheap equivalent.
|
||||
for (const bad of MACRO_NETWORK_NAMES) {
|
||||
if (ownCalls.has(bad)) {
|
||||
return `"${bad}" ist nur in Makros erlaubt, nicht in benutzerdefinierten Funktionen.`;
|
||||
}
|
||||
}
|
||||
|
||||
const graph = new Map<string, Set<string>>();
|
||||
for (const f of doc.customFunctions) {
|
||||
if (f.id === ignoreId) continue;
|
||||
@@ -1280,6 +1315,9 @@ function createStore() {
|
||||
if (MACRO_ACTION_NAMES.has(n.toUpperCase())) {
|
||||
return `"${n}" ist der Name einer Makro-Aktion und kann nicht als Name verwendet werden.`;
|
||||
}
|
||||
if (MACRO_NETWORK_NAMES.has(n.toUpperCase())) {
|
||||
return `"${n}" ist der Name einer Netzwerk-Funktion und kann nicht als Name verwendet werden.`;
|
||||
}
|
||||
}
|
||||
const badFor = findInvalidForSource(block);
|
||||
if (badFor) {
|
||||
@@ -1340,20 +1378,25 @@ function createStore() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Runs a macro to completion (see store/types.ts's `Macro` doc comment
|
||||
* for the "why explicit trigger, not recalc-embedded" design). `async`
|
||||
* because a `GET`/`POST` genuinely waits on a real network round-trip --
|
||||
* that costs the automatic "one JS turn = one undo step" property a
|
||||
* fully synchronous run would otherwise get for free, so this function
|
||||
* restores it explicitly instead (`macroRunActive` suppresses the
|
||||
* debounced auto-commit for the run's whole duration; the `finally`
|
||||
* below commits exactly once, however long the run's `await`s took --
|
||||
* see `macroRunActive`'s own comment near the history watcher).
|
||||
*
|
||||
* 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.
|
||||
* All three hosts handed to the interpreter re-read the outer `wb`/`doc`
|
||||
* on every call rather than capturing anything 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 } {
|
||||
async function runMacro(macroId: string): Promise<{ ok: boolean; message: string }> {
|
||||
const macro = doc.macros.find((m) => m.id === macroId);
|
||||
if (!macro) return { ok: false, message: 'Makro nicht gefunden.' };
|
||||
|
||||
@@ -1435,16 +1478,64 @@ function createStore() {
|
||||
},
|
||||
};
|
||||
|
||||
// GET/POST confirmation + fetch. Per-MACRO approval (not per-document
|
||||
// and not per-session): an imported document with several small macros
|
||||
// quietly contacting the same host should not have approving one of
|
||||
// them silently authorize all the others too -- each macro earns its
|
||||
// own one-time human decision the first time IT reaches a given host,
|
||||
// persisted in `macro.approvedHosts` so it only ever asks once. The
|
||||
// host (via `new URL(url).host`, i.e. including a non-default port) is
|
||||
// recorded BEFORE the fetch itself, so the approval survives even if
|
||||
// this particular request later fails.
|
||||
const netHost: MacroNetworkHost = {
|
||||
async request(method, url, body) {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(url).host;
|
||||
} catch {
|
||||
throw new Error(`${method}: '${url}' ist keine gültige URL`);
|
||||
}
|
||||
if (!macro.approvedHosts?.includes(host)) {
|
||||
const verb = method === 'POST' ? 'eine POST-Anfrage an' : 'eine GET-Anfrage an';
|
||||
if (!confirm(`Makro "${macro.name}" möchte ${verb} "${host}" senden. Erlauben?`)) {
|
||||
throw new Error(`${method}: Verbindung zu '${host}' abgelehnt`);
|
||||
}
|
||||
(macro.approvedHosts ??= []).push(host);
|
||||
}
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`${method} ${url}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`${method} ${url}: HTTP ${res.status} ${res.statusText}`);
|
||||
}
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
throw new Error(`${method} ${url}: Antwort ist kein gültiges JSON`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
macroRunActive = true;
|
||||
try {
|
||||
const block = parseMacroBlock(macro.body);
|
||||
const index = buildFunctionIndex(toFunctionDefs(doc));
|
||||
const expanded = expandMacroBlock(block, index);
|
||||
const result = runMacroActions(expanded, macroHost, writeHost);
|
||||
const result = await runMacroActions(expanded, macroHost, writeHost, netHost);
|
||||
return { ok: true, message: typeof result === 'string' ? result : 'Makro ausgeführt.' };
|
||||
} catch (e) {
|
||||
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
||||
} finally {
|
||||
restoreView();
|
||||
macroRunActive = false;
|
||||
commitHistoryStep(); // exactly one step for everything mutated since the run started, any await duration
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-1
@@ -230,7 +230,11 @@ export interface NamedRange {
|
||||
* `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. */
|
||||
* documented v1 scope cuts.
|
||||
*
|
||||
* Also able to reach the network (`GET`/`POST`, `engine/types.ts`'s
|
||||
* `MacroNetworkHost`) -- a second trust dimension beyond read/write scope,
|
||||
* gated separately via `approvedHosts` below. */
|
||||
export interface Macro {
|
||||
id: string;
|
||||
/** document-unique, compared case-insensitively; never a built-in/custom
|
||||
@@ -242,6 +246,14 @@ export interface Macro {
|
||||
* inside must be qualified with a table name (`Umsatz::A1`, never bare
|
||||
* `A1`) -- a macro has no "home table", see `validateMacro`. */
|
||||
body: string;
|
||||
/** Hostnames (`host` from `new URL(...)`, i.e. including a non-default
|
||||
* port) this macro has already been confirmed to contact via `GET`/
|
||||
* `POST`, persisted so a human only approves each macro×host pair once,
|
||||
* ever -- not once per session, not once per document (a different
|
||||
* macro contacting an already-approved host still prompts separately;
|
||||
* see `document.ts#runMacro`'s doc comment for the reasoning). Absent/
|
||||
* empty for a macro that has never made a network call. */
|
||||
approvedHosts?: string[];
|
||||
}
|
||||
|
||||
/** One entry in the formula-autocomplete dropdown (see document.ts's
|
||||
|
||||
Reference in New Issue
Block a user