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
+71 -1
View File
@@ -449,13 +449,59 @@ Schleifenvariable); alle anderen Argumente sind normale Ausdrücke.
normal (berechnet einen Wert), kann aber selbst nie schreiben — ihr normal (berechnet einen Wert), kann aber selbst nie schreiben — ihr
Funktionskörper wird immer ohne Schreib-Rechte geparst. Funktionskörper wird immer ohne Schreib-Rechte geparst.
**GET/POST + JSON-Ergebnis mit Iteration**: `r = GET(url)` und
`r = POST(url; body)` rufen eine externe Adresse auf und binden die
geparste JSON-Antwort an `r` — genau wie jede andere Zuweisung, aber die
einzige Stelle in der Sprache, die wirklich auf ein Netzwerk-Roundtrip
wartet. `FOR item IN r DO … END` (oder direkt `FOR item IN JSONGET(r;
"pfad") DO … END`) iteriert, sobald `r` ein JSON-**Array** ist, über dessen
Elemente — dieselbe `FOR`-Syntax wie bei einem Zell-/Bereichsbezug, nur
mit einer neuen Quellart. **`JSONGET(wert; pfad)`** liest ein Feld heraus
(`"a.b.0.c"` — punktgetrennt, eine Zahl indiziert ein Array, alles andere
einen Objekt-Schlüssel; ein fehlender Pfad ergibt JSON-`null`, kein
Fehler) — anders als `GET`/`POST` rein synchron und deshalb überall in
einem Ausdruck einsetzbar (`SETVALUE(Ziel; JSONGET(item; "name"))`,
verschachtelt in einer `IF`-Bedingung, …), nicht nur als ganze rechte
Seite einer Zuweisung. Ein JSON-Wert, direkt (ohne `JSONGET`) an einer
gewöhnlichen Werteposition verwendet, kollabiert automatisch: Text/Zahl
bleiben sich selbst, ein Bool wird `1`/`0`, ein Objekt/Array wird sein
JSON-Text — praktisch, um z. B. die rohe Antwort unverändert in eine Zelle
zu schreiben.
- **Verbindung wird pro Makro einmalig bestätigt.** Beim ersten Kontakt
eines Makros zu einem neuen Host (Domain **inkl. Port**) erscheint ein
Bestätigungsdialog mit Makro-Name und Ziel-Host; wird zugestimmt, ist
dieser Host für **dieses** Makro dauerhaft freigegeben (gespeichert,
Teil des JSON-Exports) — kein erneutes Nachfragen bei künftigen Läufen,
auch nicht in einer neuen Sitzung. Bewusst **pro Makro statt pro
Dokument**: ein importiertes Dokument mit mehreren kleinen Makros, die
denselben Host kontaktieren, soll nicht durch eine einzige Bestätigung
alle auf einmal freischalten — jedes Makro trifft seine eigene, einmalige
Entscheidung. Eine Ablehnung bricht das Makro ab, ohne den Host zu
speichern.
- **`GET`/`POST` sind nur als ganze rechte Seite einer Zuweisung gültig**
(`x = GET(url)`), nicht verschachtelt in einem Ausdruck — sie brauchen
einen echten `await`, den nur diese eine Stelle in der Sprache erlaubt.
Innerhalb einer normalen benutzerdefinierten Funktion sind `GET`/`POST`/
`JSONGET` von vornherein unerreichbar (Speicher-Zeit-Fehler mit klarer
Meldung) — Netzwerkzugriff bleibt strikt auf Makros beschränkt.
- **Nicht-2xx-Antwort oder ungültiges JSON** brechen den Lauf mit einer
klaren Fehlermeldung ab, wie jede andere Aktion.
**Bewusst ohne** (Stand jetzt): Makros haben keine Parameter (ein Klick **Bewusst ohne** (Stand jetzt): Makros haben keine Parameter (ein Klick
übergibt keine Argumente); mehrere Struktur-Aktionen in einer Schleife übergibt keine Argumente); mehrere Struktur-Aktionen in einer Schleife
lösen weiterhin je eine eigene Neuberechnung aus statt gebündelt einmal am lösen weiterhin je eine eigene Neuberechnung aus statt gebündelt einmal am
Ende (das kleinere Schrittbudget hält das Restrisiko klein, spätere Ende (das kleinere Schrittbudget hält das Restrisiko klein, spätere
Optimierung möglich); ein `TabellenRef`-String wird am *ersten* `!` Optimierung möglich); ein `TabellenRef`-String wird am *ersten* `!`
gesplittet (ein Tabellenname mit eigenem `!` darin ist so nicht gesplittet (ein Tabellenname mit eigenem `!` darin ist so nicht
adressierbar). adressierbar); `POST` hat keine eigenen Header/Authentifizierung (immer
`Content-Type: application/json`, der Body wird vom Makro-Autor selbst als
Text — z. B. per `&`-Verkettung — zusammengesetzt), `GET` hat keinen Body;
kein eigenes Timeout (folgt dem Browser-`fetch()`-Standard — ein
hängender Request blockiert den Makrolauf unbegrenzt); `FOR` über ein
JSON-*Objekt* (statt Array) ist ein klarer Fehler, keine implizite
„über die Werte/Einträge iterieren"-Zweitbedeutung; GET/POST teilen sich
das normale Schrittbudget (kein eigenes, kleineres Netzwerk-Kontingent).
### Zeilen & Spalten einfügen/löschen ### Zeilen & Spalten einfügen/löschen
@@ -1170,6 +1216,30 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
letzte Blatt als echten Fehlschlag statt stillem No-op, `ADDTABLE` auf ein letzte Blatt als echten Fehlschlag statt stillem No-op, `ADDTABLE` auf ein
*anderes* Blatt lässt die aktive Ansicht/Auswahl unangetastet (reiner *anderes* Blatt lässt die aktive Ansicht/Auswahl unangetastet (reiner
Seiteneffekt-Fix, live per Playwright gefunden), JSON-Round-Trip. Seiteneffekt-Fix, live per Playwright gefunden), JSON-Round-Trip.
- **Makros: GET/POST/JSONGET** (`macros.spec.ts`) — Ergebnis-Bindung +
Kollaps-Regel (Text/Zahl/Bool/Objekt/Array) gegen einen gemockten
`MacroNetworkHost`, `JSONGET`-Pfadauflösung (verschachtelt, Array-Index,
fehlender Pfad → `null`, falscher Werttyp → Fehler), `FOR` über ein
JSON-Array direkt **und** über einen `JSONGET(...)`-Aufruf direkt als
Quelle (letzteres ein live gefundener Lücke-Fix: `substitute()` allein
wertet einen Funktionsaufruf nie aus), `FOR` über ein JSON-Objekt/
Einzelwert → klarer Fehler, `GET`/`POST` außerhalb einer reinen Zuweisung
→ `#NAME?` (belegt die eindämmungsfreie Absicherung empirisch),
`JSONGET` dagegen funktioniert überall verschachtelt (rein synchron).
Store-Ebene (`document.spec.ts`): `GET`/`POST`/`JSONGET` in einer
benutzerdefinierten Funktion (Ausdruck **und** Block) abgelehnt;
`confirm()` gemockt — erster Lauf fragt und persistiert, zweiter Lauf
zum selben Host fragt nicht mehr, ein anderer Host fragt erneut, eine
Ablehnung bricht ab, ohne den Host zu speichern; nicht-2xx-Antwort
bricht sauber ab; **Pflichttest**: `SETVALUE` → ein absichtlich
langsames `GET` (300 ms, über der 150-ms-Undo-Koaleszenz) → `SETVALUE`
erzeugt trotzdem genau einen Undo-Schritt — beweist den
`macroRunActive`-Fix direkt; JSON-Round-Trip von `approvedHosts`.
Live per Playwright gegen eine echte same-origin-Fixture
(`public/test-fixtures/sample.json`) verifiziert: echter `GET`, echter
Bestätigungsdialog mit Makro-/Host-Namen, `FOR`/`JSONGET` über die
echte Antwort, kein erneuter Dialog beim zweiten Lauf, null
Konsolenfehler.
--- ---
+6
View File
@@ -0,0 +1,6 @@
{
"items": [
{ "name": "Nord", "value": 100 },
{ "name": "Süd", "value": 200 }
]
}
+22 -7
View File
@@ -7,7 +7,7 @@
* own right-click menu, for a quicker "build a button, wire it up, done" * own right-click menu, for a quicker "build a button, wire it up, done"
* loop without leaving the canvas. * loop without leaving the canvas.
*/ */
import { computed, reactive } from 'vue'; import { computed, reactive, ref } from 'vue';
import { useDocument } from '../store/document'; import { useDocument } from '../store/document';
import { useToasts } from '../composables/useToasts'; import { useToasts } from '../composables/useToasts';
import ContextMenu from './ContextMenu.vue'; import ContextMenu from './ContextMenu.vue';
@@ -21,16 +21,26 @@ const obj = computed(() => {
return o && o.kind === 'button' ? o : undefined; return o && o.kind === 'button' ? o : undefined;
}); });
function run() { // A macro can now involve a real network round-trip (GET/POST) and even a
// blocking confirm() prompt -- guard against a second click starting a
// second overlapping run while one is still in flight.
const running = ref(false);
async function run() {
const o = obj.value; const o = obj.value;
if (!o) return; if (!o || running.value) return;
if (!o.macroId || !store.doc.macros.some((m) => m.id === o.macroId)) { if (!o.macroId || !store.doc.macros.some((m) => m.id === o.macroId)) {
toasts.info('Diesem Button ist noch kein Makro zugewiesen — per Rechtsklick oder im Inspektor.'); toasts.info('Diesem Button ist noch kein Makro zugewiesen — per Rechtsklick oder im Inspektor.');
return; return;
} }
const result = store.runMacro(o.macroId); running.value = true;
if (result.ok) toasts.success(result.message); try {
else toasts.error(result.message); const result = await store.runMacro(o.macroId);
if (result.ok) toasts.success(result.message);
else toasts.error(result.message);
} finally {
running.value = false;
}
} }
const boxStyle = computed(() => { const boxStyle = computed(() => {
@@ -77,11 +87,12 @@ const menuItems = computed(() => {
<div <div
v-if="obj" v-if="obj"
class="btnbox" class="btnbox"
:class="{ running }"
:style="boxStyle" :style="boxStyle"
@click="run" @click="run"
@contextmenu="onContextMenu" @contextmenu="onContextMenu"
> >
<span class="label">{{ obj.label || 'Button' }}</span> <span class="label">{{ running ? '…' : obj.label || 'Button' }}</span>
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" /> <ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
</div> </div>
</template> </template>
@@ -100,6 +111,10 @@ const menuItems = computed(() => {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
} }
.btnbox.running {
opacity: 0.6;
cursor: default;
}
.btnbox:hover { .btnbox:hover {
filter: brightness(1.06); filter: brightness(1.06);
} }
+23 -5
View File
@@ -75,10 +75,21 @@ function removeMacro(id: string, name: string) {
} }
} }
function runMacro(id: string) { // A macro can now involve a real network round-trip (GET/POST) and even a
const result = store.runMacro(id); // blocking confirm() prompt -- guard against re-clicking "▶ Ausführen" on
if (result.ok) toasts.success(result.message); // the same row while its run is still in flight.
else toasts.error(result.message); const runningId = ref<string | null>(null);
async function runMacro(id: string) {
if (runningId.value) return;
runningId.value = id;
try {
const result = await store.runMacro(id);
if (result.ok) toasts.success(result.message);
else toasts.error(result.message);
} finally {
runningId.value = null;
}
} }
function close() { function close() {
@@ -102,7 +113,14 @@ function close() {
<li v-for="m in store.doc.macros" :key="m.id" class="fn-row"> <li v-for="m in store.doc.macros" :key="m.id" class="fn-row">
<code class="fn-sig">{{ m.name }}</code> <code class="fn-sig">{{ m.name }}</code>
<span class="fn-actions"> <span class="fn-actions">
<button class="mini run" title="Ausführen" @click="runMacro(m.id)">▶ Ausführen</button> <button
class="mini run"
title="Ausführen"
:disabled="runningId === m.id"
@click="runMacro(m.id)"
>
{{ runningId === m.id ? '… läuft' : '▶ Ausführen' }}
</button>
<button class="mini" @click="openEdit(m.id)">Bearb.</button> <button class="mini" @click="openEdit(m.id)">Bearb.</button>
<button class="mini danger" title="Löschen" @click="removeMacro(m.id, m.name)">🗑</button> <button class="mini danger" title="Löschen" @click="removeMacro(m.id, m.name)">🗑</button>
</span> </span>
+267 -38
View File
@@ -2,7 +2,14 @@ import { describe, it, expect } from 'vitest';
import { parseMacroBlock } from '../parser'; import { parseMacroBlock } from '../parser';
import { runMacroActions } from '../evaluator'; import { runMacroActions } from '../evaluator';
import { buildFunctionIndex, expandMacroBlock, type FunctionDef } from '../userFunctions'; import { buildFunctionIndex, expandMacroBlock, type FunctionDef } from '../userFunctions';
import { FormulaError, type CellRef, type CellValue, type EngineHost, type MacroWriteHost } from '../types'; import {
FormulaError,
type CellRef,
type CellValue,
type EngineHost,
type MacroNetworkHost,
type MacroWriteHost,
} from '../types';
type NamedRangeShape = { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } }; type NamedRangeShape = { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } };
@@ -60,19 +67,47 @@ function makeWriteHost(): { host: MacroWriteHost; calls: RecordedCall[] } {
return { host, calls }; return { host, calls };
} }
const runMacro = (src: string, host: EngineHost, writeHost: MacroWriteHost) => /** A mock `MacroNetworkHost` -- `responses` maps "METHOD url" to either a
runMacroActions(parseMacroBlock(src), host, writeHost); * value to resolve with (wrapped as `{ok:true, value}`) or an Error to
* reject with (`{ok:false, error}`); an un-mapped request rejects with a
* loud "no mock for this request" error so a test that forgets to mock a
* call fails clearly instead of hanging. */
function makeNetHost(
responses: Record<string, { ok: true; value: unknown } | { ok: false; error: Error }>,
): { host: MacroNetworkHost; calls: { method: string; url: string; body?: string }[] } {
const calls: { method: string; url: string; body?: string }[] = [];
const host: MacroNetworkHost = {
async request(method, url, body) {
calls.push({ method, url, body });
const key = `${method} ${url}`;
const mocked = responses[key];
if (!mocked) throw new Error(`no mock for ${key}`);
if (!mocked.ok) throw mocked.error;
return mocked.value;
},
};
return { host, calls };
}
const emptyNetHost: MacroNetworkHost = { request: () => Promise.reject(new Error('no network calls expected')) };
const runMacro = (
src: string,
host: EngineHost,
writeHost: MacroWriteHost,
netHost: MacroNetworkHost = emptyNetHost,
) => runMacroActions(parseMacroBlock(src), host, writeHost, netHost);
describe('runMacroActions -- SETVALUE target resolution', () => { describe('runMacroActions -- SETVALUE target resolution', () => {
it('a literal cell target writes exactly one cell', () => { it('a literal cell target writes exactly one cell', async () => {
const { host: writeHost, calls } = makeWriteHost(); const { host: writeHost, calls } = makeWriteHost();
runMacro('SETVALUE(Umsatz::B2; 42)', makeHost({}), writeHost); await runMacro('SETVALUE(Umsatz::B2; 42)', makeHost({}), writeHost);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 1, row: 1 }], '42'] }]); expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 1, row: 1 }], '42'] }]);
}); });
it('a range target broadcasts the same value to every cell in one call', () => { it('a range target broadcasts the same value to every cell in one call', async () => {
const { host: writeHost, calls } = makeWriteHost(); const { host: writeHost, calls } = makeWriteHost();
runMacro('SETVALUE(Umsatz::A1:A3; "x")', makeHost({}), writeHost); await runMacro('SETVALUE(Umsatz::A1:A3; "x")', makeHost({}), writeHost);
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
const [cells, text] = calls[0].args as [CellRef[], string]; const [cells, text] = calls[0].args as [CellRef[], string];
expect(cells).toEqual([ expect(cells).toEqual([
@@ -83,59 +118,59 @@ describe('runMacroActions -- SETVALUE target resolution', () => {
expect(text).toBe('x'); expect(text).toBe('x');
}); });
it('a target resolved through a FOR loop variable (substitute() through env)', () => { it('a target resolved through a FOR loop variable (substitute() through env)', async () => {
const { host: writeHost, calls } = makeWriteHost(); const { host: writeHost, calls } = makeWriteHost();
runMacro('FOR c IN Umsatz::A1:A2 DO SETVALUE(c; 0) END', makeHost({}), writeHost); await runMacro('FOR c IN Umsatz::A1:A2 DO SETVALUE(c; 0) END', makeHost({}), writeHost);
expect(calls).toEqual([ expect(calls).toEqual([
{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '0'] }, { op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '0'] },
{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 1 }], '0'] }, { op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 1 }], '0'] },
]); ]);
}); });
it('a value that starts with "=" is stringified as-is -- the store applies it as a live formula, no special-casing needed here', () => { it('a value that starts with "=" is stringified as-is -- the store applies it as a live formula, no special-casing needed here', async () => {
const { host: writeHost, calls } = makeWriteHost(); const { host: writeHost, calls } = makeWriteHost();
runMacro('SETVALUE(Umsatz::A1; "=SUM(" & "A2:A3" & ")")', makeHost({}), writeHost); await runMacro('SETVALUE(Umsatz::A1; "=SUM(" & "A2:A3" & ")")', makeHost({}), writeHost);
expect(calls[0].args[1]).toBe('=SUM(A2:A3)'); expect(calls[0].args[1]).toBe('=SUM(A2:A3)');
}); });
it('rejects an unqualified target at runtime (the static save-time check in document.ts is the primary guard; this is the interpreter\'s own defensive backstop)', () => { it('rejects an unqualified target at runtime (the static save-time check in document.ts is the primary guard; this is the interpreter\'s own defensive backstop)', async () => {
const { host: writeHost } = makeWriteHost(); const { host: writeHost } = makeWriteHost();
expect(() => runMacro('SETVALUE(A1; 1)', makeHost({}), writeHost)).toThrow(/Tabelle nennen/); await expect(runMacro('SETVALUE(A1; 1)', makeHost({}), writeHost)).rejects.toThrow(/Tabelle nennen/);
}); });
it('rejects a computed (non-reference) target', () => { it('rejects a computed (non-reference) target', async () => {
const { host: writeHost } = makeWriteHost(); const { host: writeHost } = makeWriteHost();
expect(() => runMacro('SETVALUE(1+1; 1)', makeHost({}), writeHost)).toThrow(/Zell- oder Bereichsbezug/); await expect(runMacro('SETVALUE(1+1; 1)', makeHost({}), writeHost)).rejects.toThrow(/Zell- oder Bereichsbezug/);
}); });
it('rejects an unknown table name', () => { it('rejects an unknown table name', async () => {
const { host: writeHost } = makeWriteHost(); const { host: writeHost } = makeWriteHost();
expect(() => runMacro('SETVALUE(Nirgendwo::A1; 1)', makeHost({}), writeHost)).toThrow(/unbekannte Tabelle/); await expect(runMacro('SETVALUE(Nirgendwo::A1; 1)', makeHost({}), writeHost)).rejects.toThrow(/unbekannte Tabelle/);
}); });
}); });
describe('runMacroActions -- RETURN / fall-through', () => { describe('runMacroActions -- RETURN / fall-through', () => {
it('RETURN short-circuits and its value is returned', () => { it('RETURN short-circuits and its value is returned', async () => {
const { host: writeHost } = makeWriteHost(); const { host: writeHost } = makeWriteHost();
expect(runMacro('SETVALUE(Umsatz::A1; 1) RETURN "fertig"', makeHost({}), writeHost)).toBe('fertig'); await expect(runMacro('SETVALUE(Umsatz::A1; 1) RETURN "fertig"', makeHost({}), writeHost)).resolves.toBe('fertig');
}); });
it('a RETURN inside an IF stops the remaining statements from running', () => { it('a RETURN inside an IF stops the remaining statements from running', async () => {
const { host: writeHost, calls } = makeWriteHost(); const { host: writeHost, calls } = makeWriteHost();
runMacro('IF 1 = 1 THEN RETURN "abgebrochen" END SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost); await runMacro('IF 1 = 1 THEN RETURN "abgebrochen" END SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost);
expect(calls).toHaveLength(0); expect(calls).toHaveLength(0);
}); });
it('falling off the end without RETURN yields null, not an error (unlike an ordinary block-kind custom function)', () => { it('falling off the end without RETURN yields null, not an error (unlike an ordinary block-kind custom function)', async () => {
const { host: writeHost } = makeWriteHost(); const { host: writeHost } = makeWriteHost();
expect(runMacro('SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost)).toBeNull(); await expect(runMacro('SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost)).resolves.toBeNull();
}); });
}); });
describe('runMacroActions -- the full action vocabulary dispatches correctly', () => { describe('runMacroActions -- the full action vocabulary dispatches correctly', () => {
it('every non-SETVALUE action evaluates its (string/number) args and calls the matching writeHost method', () => { it('every non-SETVALUE action evaluates its (string/number) args and calls the matching writeHost method', async () => {
const { host: writeHost, calls } = makeWriteHost(); const { host: writeHost, calls } = makeWriteHost();
runMacro( await runMacro(
[ [
'INSERTROW("Umsatz"; 3)', 'INSERTROW("Umsatz"; 3)',
'DELETEROW("Umsatz"; 4)', 'DELETEROW("Umsatz"; 4)',
@@ -165,44 +200,52 @@ describe('runMacroActions -- the full action vocabulary dispatches correctly', (
]); ]);
}); });
it('a string argument built via & concatenation and a local variable both work, since every non-target argument goes through the ordinary expression pipeline', () => { it('a string argument built via & concatenation and a local variable both work, since every non-target argument goes through the ordinary expression pipeline', async () => {
const { host: writeHost, calls } = makeWriteHost(); const { host: writeHost, calls } = makeWriteHost();
runMacro('n = "Kopie" ADDTABLE("Blatt1"; "Neu " & n)', makeHost({}), writeHost); await runMacro('n = "Kopie" ADDTABLE("Blatt1"; "Neu " & n)', makeHost({}), writeHost);
expect(calls).toEqual([{ op: 'addTable', args: ['Blatt1', 'Neu Kopie'] }]); expect(calls).toEqual([{ op: 'addTable', args: ['Blatt1', 'Neu Kopie'] }]);
}); });
it('an unknown action name throws', () => { it('an unknown action name throws', async () => {
const { host: writeHost } = makeWriteHost(); const { host: writeHost } = makeWriteHost();
expect(() => runMacro('FOOBAR(1)', makeHost({}), writeHost)).toThrow(/Unbekannte Aktion/); await expect(runMacro('FOOBAR(1)', makeHost({}), writeHost)).rejects.toThrow(/Unbekannte Aktion/);
}); });
it('a wrong argument count throws', () => { it('a wrong argument count throws', async () => {
const { host: writeHost } = makeWriteHost(); const { host: writeHost } = makeWriteHost();
expect(() => runMacro('ADDSHEET("x"; "y")', makeHost({}), writeHost)).toThrow(/erwartet 1 Argument/); await expect(runMacro('ADDSHEET("x"; "y")', makeHost({}), writeHost)).rejects.toThrow(/erwartet 1 Argument/);
}); });
}); });
describe('runMacroActions -- macro-specific step budget', () => { describe('runMacroActions -- macro-specific step budget', () => {
it('a loop that executes many structural actions is stopped by the (smaller) macro budget, not the ordinary 10,000-step FOR budget', () => { it('a loop that executes many structural actions is stopped by the (smaller) macro budget, not the ordinary 10,000-step FOR budget', async () => {
const { host: writeHost } = makeWriteHost(); const { host: writeHost } = makeWriteHost();
// 2,000 iterations, each one action call -- exceeds MAX_MACRO_STEPS // 2,000 iterations, each one action call -- exceeds MAX_MACRO_STEPS
// (1,000) but would be nowhere near the ordinary MAX_LOOP_STEPS // (1,000) but would be nowhere near the ordinary MAX_LOOP_STEPS
// (10,000) an ordinary block-kind function loop gets. // (10,000) an ordinary block-kind function loop gets.
expect(() => await expect(
runMacro('FOR c IN Umsatz::A1:A2000 DO INSERTROW("Umsatz"; 1) END', makeHost({}), writeHost), runMacro('FOR c IN Umsatz::A1:A2000 DO INSERTROW("Umsatz"; 1) END', makeHost({}), writeHost),
).toThrow(/zu viele Schritte|zu viele Zellen/); ).rejects.toThrow(/zu viele Schritte|zu viele Zellen/);
});
it('sequential GET calls in a loop also count toward the budget', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: 1 } });
await expect(
runMacro('FOR c IN Umsatz::A1:A2000 DO r = GET("https://x") END', makeHost({}), writeHost, netHost),
).rejects.toThrow(/zu viele Schritte|zu viele Zellen/);
}); });
}); });
describe('runMacroActions -- nested custom-function calls inside a macro expression', () => { describe('runMacroActions -- nested custom-function calls inside a macro expression', () => {
it('a SETVALUE value can call a custom function, resolved via expandMacroBlock before running', () => { it('a SETVALUE value can call a custom function, resolved via expandMacroBlock before running', async () => {
const { host: writeHost, calls } = makeWriteHost(); const { host: writeHost, calls } = makeWriteHost();
const defs: FunctionDef[] = [ const defs: FunctionDef[] = [
{ id: 'fn-1', name: 'MARGE', params: ['a', 'b'], body: 'a - b', kind: 'expression' }, { id: 'fn-1', name: 'MARGE', params: ['a', 'b'], body: 'a - b', kind: 'expression' },
]; ];
const index = buildFunctionIndex(defs); const index = buildFunctionIndex(defs);
const block = expandMacroBlock(parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3))'), index); const block = expandMacroBlock(parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3))'), index);
runMacroActions(block, makeHost({}), writeHost); await runMacroActions(block, makeHost({}), writeHost, emptyNetHost);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '7'] }]); expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '7'] }]);
}); });
@@ -218,10 +261,196 @@ describe('runMacroActions -- nested custom-function calls inside a macro express
}); });
describe('runMacroActions -- error propagation', () => { describe('runMacroActions -- error propagation', () => {
it('an error value in a value expression aborts with a clear message, not a silently-written "#ERR" text', () => { it('an error value in a value expression aborts with a clear message, not a silently-written "#ERR" text', async () => {
const { host: writeHost } = makeWriteHost(); const { host: writeHost } = makeWriteHost();
expect(() => runMacro('SETVALUE(Umsatz::A1; 1/0)', makeHost({}), writeHost)).toThrow( await expect(runMacro('SETVALUE(Umsatz::A1; 1/0)', makeHost({}), writeHost)).rejects.toThrow(
new FormulaError('#DIV/0!').code, new FormulaError('#DIV/0!').code,
); );
}); });
}); });
describe('runMacroActions -- GET/POST', () => {
it('GET binds the parsed JSON response, usable as a subsequent SETVALUE value', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost, calls: netCalls } = makeNetHost({
'GET https://api/kunde': { ok: true, value: { name: 'Nord' } },
});
await runMacro('r = GET("https://api/kunde") SETVALUE(Umsatz::A1; JSONGET(r; "name"))', makeHost({}), writeHost, netHost);
expect(netCalls).toEqual([{ method: 'GET', url: 'https://api/kunde', body: undefined }]);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], 'Nord'] }]);
});
it('POST sends a body and binds the parsed JSON response', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost, calls: netCalls } = makeNetHost({
'POST https://api/kunden': { ok: true, value: { id: 42 } },
});
await runMacro(
'r = POST("https://api/kunden"; "{\\"name\\":\\"Nord\\"}") SETVALUE(Umsatz::A1; JSONGET(r; "id"))',
makeHost({}),
writeHost,
netHost,
);
expect(netCalls).toEqual([{ method: 'POST', url: 'https://api/kunden', body: '{"name":"Nord"}' }]);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '42'] }]);
});
it('GET/POST used anywhere other than the whole RHS of a plain assignment fall through to the ordinary path and become #NAME? -- they need a real await, so (unlike JSONGET below) they cannot be embedded in an arbitrary expression', async () => {
const { host: writeHost } = makeWriteHost();
// nested inside an expression -- SETVALUE's error wrapping only
// surfaces the FormulaError *code*, not its detail text, so #NAME?
// (not the literal words "unknown function") is what's actually visible
await expect(
runMacro('SETVALUE(Umsatz::A1; 1 + GET("https://x"))', makeHost({}), writeHost),
).rejects.toThrow(/NAME/);
// inside an IF condition -- an error in a condition resolves the whole
// macro run with that FormulaError as its "value" (same as any other
// ifstmt-condition error, existing behaviour predating this feature),
// it does not reject the promise
const result = await runMacro('IF GET("https://x") = 1 THEN SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost);
expect(result).toEqual(new FormulaError('#NAME?', 'unknown function GET'));
});
it('JSONGET, unlike GET/POST, is purely synchronous and works nested anywhere an expression can appear (evalScalar special-case, not just the assignment-RHS form)', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: { name: 'Nord' } } });
await runMacro(
'r = GET("https://x") IF JSONGET(r; "name") = "Nord" THEN SETVALUE(Umsatz::A1; JSONGET(r; "name")) END',
makeHost({}),
writeHost,
netHost,
);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], 'Nord'] }]);
});
it('a network failure aborts the run cleanly with the failure message', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://down': { ok: false, error: new Error('GET https://down: HTTP 500 Internal Server Error') },
});
await expect(runMacro('r = GET("https://down")', makeHost({}), writeHost, netHost)).rejects.toThrow(/HTTP 500/);
});
it('a declined confirmation (surfaced by the host as a rejection) aborts without writing anything', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://x': { ok: false, error: new Error("GET: Verbindung zu 'x' abgelehnt") },
});
await expect(
runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost, netHost),
).rejects.toThrow(/abgelehnt/);
expect(calls).toHaveLength(0);
});
});
describe('runMacroActions -- JSON value collapse rule (evalScalar\'s case "json")', () => {
const cases: Array<[unknown, CellValue]> = [
[null, null],
['Nord', 'Nord'],
[42, 42],
[true, 1],
[false, 0],
[{ a: 1 }, '{"a":1}'],
[[1, 2], '[1,2]'],
];
for (const [value, expected] of cases) {
it(`a JSON ${JSON.stringify(value)} used directly as a SETVALUE value collapses to ${JSON.stringify(expected)}`, async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; r)', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe(String(expected ?? ''));
});
}
});
describe('runMacroActions -- JSONGET path resolution', () => {
const data = { items: [{ name: 'Nord', tags: ['a', 'b'] }, { name: 'Süd' }], meta: { count: 2 } };
it('resolves a nested object path', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "meta.count"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe('2');
});
it('resolves an array index within the path', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.0.name"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe('Nord');
});
it('a nested array-of-strings path resolves too', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.0.tags.1"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe('b');
});
it('a missing path resolves to JSON null, not a throw', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.9.name"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe(''); // null collapses to '' via toText(), same as an empty cell
});
it('JSONGET on a non-JSON value throws a clear error when used as an assignment RHS', async () => {
const { host: writeHost } = makeWriteHost();
await expect(
runMacro('x = 5 y = JSONGET(x; "a")', makeHost({}), writeHost),
).rejects.toThrow(/JSON-Ergebnis/);
});
it('JSONGET on a non-JSON value nested in an expression degrades to a formula error (#VALUE!), same as any other evalScalar function misuse', async () => {
const { host: writeHost } = makeWriteHost();
await expect(
runMacro('x = 5 SETVALUE(Umsatz::A1; JSONGET(x; "a"))', makeHost({}), writeHost),
).rejects.toThrow(/VALUE/);
});
});
describe('runMacroActions -- FOR over a JSON array', () => {
it('iterates each element, usable via JSONGET', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://x': { ok: true, value: [{ name: 'Nord' }, { name: 'Süd' }] },
});
await runMacro(
'r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; JSONGET(item; "name")) END',
makeHost({}),
writeHost,
netHost,
);
expect(calls.map((c) => c.args[1])).toEqual(['Nord', 'Süd']);
});
it('FOR over a JSON object (not an array) throws a clear error', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: { a: 1 } } });
await expect(
runMacro('r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost, netHost),
).rejects.toThrow(/Array/);
});
it('FOR over a JSON primitive throws the same clear error', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: 5 } });
await expect(
runMacro('r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost, netHost),
).rejects.toThrow(/Array/);
});
it('a JSONGET(...) call used directly as the FOR source (not first bound to a variable) parses and runs -- found live: the shared FOR-source shape check did not originally know about this new source kind', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://x': { ok: true, value: { items: [{ name: 'Nord' }, { name: 'Süd' }] } },
});
await runMacro(
'r = GET("https://x") FOR item IN JSONGET(r; "items") DO SETVALUE(Umsatz::A1; JSONGET(item; "name")) END',
makeHost({}),
writeHost,
netHost,
);
expect(calls.map((c) => c.args[1])).toEqual(['Nord', 'Süd']);
});
});
+4
View File
@@ -40,6 +40,10 @@ function sexpr(n: Node): string {
return `blockcall(${n.label})`; return `blockcall(${n.label})`;
case 'omitted': case 'omitted':
return 'omitted'; return 'omitted';
case 'json':
// never produced by the parser (see parser.ts's comment on
// JsonLiteralNode) -- exhaustiveness only, no test exercises this.
return `json(${JSON.stringify(n.value)})`;
} }
} }
const parse = (s: string) => sexpr(parseFormula(s)); const parse = (s: string) => sexpr(parseFormula(s));
+294 -7
View File
@@ -39,11 +39,19 @@
* ordinary function call this can't be resolved any earlier. * ordinary function call this can't be resolved any earlier.
*/ */
import type { ActionNode, BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser'; import type { ActionNode, BlockCallNode, BlockNode, CellNode, FuncNode, JsonLiteralNode, NamedRefNode, Node, RangeNode, Statement } from './parser';
import { getFunction, isTruthy } from './functions'; import { getFunction, isTruthy } from './functions';
import { expandRange } from './refs'; import { expandRange } from './refs';
import { substitute } from './userFunctions'; import { substitute } from './userFunctions';
import { FormulaError, isError, type CellRef, type CellValue, type EngineHost, type MacroWriteHost } from './types'; import {
FormulaError,
isError,
type CellRef,
type CellValue,
type EngineHost,
type MacroNetworkHost,
type MacroWriteHost,
} from './types';
export function evaluate(ast: Node, host: EngineHost): CellValue { export function evaluate(ast: Node, host: EngineHost): CellValue {
return evalScalar(ast, host); return evalScalar(ast, host);
@@ -135,6 +143,24 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
// argument evaluated lazily -- both require handling here, before the // argument evaluated lazily -- both require handling here, before the
// generic eager/flattening dispatch below. // generic eager/flattening dispatch below.
if (upperName === 'XLOOKUP') return evalXLookup(node, host); if (upperName === 'XLOOKUP') return evalXLookup(node, host);
// JSONGET needs its first argument RAW (a JsonLiteralNode, never
// reducible to a plain CellValue first) -- special-cased here for the
// same reason as IF/XLOOKUP. Unlike GET/POST (see execStatementAsync
// in the "async macro interpreter" section below), JSONGET is pure
// computation with no `await` anywhere, so it can live in this
// ordinary, fully-synchronous dispatch and therefore compose in ANY
// expression position (nested in SETVALUE's value, an IF condition,
// ...) -- not just as the whole RHS of a macro assignment. By the
// time evalScalar ever sees `node.args[0]`, evalInEnv has already run
// substitute() against the current env, so a param/loop-variable
// bound to a JsonLiteralNode already appears here as that literal
// node directly, no env access needed. Always collapses its result to
// a plain CellValue (evalScalar's return type leaves no other
// choice) -- a macro that wants to keep drilling into/iterating a
// JSON *structure* instead uses JSONGET as a whole assignment RHS
// (`x = JSONGET(r; "items")`), which the async interpreter's
// `evalSpecialAssign` intercepts before ever reaching here.
if (upperName === 'JSONGET') return evalJsonGet(node, host);
const fn = getFunction(node.name); const fn = getFunction(node.name);
if (!fn) return new FormulaError('#NAME?', `unknown function ${node.name}`); if (!fn) return new FormulaError('#NAME?', `unknown function ${node.name}`);
@@ -167,9 +193,55 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
// "evaluates to null" checks the raw arg Node's kind directly before // "evaluates to null" checks the raw arg Node's kind directly before
// ever calling evalScalar on it (see evalXLookup below). // ever calling evalScalar on it (see evalXLookup below).
return null; return null;
case 'json':
// A JSON value (a GET/POST result, or a JSONGET/FOR-over-array
// element) used in ordinary scalar position -- e.g. `SETVALUE(x;
// result)` without first drilling into it via JSONGET. Collapses to
// the best plain CellValue: a primitive keeps its own type/value
// (boolean -> 1/0, matching this engine's existing convention that
// comparisons already return 1/0 rather than a real boolean type);
// an object/array has no better scalar form than its JSON text.
return jsonValueToCellValue(node.value);
} }
} }
/** {@link evalScalar}'s `case 'json'` collapse rule -- a plain JSON
* primitive becomes the matching `CellValue`; an object/array has no
* better scalar form than its JSON text. Factored out as its own
* function so there is exactly one definition of this rule. */
function jsonValueToCellValue(value: unknown): CellValue {
if (value === null || value === undefined) return null;
if (typeof value === 'number') return value;
if (typeof value === 'string') return value;
if (typeof value === 'boolean') return value ? 1 : 0;
return JSON.stringify(value);
}
/** `JSONGET(value; path)` used nested in an ordinary expression (see the
* `case 'func'` special-case above) -- `value` must already be a
* `JsonLiteralNode` by the time it reaches here (produced earlier in the
* same macro by `GET`/`POST`/another `JSONGET`/a `FOR`-over-array
* iteration); anything else is a `#VALUE!`, the same error class any
* other function-argument type mismatch gets in this engine. `path`
* resolves through the ordinary `evalScalar`, so it can itself be a
* computed/concatenated string. Uses `jsonGetPath` (defined alongside the
* async macro interpreter below, since that is `jsonGetPath`'s only other
* caller) -- a missing path resolves to JSON `null`, not an error, see
* its own doc comment. */
function evalJsonGet(node: FuncNode, host: EngineHost): CellValue {
if (node.args.length !== 2) return new FormulaError('#ERR', 'JSONGET expects 2 arguments');
const target = node.args[0];
if (target.kind !== 'json') {
return new FormulaError('#VALUE!', 'JSONGET: first argument is not a JSON result');
}
const path = evalScalar(node.args[1], host);
if (isError(path)) return path;
const pathText = toText(path);
if (isError(pathText)) return pathText;
return jsonValueToCellValue(jsonGetPath(target.value, pathText));
}
/** IF(cond; then; else?) -- the real, short-circuiting implementation (see /** IF(cond; then; else?) -- the real, short-circuiting implementation (see
* the `case 'func'` special-case above and functions.ts's FUNCTIONS.IF * the `case 'func'` special-case above and functions.ts's FUNCTIONS.IF
* comment). Only `node.args[0]` is always evaluated; whichever of the * comment). Only `node.args[0]` is always evaluated; whichever of the
@@ -375,19 +447,40 @@ function evaluateBlockCall(node: BlockCallNode, host: EngineHost): CellValue {
* `evaluateBlockCall`, no RETURN is required: falling off the end simply * `evaluateBlockCall`, no RETURN is required: falling off the end simply
* means "done", returning `null` (a macro has no caller expecting a value; * means "done", returning `null` (a macro has no caller expecting a value;
* RETURN is still usable for an early exit / a status message, see the * RETURN is still usable for an early exit / a status message, see the
* `'return'` case in `execStatement`). `writeHost` is what makes the new * `'return'` case in `execStatementAsync`). `writeHost` is what makes the
* `'action'` statement kind (SETVALUE/INSERTROW/...) possible -- see its * `'action'` statement kind (SETVALUE/INSERTROW/...) possible; `netHost` is
* doc comment in types.ts. `host` MUST be a proxy that re-resolves against * what makes the `GET`/`POST` assignment form possible -- see their doc
* comments in types.ts. `host` MUST be a proxy that re-resolves against
* the live engine on every call, never a cached reference: a structural * the live engine on every call, never a cached reference: a structural
* action mid-run (INSERTROW, ADDTABLE, ...) replaces the underlying engine * action mid-run (INSERTROW, ADDTABLE, ...) replaces the underlying engine
* instance wholesale (see store/document.ts's `syncWorkbook`), and a cached * instance wholesale (see store/document.ts's `syncWorkbook`), and a cached
* reference would silently go stale for every read after that point. * reference would silently go stale for every read after that point.
*
* `async` because `GET`/`POST` genuinely need to wait on a real network
* round-trip -- but this is a SEPARATE interpreter (`execBlockAsync`/
* `execStatementAsync`, below) from the synchronous `execBlock`/
* `execStatement` that `evaluateBlockCall` (ordinary `kind:'block'` custom
* functions) still uses. That path is reachable from deep inside the
* fully-synchronous recalculation pass (`evalScalar`'s `case 'blockcall'`,
* itself called throughout `workbook.ts`) and must never become async --
* so rather than convert the shared interpreter and risk that cascade, the
* two statement-kind switches are two separate (structurally near-
* identical) functions. Everything they call beneath the switch itself
* (`evalInEnv`, `resolveWriteTarget`, `rangeOrCellToCellNodes`, `execAction`)
* is pure/synchronous and fully shared -- only the outer dispatch loop and
* the `'assign'`/`'for'` cases (which need to recognize a network/JSON
* call, or a JSON-array source) are duplicated.
*/ */
export function runMacroActions(block: BlockNode, host: EngineHost, writeHost: MacroWriteHost): CellValue | null { export async function runMacroActions(
block: BlockNode,
host: EngineHost,
writeHost: MacroWriteHost,
netHost: MacroNetworkHost,
): Promise<CellValue | null> {
const env = new Map<string, Node>(); const env = new Map<string, Node>();
const budget: Budget = { steps: 0, limit: MAX_MACRO_STEPS }; const budget: Budget = { steps: 0, limit: MAX_MACRO_STEPS };
try { try {
execBlock(block, env, host, budget, writeHost); await execBlockAsync(block, env, host, budget, writeHost, netHost);
} catch (e) { } catch (e) {
if (e instanceof ReturnSignal) return e.value; if (e instanceof ReturnSignal) return e.value;
throw e; throw e;
@@ -480,6 +573,200 @@ function execStatement(
} }
} }
// ---------------------------------------------------------------------------
// Async macro interpreter -- ONLY reachable via `runMacroActions`, never via
// `evaluateBlockCall` (see that function's own doc comment for why the sync
// interpreter above must stay untouched). Structurally mirrors
// `execBlock`/`execStatement` case-for-case; the only real differences are
// the `'assign'` case's GET/POST/JSONGET special-casing and the `'for'`
// case's JSON-array branch. Everything else delegates to the exact same
// synchronous helpers (`evalInEnv`, `resolveWriteTarget`,
// `rangeOrCellToCellNodes`, `execAction`) the sync interpreter already uses.
// ---------------------------------------------------------------------------
const NETWORK_OR_JSON_NAMES = new Set(['GET', 'POST', 'JSONGET']);
/** True iff `node` is exactly `NAME(args)` for one of GET/POST/JSONGET --
* the only shape `execStatementAsync`'s `'assign'` case treats specially.
* Used anywhere else (nested in an expression, an IF condition, a
* function argument) these names fall through to the ordinary, unchanged
* `evalScalar` -> `getFunction()` path and become `#NAME?`, since they are
* deliberately never registered in functions.ts's builtin table -- the
* same containment mechanism the codebase already relies on for the 11
* write actions. */
function isNetworkOrJsonCall(node: Node): node is FuncNode {
return node.kind === 'func' && NETWORK_OR_JSON_NAMES.has(node.name.toUpperCase());
}
/** Resolve a dot-separated path through a JSON value -- a numeric segment
* indexes an array, any other segment indexes an object key. A missing
* path (wrong type at some segment, out-of-range index, unknown key)
* resolves to JSON `null` rather than throwing, so a macro can safely
* probe "does this field exist" via a subsequent comparison/IF instead of
* needing its own existence-check syntax. */
function jsonGetPath(value: unknown, path: string): unknown {
let cur = value;
const trimmed = path.trim();
if (trimmed === '') return cur;
for (const segment of trimmed.split('.')) {
if (cur === null || cur === undefined) return null;
if (Array.isArray(cur)) {
const idx = Number(segment);
if (!Number.isInteger(idx) || idx < 0 || idx >= cur.length) return null;
cur = cur[idx];
} else if (typeof cur === 'object') {
cur = (cur as Record<string, unknown>)[segment] ?? null;
} else {
return null; // trying to drill into a string/number/boolean
}
}
return cur;
}
/** Resolves a `JSONGET(value; path)` call to the `JsonLiteralNode` it
* produces -- WITHOUT collapsing it to a plain `CellValue` (unlike
* `evalJsonGet` above, used when JSONGET is nested in ordinary expression
* position). Two callers need this uncollapsed form: `evalSpecialAssign`
* (`x = JSONGET(...)`, preserving structure for further drilling/
* iteration) and `execStatementAsync`'s `'for'` case (`FOR item IN
* JSONGET(r; "items") DO ...` -- a JSONGET call used directly as the FOR
* source, not first bound to a variable; found live: `substitute()` alone
* never evaluates a function call, so without this the source would still
* be an unresolved `FuncNode` by the time the array-ness check runs). */
function resolveJsonGetCall(node: FuncNode, env: Map<string, Node>, host: EngineHost): JsonLiteralNode {
if (node.args.length !== 2) {
throw new Error(`JSONGET: erwartet 2 Argumente, ${node.args.length} übergeben`);
}
const target = substitute(node.args[0], env);
if (target.kind !== 'json') {
throw new Error('JSONGET: das erste Argument muss ein JSON-Ergebnis sein (z. B. von GET/POST/JSONGET)');
}
const path = requireString(evalInEnv(node.args[1], env, host), 'JSONGET');
return { kind: 'json', value: jsonGetPath(target.value, path) };
}
/** Evaluates a `GET`/`POST`/`JSONGET` call (already confirmed by
* `isNetworkOrJsonCall`) and returns the `JsonLiteralNode` to bind the
* assigned name to. `JSONGET` is purely synchronous (no `await` inside),
* kept in this async function anyway so the one `isNetworkOrJsonCall`
* check in `execStatementAsync` covers all three names uniformly. */
async function evalSpecialAssign(
node: FuncNode,
env: Map<string, Node>,
host: EngineHost,
netHost: MacroNetworkHost,
): Promise<JsonLiteralNode> {
const upper = node.name.toUpperCase();
if (upper === 'JSONGET') return resolveJsonGetCall(node, env, host);
const isPost = upper === 'POST';
if (isPost ? node.args.length !== 2 : node.args.length !== 1) {
throw new Error(`${upper}: erwartet ${isPost ? 2 : 1} Argument${isPost ? 'e' : ''}, ${node.args.length} übergeben`);
}
const url = requireString(evalInEnv(node.args[0], env, host), upper);
const body = isPost ? requireString(evalInEnv(node.args[1], env, host), upper) : undefined;
const value = await netHost.request(isPost ? 'POST' : 'GET', url, body);
return { kind: 'json', value };
}
async function execBlockAsync(
block: BlockNode,
env: Map<string, Node>,
host: EngineHost,
budget: Budget,
writeHost: MacroWriteHost,
netHost: MacroNetworkHost,
): Promise<void> {
for (const stmt of block.statements) {
await execStatementAsync(stmt, env, host, budget, writeHost, netHost);
}
}
async function execStatementAsync(
stmt: Statement,
env: Map<string, Node>,
host: EngineHost,
budget: Budget,
writeHost: MacroWriteHost,
netHost: MacroNetworkHost,
): Promise<void> {
switch (stmt.kind) {
case 'assign': {
const substituted = substitute(stmt.value, env);
if (isNetworkOrJsonCall(substituted)) {
if (substituted.name.toUpperCase() !== 'JSONGET') {
// GET/POST count toward the step budget like any other action
// (a real network round-trip); JSONGET is pure computation, same
// treatment as an ordinary assignment.
budget.steps++;
if (budget.steps > budget.limit) {
throw new Error(`Makro führt zu viele Schritte aus (> ${budget.limit} insgesamt)`);
}
}
env.set(stmt.name.toUpperCase(), await evalSpecialAssign(substituted, env, host, netHost));
return;
}
const v = evalScalar(substituted, host);
if (isError(v)) throw new ReturnSignal(v);
env.set(stmt.name.toUpperCase(), literalNodeOf(v));
return;
}
case 'return': {
const v = evalInEnv(stmt.value, env, host);
throw new ReturnSignal(v);
}
case 'ifstmt': {
const c = evalInEnv(stmt.cond, env, host);
if (isError(c)) throw new ReturnSignal(c);
if (isTruthy(c)) await execBlockAsync(stmt.then, env, host, budget, writeHost, netHost);
else if (stmt.else) await execBlockAsync(stmt.else, env, host, budget, writeHost, netHost);
return;
}
case 'for': {
let source = substitute(stmt.source, env);
// A JSONGET(...) call used directly as the FOR source (not first
// bound to a variable) is still an unresolved FuncNode at this point
// -- substitute() only resolves name references, it never evaluates
// a call. Resolve it here to the JsonLiteralNode it produces before
// the array-ness check below.
if (source.kind === 'func' && source.name.toUpperCase() === 'JSONGET') {
source = resolveJsonGetCall(source, env, host);
}
if (source.kind === 'json') {
if (!Array.isArray(source.value)) {
throw new Error(`FOR ${stmt.varName} IN ...: JSON-Quelle muss ein Array sein, kein Objekt/Einzelwert`);
}
for (const item of source.value) {
budget.steps++;
if (budget.steps > budget.limit) {
throw new Error(`FOR-Schleife(n) über zu viele Elemente (> ${budget.limit} insgesamt)`);
}
env.set(stmt.varName.toUpperCase(), { kind: 'json', value: item });
await execBlockAsync(stmt.body, env, host, budget, writeHost, netHost);
}
return;
}
const cells = rangeOrCellToCellNodes(source, stmt.varName, host);
for (const cell of cells) {
budget.steps++;
if (budget.steps > budget.limit) {
throw new Error(`FOR-Schleife(n) über zu viele Zellen (> ${budget.limit} insgesamt)`);
}
env.set(stmt.varName.toUpperCase(), cell);
await execBlockAsync(stmt.body, env, host, budget, writeHost, netHost);
}
return;
}
case 'action': {
budget.steps++;
if (budget.steps > budget.limit) {
throw new Error(`Makro führt zu viele Schritte aus (> ${budget.limit} insgesamt)`);
}
execAction(stmt, env, host, writeHost);
return;
}
}
}
/** Dispatches one macro write-action call by name -- see types.ts's /** Dispatches one macro write-action call by name -- see types.ts's
* `MacroWriteHost` for the full vocabulary and engine/README.md's * `MacroWriteHost` for the full vocabulary and engine/README.md's
* "Makros" section for the syntax. Only `SETVALUE`'s first argument is * "Makros" section for the syntax. Only `SETVALUE`'s first argument is
+1
View File
@@ -71,5 +71,6 @@ export {
type Scalar, type Scalar,
type EngineHost, type EngineHost,
type MacroWriteHost, type MacroWriteHost,
type MacroNetworkHost,
type NamedRangeDef, type NamedRangeDef,
} from './types'; } from './types';
+17 -1
View File
@@ -105,7 +105,8 @@ export type Node =
| NamedRefNode | NamedRefNode
| ParamNode | ParamNode
| BlockCallNode | BlockCallNode
| OmittedNode; | OmittedNode
| JsonLiteralNode;
export interface NumberNode { export interface NumberNode {
kind: 'number'; kind: 'number';
@@ -193,6 +194,19 @@ export interface ParamNode {
export interface OmittedNode { export interface OmittedNode {
kind: 'omitted'; kind: 'omitted';
} }
/** A JSON value flowing through a macro's `env` (a `GET`/`POST` response, a
* `JSONGET` result, or one element while a `FOR` iterates a JSON array) --
* see evaluator.ts's `execStatementAsync`/`jsonGetPath`. Never produced by
* this parser (there is no JSON-literal syntax in the language) -- only
* the macro interpreter ever constructs one, wraps it in `literalNodeOf`-
* style env binding, and hands it back through `substitute()` like any
* other bound name. `evalScalar`'s `case 'json'` is where it collapses to
* an ordinary `CellValue` if used in plain scalar position without first
* drilling into it via `JSONGET`. */
export interface JsonLiteralNode {
kind: 'json';
value: unknown;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// "Block" function bodies (`kind: 'block'` custom functions) -- a small // "Block" function bodies (`kind: 'block'` custom functions) -- a small
@@ -794,6 +808,8 @@ export function collectRefs(
node.args.forEach((a) => collectRefs(a, out)); node.args.forEach((a) => collectRefs(a, out));
collectRefsInBlock(node.block, out); collectRefsInBlock(node.block, out);
break; break;
case 'json':
break; // a JSON value carries no cell/range/named-range refs of its own
} }
return out; return out;
} }
+24
View File
@@ -179,3 +179,27 @@ export interface MacroWriteHost {
renameSheet(sheetRef: string, newName: string): void; renameSheet(sheetRef: string, newName: string): void;
deleteSheet(sheetRef: string): void; deleteSheet(sheetRef: string): void;
} }
/**
* The document's NETWORK surface, as seen by a running macro's `GET`/`POST`
* assignment form (`x = GET(url)` / `x = POST(url; body)`; see evaluator.
* ts's `execStatementAsync`). Kept separate from {@link MacroWriteHost} for
* the same reason that one is kept separate from {@link EngineHost}: this
* engine module stays framework/DOM-agnostic (no `fetch`, no browser APIs
* anywhere in `src/engine/`) -- the store (`document.ts#runMacro`) is the
* only implementer, and it's the one place a network confirmation prompt
* and the actual `fetch()` call belong.
*
* A single `request()` method (not separate `get`/`post` methods) because
* both share the exact same "confirm this host once, then fetch and parse
* JSON" flow in the implementation -- only the HTTP method and whether a
* body is sent differ.
*/
export interface MacroNetworkHost {
/** Resolves with the parsed JSON response body, or rejects with a plain
* `Error` (network failure, a non-2xx status, a response that isn't
* valid JSON, or the user declining a host-confirmation prompt) --
* caught the same way as any other macro action failure, aborting the
* run and surfacing the message as the result's `.message`. */
request(method: 'GET' | 'POST', url: string, body?: string): Promise<unknown>;
}
+14 -1
View File
@@ -115,6 +115,7 @@ function expand(
case 'namedref': case 'namedref':
case 'param': case 'param':
case 'omitted': case 'omitted':
case 'json':
return node; // leaves -- nothing to expand return node; // leaves -- nothing to expand
case 'unary': case 'unary':
return { ...node, operand: expand(node.operand, index, bodyCache, blockCache, depth) }; return { ...node, operand: expand(node.operand, index, bodyCache, blockCache, depth) };
@@ -203,6 +204,7 @@ export function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
case 'range': case 'range':
case 'namedref': case 'namedref':
case 'omitted': case 'omitted':
case 'json':
return node; return node;
case 'unary': case 'unary':
return { ...node, operand: substitute(node.operand, bound) }; return { ...node, operand: substitute(node.operand, bound) };
@@ -328,7 +330,7 @@ export function declaredBlockNames(block: BlockNode, out: string[] = []): string
export function findInvalidForSource(block: BlockNode): ForNode | null { export function findInvalidForSource(block: BlockNode): ForNode | null {
for (const stmt of block.statements) { for (const stmt of block.statements) {
if (stmt.kind === 'for') { if (stmt.kind === 'for') {
if (!isReferenceShaped(stmt.source)) return stmt; if (!isReferenceShaped(stmt.source) && !isJsonGetCall(stmt.source)) return stmt;
const nested = findInvalidForSource(stmt.body); const nested = findInvalidForSource(stmt.body);
if (nested) return nested; if (nested) return nested;
} else if (stmt.kind === 'ifstmt') { } else if (stmt.kind === 'ifstmt') {
@@ -348,6 +350,17 @@ function isReferenceShaped(node: Node): boolean {
return node.kind === 'param' || node.kind === 'cell' || node.kind === 'range' || node.kind === 'namedref'; return node.kind === 'param' || node.kind === 'cell' || node.kind === 'range' || node.kind === 'namedref';
} }
/** A `FOR` source may also be a `JSONGET(...)` call -- once substituted and
* evaluated it may resolve to a JSON array (see evaluator.ts's
* `execStatementAsync` `'for'` case), a possibility `isReferenceShaped`
* above doesn't cover since it's a genuinely new FOR-source *kind*, not a
* reference. Not shared with `findInvalidActionTarget`'s SETVALUE-target
* check -- a JSONGET result is a VALUE, never a place to write to, so a
* write target must stay reference-shaped only. */
function isJsonGetCall(node: Node): boolean {
return node.kind === 'func' && node.name.toUpperCase() === 'JSONGET';
}
/** {@link findInvalidForSource}'s counterpart for a macro's `SETVALUE` /** {@link findInvalidForSource}'s counterpart for a macro's `SETVALUE`
* target (its first argument) instead of a FOR source -- same reasoning, * target (its first argument) instead of a FOR source -- same reasoning,
* checked at save time by store/document.ts#validateMacro. */ * checked at save time by store/document.ts#validateMacro. */
+5 -1
View File
@@ -123,7 +123,11 @@ function normaliseMacros(x: unknown): Macro[] {
continue; continue;
} }
const rec = m as Macro; const rec = m as Macro;
out.push({ id: rec.id, name: rec.name, body: rec.body }); const approvedHosts =
Array.isArray(rec.approvedHosts) && rec.approvedHosts.every((h) => typeof h === 'string')
? rec.approvedHosts
: undefined;
out.push({ id: rec.id, name: rec.name, body: rec.body, ...(approvedHosts ? { approvedHosts } : {}) });
} }
return out; return out;
} }
+178 -26
View File
@@ -777,6 +777,17 @@ describe('custom functions (document-level library)', () => {
expect(s.doc.customFunctions).toHaveLength(0); 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)', () => { it('rejects a name colliding with another custom function (case-insensitive)', () => {
const s = fresh(); const s = fresh();
s.addCustomFunction('Marge', ['x'], 'x'); s.addCustomFunction('Marge', ['x'], 'x');
@@ -1444,38 +1455,49 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
).toBeTruthy(); ).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 s = fresh();
const id = s.addTable(s.doc.activeSheetId); const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name; const name = s.tableById(id)!.name;
const macroId = s.addMacro('Setzen', `SETVALUE(${name}::A1; 42)`) as string; 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(result).toEqual({ ok: true, message: 'Makro ausgeführt.' });
expect(s.cellInput(id, 0, 0)).toBe('42'); 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 s = fresh();
const id = s.addTable(s.doc.activeSheetId); const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name; const name = s.tableById(id)!.name;
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1) RETURN "fertig"`) as string; 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 s = fresh();
const id = s.addTable(s.doc.activeSheetId); const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name; const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '3'); setCell(s, name, 'A1', '3');
setCell(s, name, 'A2', '4'); setCell(s, name, 'A2', '4');
const macroId = s.addMacro('M', `SETVALUE(${name}::A3; "=" & ${name}::A1 & "+" & ${name}::A2)`) as string; 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(s.cellInput(id, 0, 2)).toBe('=3+4');
expect(val(s, name, 'A3')).toBe(7); 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 s = fresh();
const id = s.addTable(s.doc.activeSheetId); const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name; 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`); setCell(s, name, 'B1', `=${name}::A1+${name}::A2`);
const macroId = s.addMacro('Zeile', `INSERTROW("${name}"; 0)`) as string; 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.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, 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 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 s = fresh();
const id = s.addTable(s.doc.activeSheetId); const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name; 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)`, `SETVALUE(${name}::B1; 1) INSERTROW("${name}"; 0) SETVALUE(${name}::C1; ${name}::A2 + 100)`,
) as string; ) 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(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 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 s = fresh();
const sheetId = s.doc.activeSheetId; const sheetId = s.doc.activeSheetId;
const sheetName = s.sheetById(sheetId)!.name; const sheetName = s.sheetById(sheetId)!.name;
@@ -1520,11 +1542,11 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
) as string; ) as string;
const before = s.tablesOf(sheetId).length; 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 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 s = fresh();
const startSheetId = s.doc.activeSheetId; const startSheetId = s.doc.activeSheetId;
const startSelection = s.doc.selection; const startSelection = s.doc.selection;
@@ -1533,14 +1555,14 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
expect(s.doc.activeSheetId).toBe(startSheetId); expect(s.doc.activeSheetId).toBe(startSheetId);
const macroId = s.addMacro('M', 'ADDTABLE("Ziel"; "Neu")') as string; 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.activeSheetId).toBe(startSheetId); // NOT switched to "Ziel"
expect(s.doc.selection).toEqual(startSelection); expect(s.doc.selection).toEqual(startSelection);
expect(s.tablesOf(otherSheetId).map((t) => t.name)).toContain('Neu'); // the mutation itself still happened 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 s = fresh();
const before = s.doc.sheets.length; const before = s.doc.sheets.length;
const macroId = s.addMacro( const macroId = s.addMacro(
@@ -1548,50 +1570,50 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
'ADDSHEET("MakroBlatt") RENAMESHEET("MakroBlatt"; "Umbenannt") DELETESHEET("Umbenannt")', 'ADDSHEET("MakroBlatt") RENAMESHEET("MakroBlatt"; "Umbenannt") DELETESHEET("Umbenannt")',
) as string; ) as string;
expect(s.runMacro(macroId).ok).toBe(true); expect((await s.runMacro(macroId)).ok).toBe(true);
expect(s.doc.sheets.length).toBe(before); 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(); const s = fresh();
// collapse to exactly one sheet first // collapse to exactly one sheet first
while (s.doc.sheets.length > 1) s.deleteSheet(s.doc.sheets[s.doc.sheets.length - 1].id); 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 onlySheetName = s.doc.sheets[0].name;
const macroId = s.addMacro('M', `DELETESHEET("${onlySheetName}")`) as string; 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(result.ok).toBe(false);
expect(s.doc.sheets).toHaveLength(1); 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 s = fresh();
const macroId = s.addMacro('M', 'INSERTROW("Nirgendwo"; 0)') as string; 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.ok).toBe(false);
expect(result.message).toMatch(/unbekannte Tabelle/); 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(); 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 s = fresh();
const id = s.addTable(s.doc.activeSheetId); const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name; const name = s.tableById(id)!.name;
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1)`) as string; const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1)`) as string;
s.updateMacro(macroId, 'M', `SETVALUE(${name}::A1; 2)`); s.updateMacro(macroId, 'M', `SETVALUE(${name}::A1; 2)`);
s.runMacro(macroId); await s.runMacro(macroId);
expect(s.cellInput(id, 0, 0)).toBe('2'); expect(s.cellInput(id, 0, 0)).toBe('2');
s.deleteMacro(macroId); s.deleteMacro(macroId);
expect(s.doc.macros).toHaveLength(0); 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(); vi.useFakeTimers();
try { try {
const s = fresh(); 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; const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1) INSERTROW("${name}"; 0)`) as string;
vi.advanceTimersByTime(600); // the addTable + addMacro setup settles first vi.advanceTimersByTime(600); // the addTable + addMacro setup settles first
s.runMacro(macroId); await s.runMacro(macroId);
vi.advanceTimersByTime(600); vi.advanceTimersByTime(600);
expect(s.cellInput(id, 0, 1)).toBe('1'); // shifted down by the insert expect(s.cellInput(id, 0, 1)).toBe('1'); // shifted down by the insert
expect(s.tableById(id)!.rows).toBe(13); 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', () => { it('JSON export/import round-trips macros byte-for-byte', () => {
const s = fresh(); const s = fresh();
const id = s.addTable(s.doc.activeSheetId); const id = s.addTable(s.doc.activeSheetId);
@@ -1625,6 +1760,23 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => {
const reimported = parseDocument(json); const reimported = parseDocument(json);
expect(reimported.macros).toEqual(s.doc.macros); 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")', () => { describe('custom functions: "block" kind (FOR/IF/RETURN "code")', () => {
+104 -13
View File
@@ -48,6 +48,7 @@ import {
type FunctionDef, type FunctionDef,
type NamedRangeDef, type NamedRangeDef,
type MacroWriteHost, type MacroWriteHost,
type MacroNetworkHost,
type EngineHost, type EngineHost,
} from '../engine'; } from '../engine';
import { import {
@@ -527,6 +528,18 @@ function createStore() {
// below must not treat that as "a new edit" (which would immediately wipe // below must not treat that as "a new edit" (which would immediately wipe
// the very redoStack/undoStack entry undo()/redo() just pushed). // the very redoStack/undoStack entry undo()/redo() just pushed).
let applyingHistory = false; 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() { function commitHistoryStep() {
clearTimeout(historyTimer); clearTimeout(historyTimer);
@@ -556,6 +569,7 @@ function createStore() {
historyPending.value = dirty; historyPending.value = dirty;
if (!dirty) return; // pure selection/UI change, or settled back to a no-op if (!dirty) return; // pure selection/UI change, or settled back to a no-op
clearTimeout(historyTimer); clearTimeout(historyTimer);
if (macroRunActive) return; // runMacro() commits exactly once itself, see its own comment above
historyTimer = setTimeout(commitHistoryStep, HISTORY_COALESCE_MS); historyTimer = setTimeout(commitHistoryStep, HISTORY_COALESCE_MS);
}, },
// sync: historyPending/redoStack must be accurate the instant a mutation // sync: historyPending/redoStack must be accurate the instant a mutation
@@ -1006,6 +1020,12 @@ function createStore() {
'DELETESHEET', '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 * Full validation for add/update: name & parameter shape, no collision with
* a built-in or another custom function, the body parses (as an * a built-in or another custom function, the body parses (as an
@@ -1103,6 +1123,21 @@ function createStore() {
ownCalls = collectCalledFunctionNames(ast); 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>>(); const graph = new Map<string, Set<string>>();
for (const f of doc.customFunctions) { for (const f of doc.customFunctions) {
if (f.id === ignoreId) continue; if (f.id === ignoreId) continue;
@@ -1280,6 +1315,9 @@ function createStore() {
if (MACRO_ACTION_NAMES.has(n.toUpperCase())) { if (MACRO_ACTION_NAMES.has(n.toUpperCase())) {
return `"${n}" ist der Name einer Makro-Aktion und kann nicht als Name verwendet werden.`; 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); const badFor = findInvalidForSource(block);
if (badFor) { if (badFor) {
@@ -1340,20 +1378,25 @@ function createStore() {
} }
/** /**
* Runs a macro synchronously to completion (see store/types.ts's `Macro` * Runs a macro to completion (see store/types.ts's `Macro` doc comment
* doc comment for the "why explicit trigger, not recalc-embedded" design) * for the "why explicit trigger, not recalc-embedded" design). `async`
* -- a full JS-synchronous run automatically becomes exactly ONE undo * because a `GET`/`POST` genuinely waits on a real network round-trip --
* step, for free, via the existing debounced-fingerprint history (no * that costs the automatic "one JS turn = one undo step" property a
* transaction machinery needed, see `commitHistoryStep`'s doc comment). * 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 * All three hosts handed to the interpreter re-read the outer `wb`/`doc`
* call rather than capturing it once at the top -- required correctness, * on every call rather than capturing anything once at the top --
* not a style choice: a structural action (INSERTROW, ADDTABLE, ...) * required correctness, not a style choice: a structural action
* calls `syncWorkbook()`, which reassigns `wb` to a brand-new `Workbook` * (INSERTROW, ADDTABLE, ...) calls `syncWorkbook()`, which reassigns
* instance; a cached reference from before that point would silently go * `wb` to a brand-new `Workbook` instance; a cached reference from
* stale for every read for the rest of the run. * 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); const macro = doc.macros.find((m) => m.id === macroId);
if (!macro) return { ok: false, message: 'Makro nicht gefunden.' }; 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 { try {
const block = parseMacroBlock(macro.body); const block = parseMacroBlock(macro.body);
const index = buildFunctionIndex(toFunctionDefs(doc)); const index = buildFunctionIndex(toFunctionDefs(doc));
const expanded = expandMacroBlock(block, index); 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.' }; return { ok: true, message: typeof result === 'string' ? result : 'Makro ausgeführt.' };
} catch (e) { } catch (e) {
return { ok: false, message: e instanceof Error ? e.message : String(e) }; return { ok: false, message: e instanceof Error ? e.message : String(e) };
} finally { } finally {
restoreView(); restoreView();
macroRunActive = false;
commitHistoryStep(); // exactly one step for everything mutated since the run started, any await duration
} }
} }
+13 -1
View File
@@ -230,7 +230,11 @@ export interface NamedRange {
* `document.ts`'s `runMacro`/`validateMacro` and README's "Makros" * `document.ts`'s `runMacro`/`validateMacro` and README's "Makros"
* section. Takes no parameters (a click carries no arguments) and can * section. Takes no parameters (a click carries no arguments) and can
* never call another macro (no such action exists) -- both deliberate, * 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 { export interface Macro {
id: string; id: string;
/** document-unique, compared case-insensitively; never a built-in/custom /** 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 * inside must be qualified with a table name (`Umsatz::A1`, never bare
* `A1`) -- a macro has no "home table", see `validateMacro`. */ * `A1`) -- a macro has no "home table", see `validateMacro`. */
body: string; 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 /** One entry in the formula-autocomplete dropdown (see document.ts's