Files
numolus/src/engine/types.ts
T
wkhandClaude Sonnet 5 3ae3d81066 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>
2026-09-09 11:10:19 +02:00

206 lines
8.9 KiB
TypeScript

/**
* Shared types for the formula engine.
*
* The engine is intentionally framework-agnostic: it knows nothing about Vue,
* the store or the DOM. The host application implements {@link EngineHost} to
* expose the current document to the evaluator.
*/
/** All error values the engine can produce. They propagate to dependent cells. */
export type ErrorCode =
| '#REF' // invalid / deleted reference or unknown sheet name
| '#DIV/0!' // division by zero
| '#NAME?' // unknown function
| '#ERR' // parse error
| '#CIRC' // circular reference
| '#VALUE!' // wrong value type in arithmetic (documented extension)
| '#N/A'; // XLOOKUP: no matching entry and no if_not_found given (documented extension)
export const ERROR_CODES: ErrorCode[] = [
'#REF',
'#DIV/0!',
'#NAME?',
'#ERR',
'#CIRC',
'#VALUE!',
'#N/A',
];
/** A formula error. Instances are immutable and comparable by `code`. */
export class FormulaError {
constructor(
public readonly code: ErrorCode,
public readonly detail?: string,
) {}
toString(): string {
return this.code;
}
}
export function isError(v: unknown): v is FormulaError {
return v instanceof FormulaError;
}
/** A plain scalar a cell can hold once evaluated. `null` means "empty cell". */
export type Scalar = number | string | null;
/** The fully evaluated value of a cell. */
export type CellValue = Scalar | FormulaError;
/** 0-based cell coordinate inside a single table. */
export interface Coord {
col: number;
row: number;
}
/** A cell address that also carries the id of the table it lives on. */
export interface CellRef extends Coord {
tableId: string;
}
/** A document-defined name resolving to a fixed cell/range on a specific
* table -- fed into {@link Workbook} (see workbook.ts) so it can implement
* {@link EngineHost.resolveNamedRange}. Unlike a custom function, there is
* no expression/formula of its own, just fixed coordinates -- so there is
* nothing that could cyclically reference itself. `tableId` (not a table
* *name*) so a named range survives its target table being renamed; the
* host resolves it to that table's *current* name on lookup, the same
* qualifier a formula's own `Tabelle!Zelle` reference would carry. */
export interface NamedRangeDef {
id: string;
name: string;
tableId: string;
start: Coord;
end: Coord;
}
/** Stable string key for a cell across the whole document. */
export function keyOf(tableId: string, col: number, row: number): string {
return `${tableId}␟${col}␟${row}`;
}
export function parseKey(key: string): CellRef {
const [tableId, col, row] = key.split('␟');
return { tableId, col: Number(col), row: Number(row) };
}
/**
* The document as seen by the engine. Implemented by the Vue store.
*
* The addressable unit is a **table** (a table object on a Numbers-style
* canvas). Table names are only unique *per sheet* -- two different sheets
* may each hold a table called "Umsatz". An unqualified table reference
* (`Umsatz!B2` / `Umsatz::B2`, no sheet given) resolves to whichever table
* named "Umsatz" lives on the *same sheet* as the formula doing the
* referencing, falling back to the single document-wide match when the name
* isn't ambiguous there. The explicit 3-part form (`Analyse!Umsatz::B2`)
* disambiguates when it would otherwise be genuinely ambiguous (name used on
* two *other* sheets, neither of them the formula's own).
*/
export interface EngineHost {
/** Id of the table the formula currently being evaluated lives on. */
homeTableId: string;
/**
* Resolve a table *name* (as written in a formula) to its id, optionally
* narrowed to a specific *sheet* name (the "Blatt!" part of a 3-part
* `Blatt!Tabelle::Zelle` reference). Matching is case-insensitive and
* tolerant of surrounding whitespace.
*
* - `sheetName` given: only a table with that name **on that sheet**
* matches; otherwise `null` (including when the sheet itself is unknown).
* - `sheetName` omitted/`null`: matches a table by name anywhere in the
* document. Exactly one match resolves as before. Multiple matches (the
* name exists on more than one sheet) prefer a table on the same sheet
* as {@link homeTableId}; if that still doesn't narrow it to one, `null`
* (`#REF` -- the formula needs an explicit sheet qualifier).
*/
resolveTableName(name: string, sheetName?: string | null): string | null;
/**
* Resolve a *named range* (as written, unqualified, in a formula -- e.g.
* `Umsatz_Q1`) to the table-qualified cell/range it currently stands for.
* Matching is case-insensitive and tolerant of surrounding whitespace,
* same as {@link resolveTableName}. `null` when the name is unknown (or
* its target table has since been deleted) -- the caller turns that into
* `#NAME?`, the same error class an unknown function name gets.
*
* Resolved by *name*, live, every time -- never baked into the AST -- so a
* named range survives its target table being renamed, the same way a
* `Tabelle!Zelle` reference's table name is looked up fresh on every
* evaluation rather than resolved once at parse time.
*/
resolveNamedRange(
name: string,
): { qualifier: string; sheetQualifier?: string; start: Coord; end: Coord } | null;
/** Dimensions of a table, used for range clamping and #REF detection. */
tableSize(tableId: string): { cols: number; rows: number } | null;
/**
* Already-computed value of another cell. The recalculation driver guarantees
* precedents are evaluated first (topological order), so this never triggers
* a nested evaluation.
*/
getComputed(tableId: string, col: number, row: number): CellValue;
}
/**
* The document's WRITE surface, as seen by a running macro (see
* evaluator.ts's `runMacroActions`/`execAction` and engine/README.md's
* "Makros" section). Deliberately separate from {@link EngineHost} -- that
* interface stays pure/read-only, implemented by `Workbook` alone, used by
* every ordinary formula evaluation; `MacroWriteHost` is only ever
* implemented by the store (`document.ts#runMacro`), only ever passed to
* the interpreter for an explicitly-triggered macro run, and never reaches
* the passive recalculation pass at all -- a macro's write is
* indistinguishable, from the engine's point of view, from the same edit
* made by hand through the UI.
*
* Every table/sheet argument below is a plain qualifier STRING (e.g.
* `"Umsatz"` or `"Blatt2!Kosten"`, split on the first `!`) rather than a
* resolved id or a dedicated reference AST node -- resolved fresh, by the
* implementation, exactly like a formula's own `Tabelle!Zelle` qualifier
* is resolved fresh on every read (never cached), so a macro that renames
* or adds a table earlier in its own run can address it by its new name
* later in the same run.
*/
export interface MacroWriteHost {
/** Write the same raw cell text (auto-detected as a formula if it starts
* with "=", exactly like typing it manually) into every given cell --
* one cell for a `SETVALUE` on a single reference, several for a range
* target (broadcasting the same value, Excel's Ctrl+Enter semantics). */
setValue(cells: CellRef[], text: string): void;
insertRow(tableRef: string, at: number): void;
deleteRow(tableRef: string, at: number): void;
insertCol(tableRef: string, at: number): void;
deleteCol(tableRef: string, at: number): void;
/** `sheetRef` is a plain sheet name (not table-qualified). */
addTable(sheetRef: string, name: string): void;
renameTable(tableRef: string, newName: string): void;
deleteTable(tableRef: string): void;
addSheet(name: string): void;
renameSheet(sheetRef: string, newName: string): void;
deleteSheet(sheetRef: string): void;
}
/**
* The document's NETWORK surface, as seen by a running macro's `GET`/`POST`
* assignment form (`x = GET(url)` / `x = POST(url; body)`; see evaluator.
* ts's `execStatementAsync`). Kept separate from {@link MacroWriteHost} for
* the same reason that one is kept separate from {@link EngineHost}: this
* engine module stays framework/DOM-agnostic (no `fetch`, no browser APIs
* anywhere in `src/engine/`) -- the store (`document.ts#runMacro`) is the
* only implementer, and it's the one place a network confirmation prompt
* and the actual `fetch()` call belong.
*
* A single `request()` method (not separate `get`/`post` methods) because
* both share the exact same "confirm this host once, then fetch and parse
* JSON" flow in the implementation -- only the HTTP method and whether a
* body is sent differ.
*/
export interface MacroNetworkHost {
/** Resolves with the parsed JSON response body, or rejects with a plain
* `Error` (network failure, a non-2xx status, a response that isn't
* valid JSON, or the user declining a host-confirmation prompt) --
* caught the same way as any other macro action failure, aborting the
* run and surfacing the message as the result's `.message`. */
request(method: 'GET' | 'POST', url: string, body?: string): Promise<unknown>;
}