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>
206 lines
8.9 KiB
TypeScript
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>;
|
|
}
|