diff --git a/README.md b/README.md index cde19f6..880f2a7 100644 --- a/README.md +++ b/README.md @@ -449,13 +449,59 @@ Schleifenvariable); alle anderen Argumente sind normale Ausdrücke. normal (berechnet einen Wert), kann aber selbst nie schreiben — ihr 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 übergibt keine Argumente); mehrere Struktur-Aktionen in einer Schleife lösen weiterhin je eine eigene Neuberechnung aus statt gebündelt einmal am Ende (das kleinere Schrittbudget hält das Restrisiko klein, spätere Optimierung möglich); ein `TabellenRef`-String wird am *ersten* `!` 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 @@ -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 *anderes* Blatt lässt die aktive Ansicht/Auswahl unangetastet (reiner 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. --- diff --git a/public/test-fixtures/sample.json b/public/test-fixtures/sample.json new file mode 100644 index 0000000..1484455 --- /dev/null +++ b/public/test-fixtures/sample.json @@ -0,0 +1,6 @@ +{ + "items": [ + { "name": "Nord", "value": 100 }, + { "name": "Süd", "value": 200 } + ] +} diff --git a/src/components/ButtonView.vue b/src/components/ButtonView.vue index bd0f2f6..83603a1 100644 --- a/src/components/ButtonView.vue +++ b/src/components/ButtonView.vue @@ -7,7 +7,7 @@ * own right-click menu, for a quicker "build a button, wire it up, done" * loop without leaving the canvas. */ -import { computed, reactive } from 'vue'; +import { computed, reactive, ref } from 'vue'; import { useDocument } from '../store/document'; import { useToasts } from '../composables/useToasts'; import ContextMenu from './ContextMenu.vue'; @@ -21,16 +21,26 @@ const obj = computed(() => { 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; - if (!o) return; + if (!o || running.value) return; 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.'); return; } - const result = store.runMacro(o.macroId); - if (result.ok) toasts.success(result.message); - else toasts.error(result.message); + running.value = true; + try { + 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(() => { @@ -77,11 +87,12 @@ const menuItems = computed(() => {
- {{ obj.label || 'Button' }} + {{ running ? '…' : obj.label || 'Button' }}
@@ -100,6 +111,10 @@ const menuItems = computed(() => { cursor: pointer; user-select: none; } +.btnbox.running { + opacity: 0.6; + cursor: default; +} .btnbox:hover { filter: brightness(1.06); } diff --git a/src/components/MacrosModal.vue b/src/components/MacrosModal.vue index 418c262..aeb1a9c 100644 --- a/src/components/MacrosModal.vue +++ b/src/components/MacrosModal.vue @@ -75,10 +75,21 @@ function removeMacro(id: string, name: string) { } } -function runMacro(id: string) { - const result = store.runMacro(id); - if (result.ok) toasts.success(result.message); - else toasts.error(result.message); +// A macro can now involve a real network round-trip (GET/POST) and even a +// blocking confirm() prompt -- guard against re-clicking "▶ Ausführen" on +// the same row while its run is still in flight. +const runningId = ref(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() { @@ -102,7 +113,14 @@ function close() {
  • {{ m.name }} - + diff --git a/src/engine/__tests__/macros.spec.ts b/src/engine/__tests__/macros.spec.ts index b53726b..8bc92cb 100644 --- a/src/engine/__tests__/macros.spec.ts +++ b/src/engine/__tests__/macros.spec.ts @@ -2,7 +2,14 @@ import { describe, it, expect } from 'vitest'; import { parseMacroBlock } from '../parser'; import { runMacroActions } from '../evaluator'; 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 } }; @@ -60,19 +67,47 @@ function makeWriteHost(): { host: MacroWriteHost; calls: RecordedCall[] } { return { host, calls }; } -const runMacro = (src: string, host: EngineHost, writeHost: MacroWriteHost) => - runMacroActions(parseMacroBlock(src), host, writeHost); +/** A mock `MacroNetworkHost` -- `responses` maps "METHOD url" to either a + * 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, +): { 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', () => { - it('a literal cell target writes exactly one cell', () => { + it('a literal cell target writes exactly one cell', async () => { 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'] }]); }); - 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(); - runMacro('SETVALUE(Umsatz::A1:A3; "x")', makeHost({}), writeHost); + await runMacro('SETVALUE(Umsatz::A1:A3; "x")', makeHost({}), writeHost); expect(calls).toHaveLength(1); const [cells, text] = calls[0].args as [CellRef[], string]; expect(cells).toEqual([ @@ -83,59 +118,59 @@ describe('runMacroActions -- SETVALUE target resolution', () => { 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(); - 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([ { op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '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(); - 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)'); }); - 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(); - 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(); - 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(); - 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', () => { - it('RETURN short-circuits and its value is returned', () => { + it('RETURN short-circuits and its value is returned', async () => { 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(); - 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); }); - 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(); - 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', () => { - 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(); - runMacro( + await runMacro( [ 'INSERTROW("Umsatz"; 3)', '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(); - 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'] }]); }); - it('an unknown action name throws', () => { + it('an unknown action name throws', async () => { 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(); - 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', () => { - 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(); // 2,000 iterations, each one action call -- exceeds MAX_MACRO_STEPS // (1,000) but would be nowhere near the ordinary MAX_LOOP_STEPS // (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), - ).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', () => { - 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 defs: FunctionDef[] = [ { id: 'fn-1', name: 'MARGE', params: ['a', 'b'], body: 'a - b', kind: 'expression' }, ]; const index = buildFunctionIndex(defs); 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'] }]); }); @@ -218,10 +261,196 @@ describe('runMacroActions -- nested custom-function calls inside a macro express }); 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(); - 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, ); }); }); + +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']); + }); +}); diff --git a/src/engine/__tests__/parser.spec.ts b/src/engine/__tests__/parser.spec.ts index 84b9f49..de007c5 100644 --- a/src/engine/__tests__/parser.spec.ts +++ b/src/engine/__tests__/parser.spec.ts @@ -40,6 +40,10 @@ function sexpr(n: Node): string { return `blockcall(${n.label})`; case '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)); diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index ddff6cd..2ad0dc9 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -39,11 +39,19 @@ * 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 { expandRange } from './refs'; 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 { return evalScalar(ast, host); @@ -135,6 +143,24 @@ function evalScalar(node: Node, host: EngineHost): CellValue { // argument evaluated lazily -- both require handling here, before the // generic eager/flattening dispatch below. 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); 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 // ever calling evalScalar on it (see evalXLookup below). 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 * the `case 'func'` special-case above and functions.ts's FUNCTIONS.IF * 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 * 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'` case in `execStatement`). `writeHost` is what makes the new - * `'action'` statement kind (SETVALUE/INSERTROW/...) possible -- see its - * doc comment in types.ts. `host` MUST be a proxy that re-resolves against + * `'return'` case in `execStatementAsync`). `writeHost` is what makes the + * `'action'` statement kind (SETVALUE/INSERTROW/...) possible; `netHost` is + * 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 * action mid-run (INSERTROW, ADDTABLE, ...) replaces the underlying engine * instance wholesale (see store/document.ts's `syncWorkbook`), and a cached * 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 { const env = new Map(); const budget: Budget = { steps: 0, limit: MAX_MACRO_STEPS }; try { - execBlock(block, env, host, budget, writeHost); + await execBlockAsync(block, env, host, budget, writeHost, netHost); } catch (e) { if (e instanceof ReturnSignal) return e.value; 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)[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, 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, + host: EngineHost, + netHost: MacroNetworkHost, +): Promise { + 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, + host: EngineHost, + budget: Budget, + writeHost: MacroWriteHost, + netHost: MacroNetworkHost, +): Promise { + for (const stmt of block.statements) { + await execStatementAsync(stmt, env, host, budget, writeHost, netHost); + } +} + +async function execStatementAsync( + stmt: Statement, + env: Map, + host: EngineHost, + budget: Budget, + writeHost: MacroWriteHost, + netHost: MacroNetworkHost, +): Promise { + 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 * `MacroWriteHost` for the full vocabulary and engine/README.md's * "Makros" section for the syntax. Only `SETVALUE`'s first argument is diff --git a/src/engine/index.ts b/src/engine/index.ts index d7574c8..c41c999 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -71,5 +71,6 @@ export { type Scalar, type EngineHost, type MacroWriteHost, + type MacroNetworkHost, type NamedRangeDef, } from './types'; diff --git a/src/engine/parser.ts b/src/engine/parser.ts index ef91996..3de2e61 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -105,7 +105,8 @@ export type Node = | NamedRefNode | ParamNode | BlockCallNode - | OmittedNode; + | OmittedNode + | JsonLiteralNode; export interface NumberNode { kind: 'number'; @@ -193,6 +194,19 @@ export interface ParamNode { export interface OmittedNode { 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 @@ -794,6 +808,8 @@ export function collectRefs( node.args.forEach((a) => collectRefs(a, out)); collectRefsInBlock(node.block, out); break; + case 'json': + break; // a JSON value carries no cell/range/named-range refs of its own } return out; } diff --git a/src/engine/types.ts b/src/engine/types.ts index f855e97..e1660a4 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -179,3 +179,27 @@ export interface MacroWriteHost { renameSheet(sheetRef: string, newName: 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; +} diff --git a/src/engine/userFunctions.ts b/src/engine/userFunctions.ts index fba9b95..e866082 100644 --- a/src/engine/userFunctions.ts +++ b/src/engine/userFunctions.ts @@ -115,6 +115,7 @@ function expand( case 'namedref': case 'param': case 'omitted': + case 'json': return node; // leaves -- nothing to expand case 'unary': return { ...node, operand: expand(node.operand, index, bodyCache, blockCache, depth) }; @@ -203,6 +204,7 @@ export function substitute(node: Node, bound: ReadonlyMap): Node { case 'range': case 'namedref': case 'omitted': + case 'json': return node; case 'unary': 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 { for (const stmt of block.statements) { if (stmt.kind === 'for') { - if (!isReferenceShaped(stmt.source)) return stmt; + if (!isReferenceShaped(stmt.source) && !isJsonGetCall(stmt.source)) return stmt; const nested = findInvalidForSource(stmt.body); if (nested) return nested; } 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'; } +/** 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` * target (its first argument) instead of a FOR source -- same reasoning, * checked at save time by store/document.ts#validateMacro. */ diff --git a/src/io/json.ts b/src/io/json.ts index 6be3fce..397cb8e 100644 --- a/src/io/json.ts +++ b/src/io/json.ts @@ -123,7 +123,11 @@ function normaliseMacros(x: unknown): Macro[] { continue; } 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; } diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index 443f3eb..c9d2515 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -777,6 +777,17 @@ describe('custom functions (document-level library)', () => { expect(s.doc.customFunctions).toHaveLength(0); }); + it('rejects a body calling GET/POST/JSONGET -- macro-only, in both an expression-kind and a block-kind function', () => { + const s = fresh(); + for (const name of ['GET', 'POST', 'JSONGET']) { + const expr = s.addCustomFunction('F1', ['x'], `${name}(x)`, 'expression') as { error: string }; + expect(expr.error).toBeTruthy(); + const block = s.addCustomFunction('F2', ['x'], `r = ${name}(x) RETURN r`, 'block') as { error: string }; + expect(block.error).toBeTruthy(); + } + expect(s.doc.customFunctions).toHaveLength(0); + }); + it('rejects a name colliding with another custom function (case-insensitive)', () => { const s = fresh(); s.addCustomFunction('Marge', ['x'], 'x'); @@ -1444,38 +1455,49 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => { ).toBeTruthy(); }); - it('runMacro: SETVALUE writes a cell, reported as a success with a default message', () => { + it('accepts a JSONGET(...) call used directly as a FOR source (not first bound to a variable) -- found live: this needed its own fix in findInvalidForSource, distinct from the ordinary cell/range/param shape check', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + const result = s.addMacro( + 'M', + `r = GET("https://x") FOR item IN JSONGET(r; "items") DO SETVALUE(${name}::A1; JSONGET(item; "name")) END`, + ); + expect(typeof result).toBe('string'); + }); + + it('runMacro: SETVALUE writes a cell, reported as a success with a default message', async () => { const s = fresh(); const id = s.addTable(s.doc.activeSheetId); const name = s.tableById(id)!.name; const macroId = s.addMacro('Setzen', `SETVALUE(${name}::A1; 42)`) as string; - const result = s.runMacro(macroId); + const result = await s.runMacro(macroId); expect(result).toEqual({ ok: true, message: 'Makro ausgeführt.' }); expect(s.cellInput(id, 0, 0)).toBe('42'); }); - it('runMacro: a RETURNed string becomes the success message instead of the default', () => { + it('runMacro: a RETURNed string becomes the success message instead of the default', async () => { const s = fresh(); const id = s.addTable(s.doc.activeSheetId); const name = s.tableById(id)!.name; const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1) RETURN "fertig"`) as string; - expect(s.runMacro(macroId)).toEqual({ ok: true, message: 'fertig' }); + await expect(s.runMacro(macroId)).resolves.toEqual({ ok: true, message: 'fertig' }); }); - it('runMacro: SETVALUE can write a live formula (a value starting with "=")', () => { + it('runMacro: SETVALUE can write a live formula (a value starting with "=")', async () => { const s = fresh(); const id = s.addTable(s.doc.activeSheetId); const name = s.tableById(id)!.name; setCell(s, name, 'A1', '3'); setCell(s, name, 'A2', '4'); const macroId = s.addMacro('M', `SETVALUE(${name}::A3; "=" & ${name}::A1 & "+" & ${name}::A2)`) as string; - expect(s.runMacro(macroId).ok).toBe(true); + expect((await s.runMacro(macroId)).ok).toBe(true); expect(s.cellInput(id, 0, 2)).toBe('=3+4'); expect(val(s, name, 'A3')).toBe(7); }); - it('runMacro: INSERTROW performs the same document-wide reference rewrite as the store primitive', () => { + it('runMacro: INSERTROW performs the same document-wide reference rewrite as the store primitive', async () => { const s = fresh(); const id = s.addTable(s.doc.activeSheetId); const name = s.tableById(id)!.name; @@ -1484,13 +1506,13 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => { setCell(s, name, 'B1', `=${name}::A1+${name}::A2`); const macroId = s.addMacro('Zeile', `INSERTROW("${name}"; 0)`) as string; - expect(s.runMacro(macroId).ok).toBe(true); + expect((await s.runMacro(macroId)).ok).toBe(true); expect(s.tableById(id)!.rows).toBe(13); // seed default 12 + 1 expect(s.cellInput(id, 0, 1)).toBe('10'); // A1's content slid down to A2 expect(s.cellInput(id, 1, 1)).toBe(`=${name}::A2+${name}::A3`); // the SUM formula's own refs shifted too }); - it('runMacro: a structural action mid-run does not leave later reads/writes stale (the thin-host-proxy fix)', () => { + it('runMacro: a structural action mid-run does not leave later reads/writes stale (the thin-host-proxy fix)', async () => { const s = fresh(); const id = s.addTable(s.doc.activeSheetId); const name = s.tableById(id)!.name; @@ -1505,12 +1527,12 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => { `SETVALUE(${name}::B1; 1) INSERTROW("${name}"; 0) SETVALUE(${name}::C1; ${name}::A2 + 100)`, ) as string; - expect(s.runMacro(macroId)).toEqual({ ok: true, message: 'Makro ausgeführt.' }); + await expect(s.runMacro(macroId)).resolves.toEqual({ ok: true, message: 'Makro ausgeführt.' }); expect(s.cellInput(id, 1, 1)).toBe('1'); // B1's write, shifted to B2 by the insert expect(val(s, name, 'C1')).toBe(105); // reads A2 (=5, shifted from A1) fresh, post-insert }); - it('runMacro: ADDTABLE/RENAMETABLE/DELETETABLE round-trip', () => { + it('runMacro: ADDTABLE/RENAMETABLE/DELETETABLE round-trip', async () => { const s = fresh(); const sheetId = s.doc.activeSheetId; const sheetName = s.sheetById(sheetId)!.name; @@ -1520,11 +1542,11 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => { ) as string; const before = s.tablesOf(sheetId).length; - expect(s.runMacro(macroId).ok).toBe(true); + expect((await s.runMacro(macroId)).ok).toBe(true); expect(s.tablesOf(sheetId).length).toBe(before); // added then deleted -- net zero }); - it('runMacro: ADDTABLE on a DIFFERENT sheet does not yank the active sheet/selection there (found live: addTable() itself navigates, a macro must undo just that incidental side effect)', () => { + it('runMacro: ADDTABLE on a DIFFERENT sheet does not yank the active sheet/selection there (found live: addTable() itself navigates, a macro must undo just that incidental side effect)', async () => { const s = fresh(); const startSheetId = s.doc.activeSheetId; const startSelection = s.doc.selection; @@ -1533,14 +1555,14 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => { expect(s.doc.activeSheetId).toBe(startSheetId); const macroId = s.addMacro('M', 'ADDTABLE("Ziel"; "Neu")') as string; - expect(s.runMacro(macroId).ok).toBe(true); + expect((await s.runMacro(macroId)).ok).toBe(true); expect(s.doc.activeSheetId).toBe(startSheetId); // NOT switched to "Ziel" expect(s.doc.selection).toEqual(startSelection); expect(s.tablesOf(otherSheetId).map((t) => t.name)).toContain('Neu'); // the mutation itself still happened }); - it('runMacro: ADDSHEET/RENAMESHEET/DELETESHEET round-trip', () => { + it('runMacro: ADDSHEET/RENAMESHEET/DELETESHEET round-trip', async () => { const s = fresh(); const before = s.doc.sheets.length; const macroId = s.addMacro( @@ -1548,50 +1570,50 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => { 'ADDSHEET("MakroBlatt") RENAMESHEET("MakroBlatt"; "Umbenannt") DELETESHEET("Umbenannt")', ) as string; - expect(s.runMacro(macroId).ok).toBe(true); + expect((await s.runMacro(macroId)).ok).toBe(true); expect(s.doc.sheets.length).toBe(before); }); - it('runMacro: DELETESHEET refuses the last sheet, surfaced as a real failure not a silent no-op', () => { + it('runMacro: DELETESHEET refuses the last sheet, surfaced as a real failure not a silent no-op', async () => { const s = fresh(); // collapse to exactly one sheet first while (s.doc.sheets.length > 1) s.deleteSheet(s.doc.sheets[s.doc.sheets.length - 1].id); const onlySheetName = s.doc.sheets[0].name; const macroId = s.addMacro('M', `DELETESHEET("${onlySheetName}")`) as string; - const result = s.runMacro(macroId); + const result = await s.runMacro(macroId); expect(result.ok).toBe(false); expect(s.doc.sheets).toHaveLength(1); }); - it('runMacro: an unknown table reference fails cleanly, no throw escapes', () => { + it('runMacro: an unknown table reference fails cleanly, no throw escapes', async () => { const s = fresh(); const macroId = s.addMacro('M', 'INSERTROW("Nirgendwo"; 0)') as string; - const result = s.runMacro(macroId); + const result = await s.runMacro(macroId); expect(result.ok).toBe(false); expect(result.message).toMatch(/unbekannte Tabelle/); }); - it('runMacro: an unknown macro id fails cleanly', () => { + it('runMacro: an unknown macro id fails cleanly', async () => { const s = fresh(); - expect(s.runMacro('does-not-exist').ok).toBe(false); + expect((await s.runMacro('does-not-exist')).ok).toBe(false); }); - it('updateMacro changes the body; deleteMacro removes it', () => { + it('updateMacro changes the body; deleteMacro removes it', async () => { const s = fresh(); const id = s.addTable(s.doc.activeSheetId); const name = s.tableById(id)!.name; const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1)`) as string; s.updateMacro(macroId, 'M', `SETVALUE(${name}::A1; 2)`); - s.runMacro(macroId); + await s.runMacro(macroId); expect(s.cellInput(id, 0, 0)).toBe('2'); s.deleteMacro(macroId); expect(s.doc.macros).toHaveLength(0); }); - it('a whole synchronous macro run (SETVALUE + a structural action) coalesces into exactly one undo step', () => { + it('a whole synchronous macro run (SETVALUE + a structural action) coalesces into exactly one undo step', async () => { vi.useFakeTimers(); try { const s = fresh(); @@ -1600,7 +1622,7 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => { const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1) INSERTROW("${name}"; 0)`) as string; vi.advanceTimersByTime(600); // the addTable + addMacro setup settles first - s.runMacro(macroId); + await s.runMacro(macroId); vi.advanceTimersByTime(600); expect(s.cellInput(id, 0, 1)).toBe('1'); // shifted down by the insert expect(s.tableById(id)!.rows).toBe(13); @@ -1615,6 +1637,119 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => { } }); + describe('GET/POST: confirmation gate + persistence, and the undo-invariant fix', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('confirms the first GET to a new host, persists it, and does not re-prompt for a second run to the same host', async () => { + const confirmMock = vi.fn(() => true); + vi.stubGlobal('confirm', confirmMock); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, json: async () => ({ v: 1 }) })), + ); + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + const macroId = s.addMacro('M', `r = GET("https://api.example.com/x") SETVALUE(${name}::A1; JSONGET(r; "v"))`) as string; + + expect((await s.runMacro(macroId)).ok).toBe(true); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual(['api.example.com']); + + expect((await s.runMacro(macroId)).ok).toBe(true); + expect(confirmMock).toHaveBeenCalledTimes(1); // NOT called again -- already approved + }); + + it('a different host on the same macro prompts again', async () => { + vi.stubGlobal('confirm', vi.fn(() => true)); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => 1 }))); + const s = fresh(); + const macroId = s.addMacro('M', 'a = GET("https://one.example.com") b = GET("https://two.example.com")') as string; + + expect((await s.runMacro(macroId)).ok).toBe(true); + expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual([ + 'one.example.com', + 'two.example.com', + ]); + }); + + it('declining the confirmation aborts the macro and does NOT persist the host', async () => { + const confirmMock = vi.fn(() => false); + vi.stubGlobal('confirm', confirmMock); + vi.stubGlobal('fetch', vi.fn()); + const s = fresh(); + const macroId = s.addMacro('M', 'r = GET("https://api.example.com/x")') as string; + + const result = await s.runMacro(macroId); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/abgelehnt/); + expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts ?? []).toEqual([]); + }); + + it('a non-2xx response aborts the macro cleanly', async () => { + vi.stubGlobal('confirm', vi.fn(() => true)); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: false, status: 500, statusText: 'Internal Server Error' })), + ); + const s = fresh(); + const macroId = s.addMacro('M', 'r = GET("https://api.example.com/x")') as string; + + const result = await s.runMacro(macroId); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/500/); + }); + + it('the mandatory test: SETVALUE -> a slow GET -> SETVALUE still produces exactly one undo step, proving the macroRunActive fix', async () => { + vi.useFakeTimers(); + try { + vi.stubGlobal('confirm', vi.fn(() => true)); + vi.stubGlobal( + 'fetch', + vi.fn( + () => + new Promise((resolve) => + // longer than HISTORY_COALESCE_MS (150ms) -- without the + // macroRunActive fix, the debounced auto-commit would fire + // *during* this wait and split the run into two undo steps + setTimeout(() => resolve({ ok: true, json: async () => 7 }), 300), + ), + ), + ); + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + const macroId = s.addMacro( + 'M', + `SETVALUE(${name}::A1; 1) r = GET("https://api.example.com/x") SETVALUE(${name}::A2; r)`, + ) as string; + vi.advanceTimersByTime(600); // let the addTable + addMacro setup settle into its OWN step first + + const runPromise = s.runMacro(macroId); + await vi.advanceTimersByTimeAsync(300); // let the mocked fetch's internal setTimeout resolve + await runPromise; + expect(s.cellInput(id, 0, 0)).toBe('1'); + expect(s.cellInput(id, 0, 1)).toBe('7'); + + // If the run had split into two undo steps (the bug this fix + // prevents), this single undo() would only revert the *second* + // SETVALUE, leaving A1 still '1' -- a direct, unambiguous proof of + // "one run = one step" without needing to peek at undoStack.length. + // (The table itself survives too, proving it settled as its own + // separate step beforehand, not bundled into the macro's step.) + s.undo(); + expect(s.tableById(id)).toBeDefined(); + expect(s.cellInput(id, 0, 0)).toBe(''); + expect(s.cellInput(id, 0, 1)).toBe(''); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } + }); + }); + it('JSON export/import round-trips macros byte-for-byte', () => { const s = fresh(); const id = s.addTable(s.doc.activeSheetId); @@ -1625,6 +1760,23 @@ describe('macros (document-level, explicitly-triggered write scripts)', () => { const reimported = parseDocument(json); expect(reimported.macros).toEqual(s.doc.macros); }); + + it('JSON export/import round-trips approvedHosts too', async () => { + vi.stubGlobal('confirm', vi.fn(() => true)); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => 1 }))); + try { + const s = fresh(); + const macroId = s.addMacro('M', 'r = GET("https://api.example.com/x")') as string; + await s.runMacro(macroId); + expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual(['api.example.com']); + + const json = serializeDocument(s.doc); + const reimported = parseDocument(json); + expect(reimported.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual(['api.example.com']); + } finally { + vi.unstubAllGlobals(); + } + }); }); describe('custom functions: "block" kind (FOR/IF/RETURN "code")', () => { diff --git a/src/store/document.ts b/src/store/document.ts index 212a15d..2714473 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -48,6 +48,7 @@ import { type FunctionDef, type NamedRangeDef, type MacroWriteHost, + type MacroNetworkHost, type EngineHost, } from '../engine'; import { @@ -527,6 +528,18 @@ function createStore() { // below must not treat that as "a new edit" (which would immediately wipe // the very redoStack/undoStack entry undo()/redo() just pushed). let applyingHistory = false; + // true for the duration of one runMacro() call (set/cleared there) -- a + // macro with a GET/POST can genuinely take longer than HISTORY_COALESCE_MS + // between two mutations (a real network round-trip), which would otherwise + // let the debounced commit below fire *during* the run and split one + // macro into two+ undo steps, silently breaking the documented "one + // synchronous run = one undo step" guarantee (see README's "Makros" + // section). While this is true, the watch below still tracks + // historyPending/redoStack (so a bound Undo/Redo button stays accurate + // mid-run) but never *schedules* commitHistoryStep -- runMacro() calls it + // exactly once itself, in its `finally`, regardless of how long the + // run's awaits took. + let macroRunActive = false; function commitHistoryStep() { clearTimeout(historyTimer); @@ -556,6 +569,7 @@ function createStore() { historyPending.value = dirty; if (!dirty) return; // pure selection/UI change, or settled back to a no-op clearTimeout(historyTimer); + if (macroRunActive) return; // runMacro() commits exactly once itself, see its own comment above historyTimer = setTimeout(commitHistoryStep, HISTORY_COALESCE_MS); }, // sync: historyPending/redoStack must be accurate the instant a mutation @@ -1006,6 +1020,12 @@ function createStore() { 'DELETESHEET', ]); + /** GET/POST/JSONGET (see evaluator.ts's `execStatementAsync`) -- reserved + * against use as a macro-local variable name, same reasoning as + * `MACRO_ACTION_NAMES`. Also used by `validateCustomFunction` to reject + * these three names as unreachable outside a macro (see there). */ + const MACRO_NETWORK_NAMES = new Set(['GET', 'POST', 'JSONGET']); + /** * Full validation for add/update: name & parameter shape, no collision with * a built-in or another custom function, the body parses (as an @@ -1103,6 +1123,21 @@ function createStore() { ownCalls = collectCalledFunctionNames(ast); } + // GET/POST/JSONGET are never registered as built-ins (see runMacro's + // MACRO_NETWORK_NAMES/execStatementAsync in evaluator.ts) -- calling + // one here would already just become #NAME? at evaluation time, same + // as any other unknown function. This check exists only to catch it + // immediately with a clear message, mirroring how the 11 write actions + // aren't separately validated here either (parser-level containment + // already makes ActionNode unreachable from a function body) -- for + // GET/POST/JSONGET there's no parser-level gate (they're ordinary + // FuncNode calls), so a save-time name check is the cheap equivalent. + for (const bad of MACRO_NETWORK_NAMES) { + if (ownCalls.has(bad)) { + return `"${bad}" ist nur in Makros erlaubt, nicht in benutzerdefinierten Funktionen.`; + } + } + const graph = new Map>(); for (const f of doc.customFunctions) { if (f.id === ignoreId) continue; @@ -1280,6 +1315,9 @@ function createStore() { if (MACRO_ACTION_NAMES.has(n.toUpperCase())) { return `"${n}" ist der Name einer Makro-Aktion und kann nicht als Name verwendet werden.`; } + if (MACRO_NETWORK_NAMES.has(n.toUpperCase())) { + return `"${n}" ist der Name einer Netzwerk-Funktion und kann nicht als Name verwendet werden.`; + } } const badFor = findInvalidForSource(block); if (badFor) { @@ -1340,20 +1378,25 @@ function createStore() { } /** - * Runs a macro synchronously to completion (see store/types.ts's `Macro` - * doc comment for the "why explicit trigger, not recalc-embedded" design) - * -- a full JS-synchronous run automatically becomes exactly ONE undo - * step, for free, via the existing debounced-fingerprint history (no - * transaction machinery needed, see `commitHistoryStep`'s doc comment). + * Runs a macro to completion (see store/types.ts's `Macro` doc comment + * for the "why explicit trigger, not recalc-embedded" design). `async` + * because a `GET`/`POST` genuinely waits on a real network round-trip -- + * that costs the automatic "one JS turn = one undo step" property a + * fully synchronous run would otherwise get for free, so this function + * restores it explicitly instead (`macroRunActive` suppresses the + * debounced auto-commit for the run's whole duration; the `finally` + * below commits exactly once, however long the run's `await`s took -- + * see `macroRunActive`'s own comment near the history watcher). * - * Both hosts handed to the interpreter re-read the outer `wb` on every - * call rather than capturing it once at the top -- required correctness, - * not a style choice: a structural action (INSERTROW, ADDTABLE, ...) - * calls `syncWorkbook()`, which reassigns `wb` to a brand-new `Workbook` - * instance; a cached reference from before that point would silently go - * stale for every read for the rest of the run. + * All three hosts handed to the interpreter re-read the outer `wb`/`doc` + * on every call rather than capturing anything once at the top -- + * required correctness, not a style choice: a structural action + * (INSERTROW, ADDTABLE, ...) calls `syncWorkbook()`, which reassigns + * `wb` to a brand-new `Workbook` instance; a cached reference from + * before that point would silently go stale for every read for the + * rest of the run. */ - function runMacro(macroId: string): { ok: boolean; message: string } { + async function runMacro(macroId: string): Promise<{ ok: boolean; message: string }> { const macro = doc.macros.find((m) => m.id === macroId); if (!macro) return { ok: false, message: 'Makro nicht gefunden.' }; @@ -1435,16 +1478,64 @@ function createStore() { }, }; + // GET/POST confirmation + fetch. Per-MACRO approval (not per-document + // and not per-session): an imported document with several small macros + // quietly contacting the same host should not have approving one of + // them silently authorize all the others too -- each macro earns its + // own one-time human decision the first time IT reaches a given host, + // persisted in `macro.approvedHosts` so it only ever asks once. The + // host (via `new URL(url).host`, i.e. including a non-default port) is + // recorded BEFORE the fetch itself, so the approval survives even if + // this particular request later fails. + const netHost: MacroNetworkHost = { + async request(method, url, body) { + let host: string; + try { + host = new URL(url).host; + } catch { + throw new Error(`${method}: '${url}' ist keine gültige URL`); + } + if (!macro.approvedHosts?.includes(host)) { + const verb = method === 'POST' ? 'eine POST-Anfrage an' : 'eine GET-Anfrage an'; + if (!confirm(`Makro "${macro.name}" möchte ${verb} "${host}" senden. Erlauben?`)) { + throw new Error(`${method}: Verbindung zu '${host}' abgelehnt`); + } + (macro.approvedHosts ??= []).push(host); + } + let res: Response; + try { + res = await fetch(url, { + method, + headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, + body, + }); + } catch (e) { + throw new Error(`${method} ${url}: ${e instanceof Error ? e.message : String(e)}`); + } + if (!res.ok) { + throw new Error(`${method} ${url}: HTTP ${res.status} ${res.statusText}`); + } + try { + return await res.json(); + } catch { + throw new Error(`${method} ${url}: Antwort ist kein gültiges JSON`); + } + }, + }; + + macroRunActive = true; try { const block = parseMacroBlock(macro.body); const index = buildFunctionIndex(toFunctionDefs(doc)); const expanded = expandMacroBlock(block, index); - const result = runMacroActions(expanded, macroHost, writeHost); + const result = await runMacroActions(expanded, macroHost, writeHost, netHost); return { ok: true, message: typeof result === 'string' ? result : 'Makro ausgeführt.' }; } catch (e) { return { ok: false, message: e instanceof Error ? e.message : String(e) }; } finally { restoreView(); + macroRunActive = false; + commitHistoryStep(); // exactly one step for everything mutated since the run started, any await duration } } diff --git a/src/store/types.ts b/src/store/types.ts index ac1e29a..454dfd2 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -230,7 +230,11 @@ export interface NamedRange { * `document.ts`'s `runMacro`/`validateMacro` and README's "Makros" * section. Takes no parameters (a click carries no arguments) and can * never call another macro (no such action exists) -- both deliberate, - * documented v1 scope cuts. */ + * documented v1 scope cuts. + * + * Also able to reach the network (`GET`/`POST`, `engine/types.ts`'s + * `MacroNetworkHost`) -- a second trust dimension beyond read/write scope, + * gated separately via `approvedHosts` below. */ export interface Macro { id: string; /** document-unique, compared case-insensitively; never a built-in/custom @@ -242,6 +246,14 @@ export interface Macro { * inside must be qualified with a table name (`Umsatz::A1`, never bare * `A1`) -- a macro has no "home table", see `validateMacro`. */ body: string; + /** Hostnames (`host` from `new URL(...)`, i.e. including a non-default + * port) this macro has already been confirmed to contact via `GET`/ + * `POST`, persisted so a human only approves each macro×host pair once, + * ever -- not once per session, not once per document (a different + * macro contacting an already-approved host still prompts separately; + * see `document.ts#runMacro`'s doc comment for the reasoning). Absent/ + * empty for a macro that has never made a network call. */ + approvedHosts?: string[]; } /** One entry in the formula-autocomplete dropdown (see document.ts's