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:
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{ "name": "Nord", "value": 100 },
|
||||||
|
{ "name": "Süd", "value": 200 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
try {
|
||||||
|
const result = await store.runMacro(o.macroId);
|
||||||
if (result.ok) toasts.success(result.message);
|
if (result.ok) toasts.success(result.message);
|
||||||
else toasts.error(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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
// the same row while its run is still in flight.
|
||||||
|
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);
|
if (result.ok) toasts.success(result.message);
|
||||||
else toasts.error(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>
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user