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:
wkh
2026-09-09 11:10:19 +02:00
co-authored by Claude Sonnet 5
parent a59fda25b0
commit 3ae3d81066
15 changed files with 1043 additions and 101 deletions
+104 -13
View File
@@ -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
}
}