Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFormula, parseFunctionBlock, parseMacroBlock } from '../../engine';
|
||||
import { CODE_REFERENCE, matchesQuery, referenceFor } from '../codeReference';
|
||||
|
||||
describe('CODE_REFERENCE', () => {
|
||||
it('documents every macro write action', () => {
|
||||
const names = new Set(referenceFor('macro').map((e) => e.name));
|
||||
for (const a of [
|
||||
'SETVALUE', 'INSERTROW', 'DELETEROW', 'INSERTCOL', 'DELETECOL', 'ADDTABLE',
|
||||
'RENAMETABLE', 'DELETETABLE', 'ADDSHEET', 'RENAMESHEET', 'DELETESHEET',
|
||||
'GET', 'POST', 'JSONGET',
|
||||
]) {
|
||||
expect(names.has(a)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('every example (except "…" fragments) parses in each context it is listed for', () => {
|
||||
// the parameter names the function examples use; in a function body only
|
||||
// control-flow examples are whole statement sequences, the rest are
|
||||
// expressions (usable anywhere inside one)
|
||||
const params = ['bereich', 'umsatz', 'kosten'];
|
||||
for (const e of CODE_REFERENCE) {
|
||||
if (!e.example || e.example.includes('…')) continue;
|
||||
const ex = e.example;
|
||||
for (const ctx of e.contexts) {
|
||||
const parse =
|
||||
ctx === 'macro'
|
||||
? () => parseMacroBlock(ex)
|
||||
: ctx === 'block' && e.category === 'Ablauf'
|
||||
? () => parseFunctionBlock(ex, params)
|
||||
: () => parseFormula(ex, params);
|
||||
expect(parse, `${ctx} · ${e.name}: ${ex}`).not.toThrow();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('function editors never offer write actions or network calls', () => {
|
||||
for (const ctx of ['block', 'expression'] as const) {
|
||||
const names = referenceFor(ctx).map((e) => e.name);
|
||||
for (const a of ['SETVALUE', 'INSERTROW', 'ADDSHEET', 'GET', 'POST', 'JSONGET']) {
|
||||
expect(names).not.toContain(a);
|
||||
}
|
||||
}
|
||||
// single formulas have no statements at all
|
||||
expect(referenceFor('expression').some((e) => e.category === 'Ablauf')).toBe(false);
|
||||
});
|
||||
|
||||
it('search matches synonyms and requires every word', () => {
|
||||
const hits = (q: string) => referenceFor('macro').filter((e) => matchesQuery(e, q)).map((e) => e.name);
|
||||
expect(hits('zeile einfügen')).toContain('INSERTROW');
|
||||
expect(hits('zeile einfügen')).not.toContain('DELETEROW');
|
||||
expect(hits('schleife')).toContain('FOR … IN … DO … END');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* The searchable command reference shown next to the macro and custom-
|
||||
* function editors (components/CodeReference.vue). Each entry lists the
|
||||
* editing contexts it applies to -- a macro, a "Mit Schleife" function body
|
||||
* or a single-formula function body. Hand-maintained alongside
|
||||
* engine/evaluator.ts's `execAction()` (the write actions), its GET/POST/
|
||||
* JSONGET handling and the statement grammar in engine/parser.ts -- the
|
||||
* formula-function entries are derived from `BUILTIN_SIGNATURES`, so a new
|
||||
* built-in shows up here automatically (with a generic description until
|
||||
* one is added to `FUNCTION_DESCRIPTIONS`).
|
||||
*/
|
||||
import { BUILTIN_SIGNATURES } from '../engine';
|
||||
|
||||
export type RefCategory =
|
||||
| 'Zellen'
|
||||
| 'Zeilen & Spalten'
|
||||
| 'Tabellen'
|
||||
| 'Blätter'
|
||||
| 'Ablauf'
|
||||
| 'Netzwerk & JSON'
|
||||
| 'Operatoren'
|
||||
| 'Formelfunktionen';
|
||||
|
||||
/** Which editor the reference is shown in. */
|
||||
export type RefContext = 'macro' | 'block' | 'expression';
|
||||
|
||||
export interface RefEntry {
|
||||
name: string;
|
||||
/** Editors this entry applies to. */
|
||||
contexts: RefContext[];
|
||||
category: RefCategory;
|
||||
/** Signature as shown in the list. */
|
||||
syntax: string;
|
||||
description: string;
|
||||
/** A complete, working example. */
|
||||
example?: string;
|
||||
/** What "Einfügen" puts into the editor. */
|
||||
snippet: string;
|
||||
/** Extra search terms (German synonyms etc.). */
|
||||
keywords?: string;
|
||||
}
|
||||
|
||||
export const REF_CATEGORIES: RefCategory[] = [
|
||||
'Zellen',
|
||||
'Zeilen & Spalten',
|
||||
'Tabellen',
|
||||
'Blätter',
|
||||
'Ablauf',
|
||||
'Netzwerk & JSON',
|
||||
'Operatoren',
|
||||
'Formelfunktionen',
|
||||
];
|
||||
|
||||
const ACTIONS: RefEntry[] = [
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'SETVALUE',
|
||||
category: 'Zellen',
|
||||
syntax: 'SETVALUE(Ziel; Wert)',
|
||||
description:
|
||||
'Schreibt einen Wert in eine Zelle. Ist das Ziel ein Bereich, erhält jede Zelle darin denselben Wert. Beginnt der Text mit „=", wird daraus eine echte Formel. Das Ziel muss die Tabelle nennen (Umsatz::A1, nie bloß A1).',
|
||||
example: 'SETVALUE(Umsatz::B2; Umsatz::B2 + 1)\nSETVALUE(Umsatz::F2; "=SUM(B2:E2)")',
|
||||
snippet: 'SETVALUE(Tabelle::A1; Wert)',
|
||||
keywords: 'schreiben setzen wert zelle bereich formel eintragen',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'INSERTROW',
|
||||
category: 'Zeilen & Spalten',
|
||||
syntax: 'INSERTROW(Tabelle; Index)',
|
||||
description:
|
||||
'Fügt eine leere Zeile ein. Index zählt ab 0: 0 fügt vor der ersten Zeile ein. Formelbezüge im ganzen Dokument werden wie beim manuellen Einfügen angepasst.',
|
||||
example: 'INSERTROW("Umsatz"; 1)',
|
||||
snippet: 'INSERTROW("Tabelle"; 0)',
|
||||
keywords: 'zeile einfügen hinzufügen neu',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'DELETEROW',
|
||||
category: 'Zeilen & Spalten',
|
||||
syntax: 'DELETEROW(Tabelle; Index)',
|
||||
description: 'Löscht die Zeile mit dem Index (ab 0). Bezüge auf gelöschte Zellen werden zu #REF!.',
|
||||
example: 'DELETEROW("Umsatz"; 5)',
|
||||
snippet: 'DELETEROW("Tabelle"; 0)',
|
||||
keywords: 'zeile löschen entfernen',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'INSERTCOL',
|
||||
category: 'Zeilen & Spalten',
|
||||
syntax: 'INSERTCOL(Tabelle; Index)',
|
||||
description: 'Fügt eine leere Spalte ein. Index zählt ab 0 (0 = vor Spalte A).',
|
||||
example: 'INSERTCOL("Umsatz"; 5)',
|
||||
snippet: 'INSERTCOL("Tabelle"; 0)',
|
||||
keywords: 'spalte einfügen hinzufügen neu',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'DELETECOL',
|
||||
category: 'Zeilen & Spalten',
|
||||
syntax: 'DELETECOL(Tabelle; Index)',
|
||||
description: 'Löscht die Spalte mit dem Index (ab 0, 0 = Spalte A).',
|
||||
example: 'DELETECOL("Umsatz"; 7)',
|
||||
snippet: 'DELETECOL("Tabelle"; 0)',
|
||||
keywords: 'spalte löschen entfernen',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'ADDTABLE',
|
||||
category: 'Tabellen',
|
||||
syntax: 'ADDTABLE(Blatt; Name)',
|
||||
description: 'Legt auf dem Blatt eine neue, leere Tabelle mit diesem Namen an.',
|
||||
example: 'ADDTABLE("Analyse"; "Auswertung")',
|
||||
snippet: 'ADDTABLE("Blatt"; "Name")',
|
||||
keywords: 'tabelle neu anlegen erstellen hinzufügen',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'RENAMETABLE',
|
||||
category: 'Tabellen',
|
||||
syntax: 'RENAMETABLE(Tabelle; NeuerName)',
|
||||
description: 'Benennt eine Tabelle um. Formeln, die sie nennen, werden dokumentweit mit umgeschrieben.',
|
||||
example: 'RENAMETABLE("Umsatz"; "Umsatz 2026")',
|
||||
snippet: 'RENAMETABLE("Tabelle"; "NeuerName")',
|
||||
keywords: 'tabelle umbenennen name ändern',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'DELETETABLE',
|
||||
category: 'Tabellen',
|
||||
syntax: 'DELETETABLE(Tabelle)',
|
||||
description: 'Löscht eine Tabelle. Bezüge darauf werden zu #REF!.',
|
||||
example: 'DELETETABLE("Blatt 2!Entwurf")',
|
||||
snippet: 'DELETETABLE("Tabelle")',
|
||||
keywords: 'tabelle löschen entfernen',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'ADDSHEET',
|
||||
category: 'Blätter',
|
||||
syntax: 'ADDSHEET(Name)',
|
||||
description: 'Legt ein neues, leeres Blatt an. Ist der Name vergeben, wird eine Nummer angehängt.',
|
||||
example: 'ADDSHEET("Auswertung")',
|
||||
snippet: 'ADDSHEET("Name")',
|
||||
keywords: 'blatt neu anlegen erstellen hinzufügen sheet',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'RENAMESHEET',
|
||||
category: 'Blätter',
|
||||
syntax: 'RENAMESHEET(Blatt; NeuerName)',
|
||||
description: 'Benennt ein Blatt um.',
|
||||
example: 'RENAMESHEET("Blatt 2"; "Archiv")',
|
||||
snippet: 'RENAMESHEET("Blatt"; "NeuerName")',
|
||||
keywords: 'blatt umbenennen name ändern sheet',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'DELETESHEET',
|
||||
category: 'Blätter',
|
||||
syntax: 'DELETESHEET(Blatt)',
|
||||
description: 'Löscht ein Blatt samt Inhalt. Das letzte Blatt bleibt immer erhalten.',
|
||||
example: 'DELETESHEET("Archiv")',
|
||||
snippet: 'DELETESHEET("Blatt")',
|
||||
keywords: 'blatt löschen entfernen sheet',
|
||||
},
|
||||
];
|
||||
|
||||
const CONTROL_MACRO: RefEntry[] = [
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'Zuweisung',
|
||||
category: 'Ablauf',
|
||||
syntax: 'name = Ausdruck',
|
||||
description:
|
||||
'Speichert einen Wert unter einem Namen. Der Name bleibt für den restlichen Code sichtbar, auch nach dem Ende eines FOR/IF.',
|
||||
example: 'summe = SUM(Umsatz::B2:B6)\nSETVALUE(Umsatz::B8; summe)',
|
||||
snippet: 'name = Ausdruck',
|
||||
keywords: 'variable zuweisen speichern let',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'FOR … IN … DO … END',
|
||||
category: 'Ablauf',
|
||||
syntax: 'FOR x IN Bereich DO … END',
|
||||
description:
|
||||
'Wiederholt die Anweisungen für jede Zelle eines Bereichs; x ist dabei der jeweilige Zellbezug. Auch über ein JSON-Array (siehe GET).',
|
||||
example: 'FOR z IN Umsatz::B2:B6 DO\n SETVALUE(z; z * 1.1)\nEND',
|
||||
snippet: 'FOR x IN Tabelle::A1:A10 DO\n \nEND',
|
||||
keywords: 'schleife loop wiederholen jede zelle iteration',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'IF … THEN … END',
|
||||
category: 'Ablauf',
|
||||
syntax: 'IF Bedingung THEN … END',
|
||||
description: 'Führt die Anweisungen nur aus, wenn die Bedingung wahr ist. Beliebig verschachtelbar.',
|
||||
example: 'IF Umsatz::B2 > 1000 THEN\n SETVALUE(Umsatz::C2; "Ziel erreicht")\nEND',
|
||||
snippet: 'IF Bedingung THEN\n \nEND',
|
||||
keywords: 'wenn bedingung verzweigung falls',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'IF … THEN … ELSE … END',
|
||||
category: 'Ablauf',
|
||||
syntax: 'IF Bedingung THEN … ELSE … END',
|
||||
description: 'Wie IF, mit einem zweiten Zweig, der läuft, wenn die Bedingung falsch ist.',
|
||||
example: 'IF Umsatz::B2 > 0 THEN\n SETVALUE(Umsatz::C2; "Plus")\nELSE\n SETVALUE(Umsatz::C2; "Minus")\nEND',
|
||||
snippet: 'IF Bedingung THEN\n \nELSE\n \nEND',
|
||||
keywords: 'wenn sonst bedingung verzweigung',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'RETURN',
|
||||
category: 'Ablauf',
|
||||
syntax: 'RETURN "Meldung"',
|
||||
description:
|
||||
'Beendet das Makro sofort, auch mitten in einer Schleife. Der Text erscheint als Erfolgsmeldung statt „Makro ausgeführt.".',
|
||||
example: 'IF Umsatz::A2 = "" THEN RETURN "Nichts zu tun" END',
|
||||
snippet: 'RETURN "Fertig"',
|
||||
keywords: 'abbrechen beenden stop meldung',
|
||||
},
|
||||
];
|
||||
|
||||
const NETWORK: RefEntry[] = [
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'GET',
|
||||
category: 'Netzwerk & JSON',
|
||||
syntax: 'r = GET(URL)',
|
||||
description:
|
||||
'Ruft eine Adresse ab und speichert die JSON-Antwort in r. Nur als ganze rechte Seite einer Zuweisung erlaubt. Beim ersten Kontakt zu einem neuen Host fragt die App einmalig nach.',
|
||||
example: 'r = GET("https://api.example.com/kurse")\nSETVALUE(Kurse::B1; JSONGET(r; "eur.chf"))',
|
||||
snippet: 'r = GET("https://")',
|
||||
keywords: 'http abrufen laden api web url fetch request',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'POST',
|
||||
category: 'Netzwerk & JSON',
|
||||
syntax: 'r = POST(URL; Body)',
|
||||
description:
|
||||
'Sendet den Body-Text als JSON (Content-Type: application/json) und speichert die Antwort in r. Den Body setzt du selbst als Text zusammen, z. B. mit &; Anführungszeichen im Text schreibst du als \\".',
|
||||
example: 'r = POST("https://api.example.com/log"; "{\\"summe\\": " & Umsatz::B8 & "}")',
|
||||
snippet: 'r = POST("https://"; "{}")',
|
||||
keywords: 'http senden api web url request',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'JSONGET',
|
||||
category: 'Netzwerk & JSON',
|
||||
syntax: 'JSONGET(Wert; "pfad")',
|
||||
description:
|
||||
'Liest ein Feld aus einem JSON-Ergebnis. Pfad punktgetrennt, Zahlen indizieren Arrays ("daten.0.name"). Ein fehlender Pfad ergibt null, keinen Fehler.',
|
||||
example: 'r = GET("https://api.example.com/produkte")\nSETVALUE(Kurse::A1; JSONGET(r; "daten.0.name"))',
|
||||
snippet: 'JSONGET(r; "pfad")',
|
||||
keywords: 'json feld lesen pfad objekt',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'FOR über JSON-Array',
|
||||
category: 'Netzwerk & JSON',
|
||||
syntax: 'FOR item IN r DO … END',
|
||||
description: 'Durchläuft die Elemente eines JSON-Arrays (z. B. aus GET oder JSONGET).',
|
||||
example:
|
||||
'r = GET("https://api.example.com/produkte")\nFOR p IN JSONGET(r; "items") DO\n INSERTROW("Produkte"; 1)\n SETVALUE(Produkte::A2; JSONGET(p; "name"))\nEND',
|
||||
snippet: 'FOR item IN JSONGET(r; "pfad") DO\n \nEND',
|
||||
keywords: 'schleife array liste json iteration',
|
||||
},
|
||||
];
|
||||
|
||||
const OPERATORS_MACRO: RefEntry[] = [
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'Rechnen',
|
||||
category: 'Operatoren',
|
||||
syntax: '+ - * / ^',
|
||||
description: 'Grundrechenarten und Potenz. ^ bindet am stärksten, dann * und /, dann + und -.',
|
||||
example: 'SETVALUE(Umsatz::H3; (Umsatz::B2 + Umsatz::C2) / 2)',
|
||||
snippet: ' + ',
|
||||
keywords: 'plus minus mal geteilt potenz rechnen',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'Text verketten',
|
||||
category: 'Operatoren',
|
||||
syntax: 'Text & Text',
|
||||
description: 'Hängt Texte und Zahlen aneinander.',
|
||||
example: 'SETVALUE(Umsatz::A20; "Stand: " & TODAY())',
|
||||
snippet: ' & ',
|
||||
keywords: 'verketten zusammenfügen text string',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'Vergleichen',
|
||||
category: 'Operatoren',
|
||||
syntax: '= <> < <= > >=',
|
||||
description: 'Vergleiche für IF-Bedingungen. Texte werden ohne Groß-/Kleinschreibung verglichen.',
|
||||
example: 'IF Umsatz::B2 >= 1000 THEN … END',
|
||||
snippet: ' >= ',
|
||||
keywords: 'gleich ungleich größer kleiner vergleich',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'Bezüge',
|
||||
category: 'Operatoren',
|
||||
syntax: 'Tabelle::A1 Blatt!Tabelle::A1:B5',
|
||||
description:
|
||||
'In Makros muss jeder Zell- und Bereichsbezug die Tabelle nennen. Namen mit Leerzeichen in einfache Anführungszeichen setzen: \'Umsatz 2026\'::A1.',
|
||||
example: "SETVALUE('Blatt 2'!Kosten::B2; 0)",
|
||||
snippet: 'Tabelle::A1',
|
||||
keywords: 'zelle bereich referenz adresse tabelle blatt',
|
||||
},
|
||||
];
|
||||
|
||||
// Function-body counterparts: same grammar, but no write actions -- examples
|
||||
// compute and RETURN a value instead of writing it. Parameter names (bereich,
|
||||
// umsatz, ...) stand for whatever the function declares.
|
||||
const CONTROL_FUNCTION: RefEntry[] = [
|
||||
{
|
||||
contexts: ['block'],
|
||||
name: 'Zuweisung',
|
||||
category: 'Ablauf',
|
||||
syntax: 'name = Ausdruck',
|
||||
description:
|
||||
'Speichert einen Zwischenwert unter einem Namen. Der Name bleibt für den restlichen Code sichtbar, auch nach dem Ende eines FOR/IF.',
|
||||
example: 'marge = (umsatz - kosten) / umsatz\nRETURN marge * 100',
|
||||
snippet: 'name = Ausdruck',
|
||||
keywords: 'variable zuweisen speichern zwischenwert',
|
||||
},
|
||||
{
|
||||
contexts: ['block'],
|
||||
name: 'FOR … IN … DO … END',
|
||||
category: 'Ablauf',
|
||||
syntax: 'FOR x IN bereich DO … END',
|
||||
description:
|
||||
'Wiederholt die Anweisungen für jedes Element eines Bereichs. Die Quelle ist ein Parameter, eine Zelle oder ein Bereich, kein berechneter Ausdruck.',
|
||||
example: 'ergebnis = 1\nFOR x IN bereich DO\n ergebnis = ergebnis * x\nEND\nRETURN ergebnis',
|
||||
snippet: 'FOR x IN bereich DO\n \nEND',
|
||||
keywords: 'schleife loop wiederholen jedes element iteration',
|
||||
},
|
||||
{
|
||||
contexts: ['block'],
|
||||
name: 'IF … THEN … END',
|
||||
category: 'Ablauf',
|
||||
syntax: 'IF Bedingung THEN … END',
|
||||
description: 'Führt die Anweisungen nur aus, wenn die Bedingung wahr ist. Beliebig verschachtelbar, auch in einer Schleife.',
|
||||
example: 'IF umsatz = 0 THEN RETURN 0 END\nRETURN (umsatz - kosten) / umsatz',
|
||||
snippet: 'IF Bedingung THEN\n \nEND',
|
||||
keywords: 'wenn bedingung verzweigung falls',
|
||||
},
|
||||
{
|
||||
contexts: ['block'],
|
||||
name: 'IF … THEN … ELSE … END',
|
||||
category: 'Ablauf',
|
||||
syntax: 'IF Bedingung THEN … ELSE … END',
|
||||
description: 'Wie IF, mit einem zweiten Zweig, der läuft, wenn die Bedingung falsch ist.',
|
||||
example: 'IF umsatz > kosten THEN\n RETURN "Gewinn"\nELSE\n RETURN "Verlust"\nEND',
|
||||
snippet: 'IF Bedingung THEN\n \nELSE\n \nEND',
|
||||
keywords: 'wenn sonst bedingung verzweigung',
|
||||
},
|
||||
{
|
||||
contexts: ['block'],
|
||||
name: 'RETURN',
|
||||
category: 'Ablauf',
|
||||
syntax: 'RETURN Ausdruck',
|
||||
description:
|
||||
'Liefert den Funktionswert und beendet die Ausführung sofort, auch mitten in einer Schleife oder einem IF-Zweig.',
|
||||
example: 'FOR x IN bereich DO\n IF x < 0 THEN RETURN "negativ" END\nEND\nRETURN "ok"',
|
||||
snippet: 'RETURN ',
|
||||
keywords: 'ergebnis zurückgeben beenden abbrechen',
|
||||
},
|
||||
];
|
||||
|
||||
const OPERATORS_FUNCTION: RefEntry[] = [
|
||||
{
|
||||
contexts: ['block', 'expression'],
|
||||
name: 'Rechnen',
|
||||
category: 'Operatoren',
|
||||
syntax: '+ - * / ^',
|
||||
description: 'Grundrechenarten und Potenz. ^ bindet am stärksten, dann * und /, dann + und -.',
|
||||
example: '(umsatz - kosten) / umsatz * 100',
|
||||
snippet: ' + ',
|
||||
keywords: 'plus minus mal geteilt potenz rechnen',
|
||||
},
|
||||
{
|
||||
contexts: ['block', 'expression'],
|
||||
name: 'Text verketten',
|
||||
category: 'Operatoren',
|
||||
syntax: 'Text & Text',
|
||||
description: 'Hängt Texte und Zahlen aneinander.',
|
||||
example: 'CONCAT("Marge: "; (umsatz - kosten) / umsatz * 100) & " %"',
|
||||
snippet: ' & ',
|
||||
keywords: 'verketten zusammenfügen text string',
|
||||
},
|
||||
{
|
||||
contexts: ['block', 'expression'],
|
||||
name: 'Vergleichen',
|
||||
category: 'Operatoren',
|
||||
syntax: '= <> < <= > >=',
|
||||
description: 'Vergleiche für IF. Texte werden ohne Groß-/Kleinschreibung verglichen.',
|
||||
example: 'IF(umsatz >= kosten; "Gewinn"; "Verlust")',
|
||||
snippet: ' >= ',
|
||||
keywords: 'gleich ungleich größer kleiner vergleich',
|
||||
},
|
||||
{
|
||||
contexts: ['block', 'expression'],
|
||||
name: 'Bereich als Parameter',
|
||||
category: 'Operatoren',
|
||||
syntax: 'SUM(bereich)',
|
||||
description:
|
||||
'Ein Parameter kann ein Bereich sein, wenn er direkt an eine Funktion wie SUM, AVG, MIN oder MAX geht. Aufruf dann z. B. =SPANNE(Umsatz!B2:E6).',
|
||||
example: 'MAX(bereich) - MIN(bereich)',
|
||||
snippet: 'SUM(bereich)',
|
||||
keywords: 'bereich parameter range argument',
|
||||
},
|
||||
];
|
||||
|
||||
const FUNCTION_DESCRIPTIONS: Record<string, string> = {
|
||||
SUM: 'Summe der Werte.',
|
||||
AVG: 'Mittelwert der Werte.',
|
||||
AVERAGE: 'Mittelwert der Werte (wie AVG).',
|
||||
MIN: 'Kleinster Wert.',
|
||||
MAX: 'Größter Wert.',
|
||||
COUNT: 'Anzahl der Zahlen.',
|
||||
COUNT2: 'Anzahl der nicht leeren Zellen.',
|
||||
CONCAT: 'Verkettet Texte.',
|
||||
CONCATENATE: 'Verkettet Texte (wie CONCAT).',
|
||||
IF: 'Wählt je nach Bedingung einen von zwei Werten.',
|
||||
AND: 'Wahr, wenn alle Bedingungen wahr sind.',
|
||||
OR: 'Wahr, wenn mindestens eine Bedingung wahr ist.',
|
||||
NOT: 'Kehrt eine Bedingung um.',
|
||||
DATE: 'Datum aus Jahr, Monat und Tag.',
|
||||
TIME: 'Uhrzeit aus Stunden, Minuten und Sekunden.',
|
||||
TODAY: 'Heutiges Datum.',
|
||||
NOW: 'Aktuelles Datum mit Uhrzeit.',
|
||||
XLOOKUP: 'Sucht einen Wert und gibt den passenden Eintrag aus einem zweiten Bereich zurück.',
|
||||
YEAR: 'Jahr eines Datums.',
|
||||
MONTH: 'Monat eines Datums (1–12).',
|
||||
DAY: 'Tag des Monats (1–31).',
|
||||
HOUR: 'Stunde einer Uhrzeit (0–23).',
|
||||
MINUTE: 'Minute einer Uhrzeit (0–59).',
|
||||
SECOND: 'Sekunde einer Uhrzeit (0–59).',
|
||||
WEEKDAY: 'Wochentag nach ISO: Montag = 1 … Sonntag = 7. Typ 1: Sonntag = 1, Typ 3: Montag = 0.',
|
||||
ISOWEEKNUM: 'Kalenderwoche nach ISO 8601 (Woche 1 enthält den ersten Donnerstag des Jahres).',
|
||||
WEEKNUM: 'Kalenderwoche, standardmäßig nach ISO. Typ 1/2: US-Zählung mit Wochenbeginn Sonntag/Montag.',
|
||||
EDATE: 'Gleicher Tag einige Monate später (negativ: früher), am Monatsende gekürzt.',
|
||||
EOMONTH: 'Letzter Tag des Monats, einige Monate vor oder nach dem Datum.',
|
||||
DAYS: 'Anzahl Kalendertage zwischen zwei Daten.',
|
||||
DATEDIF: 'Abstand zweier Daten in "Y" Jahren, "M" Monaten, "D" Tagen oder als Rest "YM", "MD", "YD".',
|
||||
NETWORKDAYS: 'Arbeitstage (Mo–Fr) zwischen zwei Daten, beide eingeschlossen, ohne Feiertage.',
|
||||
WORKDAY: 'Datum nach einer Anzahl Arbeitstage (Mo–Fr), Feiertage werden übersprungen.',
|
||||
DATEVALUE: 'Datum aus einem Text wie "2026-09-02" oder "02.09.2026".',
|
||||
TIMEVALUE: 'Uhrzeit aus einem Text wie "14:30".',
|
||||
};
|
||||
|
||||
/** German search terms for built-ins whose description doesn't already say it. */
|
||||
const FUNCTION_KEYWORDS: Record<string, string> = {
|
||||
SUM: 'summe addieren',
|
||||
AVG: 'durchschnitt mittelwert',
|
||||
AVERAGE: 'durchschnitt mittelwert',
|
||||
COUNT: 'anzahl zählen',
|
||||
COUNT2: 'anzahl zählen',
|
||||
IF: 'wenn falls bedingung',
|
||||
AND: 'und',
|
||||
OR: 'oder',
|
||||
NOT: 'nicht',
|
||||
TODAY: 'heute datum',
|
||||
NOW: 'jetzt zeit',
|
||||
XLOOKUP: 'suchen sverweis verweis nachschlagen',
|
||||
YEAR: 'jahr datum',
|
||||
MONTH: 'monat datum',
|
||||
DAY: 'tag datum',
|
||||
HOUR: 'stunde zeit uhrzeit',
|
||||
MINUTE: 'minute zeit uhrzeit',
|
||||
SECOND: 'sekunde zeit uhrzeit',
|
||||
WEEKDAY: 'wochentag tag woche',
|
||||
ISOWEEKNUM: 'kalenderwoche kw woche',
|
||||
WEEKNUM: 'kalenderwoche kw woche',
|
||||
EDATE: 'monat addieren datum',
|
||||
EOMONTH: 'monatsende ultimo',
|
||||
DAYS: 'tage differenz abstand',
|
||||
DATEDIF: 'differenz abstand alter jahre monate tage',
|
||||
NETWORKDAYS: 'arbeitstage werktage feiertage',
|
||||
WORKDAY: 'arbeitstag werktag feiertage frist',
|
||||
DATEVALUE: 'datum text umwandeln',
|
||||
TIMEVALUE: 'zeit uhrzeit text umwandeln',
|
||||
};
|
||||
|
||||
const FUNCTIONS: RefEntry[] = BUILTIN_SIGNATURES.map((s) => ({
|
||||
contexts: ['macro', 'block', 'expression'],
|
||||
name: s.name,
|
||||
category: 'Formelfunktionen' as const,
|
||||
syntax: s.hint,
|
||||
description: FUNCTION_DESCRIPTIONS[s.name] ?? 'Formelfunktion.',
|
||||
keywords: FUNCTION_KEYWORDS[s.name],
|
||||
snippet: `${s.name}()`,
|
||||
}));
|
||||
|
||||
export const CODE_REFERENCE: RefEntry[] = [
|
||||
...ACTIONS,
|
||||
...CONTROL_MACRO,
|
||||
...CONTROL_FUNCTION,
|
||||
...NETWORK,
|
||||
...OPERATORS_MACRO,
|
||||
...OPERATORS_FUNCTION,
|
||||
...FUNCTIONS,
|
||||
];
|
||||
|
||||
export const referenceFor = (ctx: RefContext): RefEntry[] =>
|
||||
CODE_REFERENCE.filter((e) => e.contexts.includes(ctx));
|
||||
|
||||
/** Case-insensitive match over name, syntax, description and keywords. */
|
||||
export function matchesQuery(
|
||||
e: Pick<RefEntry, 'name' | 'syntax' | 'description' | 'keywords'>,
|
||||
query: string,
|
||||
): boolean {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return true;
|
||||
const hay = `${e.name} ${e.syntax} ${e.description} ${e.keywords ?? ''}`.toLowerCase();
|
||||
return words.every((w) => hay.includes(w));
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Document templates: `createBlankDocument()` (Datei › Neu, and what a first
|
||||
* start opens) and `createSeedDocument()` (Datei › Muster).
|
||||
*
|
||||
* The pre-filled document ("roter Faden" of the study), in the v2 canvas model:
|
||||
* one sheet ("Analyse") carrying three table objects and a chart object, plus a
|
||||
* second empty sheet to try things out.
|
||||
*
|
||||
* Note on the brief: the acceptance list mentions "Übersicht!B2 = 6070" as the
|
||||
* yearly Nord revenue, but the given Nord row is 1200 + 1450 + 1600 + 1720 =
|
||||
* 5970. The explicit table wins -- the exact number is deterministic and the
|
||||
* propagation behaviour it demonstrates is unchanged.
|
||||
*/
|
||||
|
||||
import { parseA1 } from '../engine';
|
||||
import type { ChartObject, DocumentState, TableObject } from '../store/types';
|
||||
import { defaultTableView } from '../store/types';
|
||||
|
||||
let uid = 0;
|
||||
const nextId = (p: string) => `${p}-seed-${(uid++).toString(36)}`;
|
||||
|
||||
function makeTable(
|
||||
name: string,
|
||||
x: number,
|
||||
y: number,
|
||||
rows: number,
|
||||
cols: number,
|
||||
entries: Array<[string, string | number]>,
|
||||
colWidths: Record<number, number> = {},
|
||||
viewOverride: Partial<TableObject['view']> = {},
|
||||
): TableObject {
|
||||
const cells: Record<string, string> = {};
|
||||
for (const [a1, value] of entries) {
|
||||
const { col, row } = parseA1(a1);
|
||||
cells[`${col},${row}`] = String(value);
|
||||
}
|
||||
return {
|
||||
kind: 'table',
|
||||
id: nextId('tbl'),
|
||||
name,
|
||||
x,
|
||||
y,
|
||||
rows,
|
||||
cols,
|
||||
colWidths,
|
||||
cells,
|
||||
view: { ...defaultTableView(), ...viewOverride },
|
||||
};
|
||||
}
|
||||
|
||||
export function createSeedDocument(): DocumentState {
|
||||
const umsatz = makeTable('Umsatz', 40, 40, 20, 8, [
|
||||
['A1', 'Region'], ['B1', 'Q1'], ['C1', 'Q2'], ['D1', 'Q3'], ['E1', 'Q4'],
|
||||
['A2', 'Nord'], ['B2', 1200], ['C2', 1450], ['D2', 1600], ['E2', 1720],
|
||||
['A3', 'Süd'], ['B3', 980], ['C3', 1100], ['D3', 1250], ['E3', 1310],
|
||||
['A4', 'Ost'], ['B4', 1550], ['C4', 1480], ['D4', 1620], ['E4', 1700],
|
||||
['A5', 'West'], ['B5', 1340], ['C5', 1390], ['D5', 1500], ['E5', 1580],
|
||||
['A6', 'Zentral'], ['B6', 1105], ['C6', 1205], ['D6', 1298], ['E6', 1402],
|
||||
['A8', 'Summe'],
|
||||
['B8', '=SUM(B2:B6)'], ['C8', '=SUM(C2:C6)'], ['D8', '=SUM(D2:D6)'], ['E8', '=SUM(E2:E6)'],
|
||||
['A9', 'Mittelwert'],
|
||||
['B9', '=AVG(B2:B6)'], ['C9', '=AVG(C2:C6)'], ['D9', '=AVG(D2:D6)'], ['E9', '=AVG(E2:E6)'],
|
||||
['A10', 'Minimum'],
|
||||
['B10', '=MIN(B2:B6)'], ['C10', '=MIN(C2:C6)'], ['D10', '=MIN(D2:D6)'], ['E10', '=MIN(E2:E6)'],
|
||||
['A11', 'Maximum'],
|
||||
['B11', '=MAX(B2:B6)'], ['C11', '=MAX(C2:C6)'], ['D11', '=MAX(D2:D6)'], ['E11', '=MAX(E2:E6)'],
|
||||
['A12', 'Anzahl'],
|
||||
['B12', '=COUNT(B2:B6)'], ['C12', '=COUNT(C2:C6)'], ['D12', '=COUNT(D2:D6)'], ['E12', '=COUNT(E2:E6)'],
|
||||
['G2', 'Jahr Nord'], ['H2', '=SUM(B2:E2)'],
|
||||
['G3', 'Schnitt/Q'], ['H3', '=SUM(B2:E2)/4'],
|
||||
['G4', 'Delta Q4-Q1'], ['H4', '=E2-B2'],
|
||||
], { 0: 92, 6: 96 }, { mode: 'fixed', width: 545, height: 360, frozenRows: 1, frozenCols: 1 });
|
||||
|
||||
const kosten = makeTable('Kosten', 40, 700, 12, 8, [
|
||||
['A1', 'Region'], ['B1', 'Q1'], ['C1', 'Q2'], ['D1', 'Q3'], ['E1', 'Q4'],
|
||||
['A2', 'Nord'], ['B2', 800], ['C2', 820], ['D2', 900], ['E2', 880],
|
||||
['A3', 'Süd'], ['B3', 700], ['C3', 690], ['D3', 710], ['E3', 720],
|
||||
['A4', 'Ost'], ['B4', 900], ['C4', 910], ['D4', 950], ['E4', 980],
|
||||
['A5', 'West'], ['B5', 760], ['C5', 770], ['D5', 780], ['E5', 790],
|
||||
['A6', 'Zentral'], ['B6', 640], ['C6', 650], ['D6', 660], ['E6', 670],
|
||||
], { 0: 92 });
|
||||
|
||||
const regions = ['Nord', 'Süd', 'Ost', 'West', 'Zentral'];
|
||||
const overview: Array<[string, string | number]> = [
|
||||
['A1', 'Region'], ['B1', 'Umsatz Jahr'], ['C1', 'Kosten Jahr'],
|
||||
['D1', 'Deckungsbeitrag'], ['E1', 'Marge %'],
|
||||
];
|
||||
regions.forEach((region, i) => {
|
||||
const r = i + 2;
|
||||
overview.push(
|
||||
[`A${r}`, region],
|
||||
[`B${r}`, `=SUM(Umsatz!B${r}:E${r})`],
|
||||
[`C${r}`, `=SUM(Kosten!B${r}:E${r})`],
|
||||
[`D${r}`, `=B${r}-C${r}`],
|
||||
[`E${r}`, `=D${r}/B${r}*100`],
|
||||
);
|
||||
});
|
||||
overview.push(
|
||||
['A8', 'Gesamt'],
|
||||
['B8', '=SUM(B2:B6)'], ['C8', '=SUM(C2:C6)'],
|
||||
['D8', '=SUM(D2:D6)'], ['E8', '=AVG(E2:E6)'],
|
||||
);
|
||||
const uebersicht = makeTable('Übersicht', 1000, 40, 12, 6, overview, {
|
||||
0: 84, 3: 118,
|
||||
});
|
||||
|
||||
const chart: ChartObject = {
|
||||
kind: 'chart',
|
||||
id: nextId('cht'),
|
||||
name: 'Umsatz je Region',
|
||||
x: 1000,
|
||||
y: 460,
|
||||
width: 560,
|
||||
height: 340,
|
||||
view: 'bar',
|
||||
sourceTableId: umsatz.id,
|
||||
};
|
||||
|
||||
const analyse = {
|
||||
id: nextId('sheet'),
|
||||
name: 'Analyse',
|
||||
objects: [umsatz, kosten, uebersicht, chart],
|
||||
};
|
||||
// second sheet demos the per-sheet backdrop colour + hidden grid
|
||||
const blatt2 = {
|
||||
id: nextId('sheet'),
|
||||
name: 'Blatt 2',
|
||||
objects: [],
|
||||
background: '#f4f1ff',
|
||||
showGrid: false,
|
||||
};
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
sheets: [analyse, blatt2],
|
||||
activeSheetId: analyse.id,
|
||||
activeObjectId: umsatz.id,
|
||||
selection: { tableId: umsatz.id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } },
|
||||
locale: 'de-DE',
|
||||
csvDelimiter: ',',
|
||||
customFunctions: [],
|
||||
namedRanges: [
|
||||
// demos "🏷 Namen": =Umsatz_Q1 anywhere resolves to Umsatz!B2:B6, even
|
||||
// from a different sheet, without a table qualifier.
|
||||
{ id: nextId('nr'), name: 'Umsatz_Q1', tableId: umsatz.id, start: { col: 1, row: 1 }, end: { col: 1, row: 5 } },
|
||||
],
|
||||
macros: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** An empty document: one sheet with one empty table, ready to type into. */
|
||||
export function createBlankDocument(): DocumentState {
|
||||
const table = makeTable('Tabelle 1', 40, 40, 12, 6, []);
|
||||
const sheet = { id: nextId('sheet'), name: 'Blatt 1', objects: [table] };
|
||||
return {
|
||||
version: 2,
|
||||
sheets: [sheet],
|
||||
activeSheetId: sheet.id,
|
||||
activeObjectId: table.id,
|
||||
selection: { tableId: table.id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } },
|
||||
locale: 'de-DE',
|
||||
csvDelimiter: ',',
|
||||
customFunctions: [],
|
||||
namedRanges: [],
|
||||
macros: [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user