Searchable command reference in the macro and function editors
Both editors get a side panel listing everything that can go into the code: search (incl. German synonyms), category chips, expandable entries with description and a complete example, and "Einfügen" to put a snippet or the example at the caret. The list adapts to the editor -- macros see write actions, GET/POST/JSONGET and the document's sheets/tables; "Mit Schleife" functions see FOR/IF/RETURN; single formulas only operators and functions; function editors also list their own parameters. The catalog lives in src/data/codeReference.ts (built-in functions derived from BUILTIN_SIGNATURES). Tests check that every write action is documented, that function editors never offer write/network commands, and that every example parses in each context it is shown in. BaseModal gains a `wide` prop for the two-column edit layout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch } from 'vue';
|
import { watch } from 'vue';
|
||||||
|
|
||||||
const props = defineProps<{ open: boolean; title: string }>();
|
const props = defineProps<{ open: boolean; title: string; wide?: boolean }>();
|
||||||
const emit = defineEmits<{ (e: 'close'): void }>();
|
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
@@ -19,7 +19,7 @@ watch(
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="open" class="backdrop" @click.self="emit('close')">
|
<div v-if="open" class="backdrop" @click.self="emit('close')">
|
||||||
<div class="modal" role="dialog" aria-modal="true">
|
<div class="modal" :class="{ wide }" role="dialog" aria-modal="true">
|
||||||
<header>
|
<header>
|
||||||
<h2>{{ title }}</h2>
|
<h2>{{ title }}</h2>
|
||||||
<button class="x" @click="emit('close')">×</button>
|
<button class="x" @click="emit('close')">×</button>
|
||||||
@@ -49,6 +49,9 @@ watch(
|
|||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.modal.wide {
|
||||||
|
width: min(920px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Searchable command reference next to the macro editor (MacrosModal.vue)
|
||||||
|
* and the custom-function editor (CustomFunctionsModal.vue). Static entries
|
||||||
|
* come from data/codeReference.ts, filtered to `context`; the "Im Dokument"
|
||||||
|
* category is built live from the current document (tables and sheets for
|
||||||
|
* macros, named ranges, custom functions) so their exact, correctly quoted
|
||||||
|
* names can be inserted with one click, and a function's own parameters get
|
||||||
|
* a "Parameter" category. Emits `insert` with the text to put at the
|
||||||
|
* editor's caret.
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useDocument } from '../store/document';
|
||||||
|
import {
|
||||||
|
REF_CATEGORIES,
|
||||||
|
matchesQuery,
|
||||||
|
referenceFor,
|
||||||
|
type RefContext,
|
||||||
|
type RefEntry,
|
||||||
|
} from '../data/codeReference';
|
||||||
|
|
||||||
|
/** A listed entry -- static ones are already filtered to `context`. */
|
||||||
|
type Entry = Omit<RefEntry, 'contexts'>;
|
||||||
|
|
||||||
|
const props = defineProps<{ context: RefContext; params?: string[] }>();
|
||||||
|
const emit = defineEmits<{ (e: 'insert', text: string): void }>();
|
||||||
|
|
||||||
|
const store = useDocument();
|
||||||
|
|
||||||
|
const DOC_CATEGORY = 'Im Dokument';
|
||||||
|
const PARAM_CATEGORY = 'Parameter';
|
||||||
|
type Category = (typeof REF_CATEGORIES)[number] | typeof DOC_CATEGORY | typeof PARAM_CATEGORY;
|
||||||
|
|
||||||
|
const query = ref('');
|
||||||
|
const category = ref<Category | null>(null);
|
||||||
|
const expanded = ref<string | null>(null);
|
||||||
|
|
||||||
|
/** Quote a table/sheet name for a reference qualifier when it isn't a bare word. */
|
||||||
|
const qualifier = (name: string) => (/^[\p{L}_][\p{L}\d_]*$/u.test(name) ? name : `'${name.replace(/'/g, "''")}'`);
|
||||||
|
|
||||||
|
const docEntries = computed<Entry[]>(() => {
|
||||||
|
const d = store.doc;
|
||||||
|
const out: Entry[] = [];
|
||||||
|
const tableCount = new Map<string, number>();
|
||||||
|
for (const s of d.sheets)
|
||||||
|
for (const o of s.objects)
|
||||||
|
if (o.kind === 'table') tableCount.set(o.name.toLowerCase(), (tableCount.get(o.name.toLowerCase()) ?? 0) + 1);
|
||||||
|
// sheets/tables are only ever named (as text or qualifier) by a macro
|
||||||
|
for (const s of props.context === 'macro' ? d.sheets : []) {
|
||||||
|
out.push({
|
||||||
|
name: s.name,
|
||||||
|
category: 'Blätter',
|
||||||
|
syntax: `"${s.name}"`,
|
||||||
|
description: 'Blatt in diesem Dokument — als Text für ADDTABLE, RENAMESHEET, DELETESHEET.',
|
||||||
|
snippet: `"${s.name}"`,
|
||||||
|
keywords: 'blatt sheet',
|
||||||
|
});
|
||||||
|
for (const o of s.objects) {
|
||||||
|
if (o.kind !== 'table') continue;
|
||||||
|
// a table name used on several sheets must carry its sheet
|
||||||
|
const ambiguous = (tableCount.get(o.name.toLowerCase()) ?? 0) > 1;
|
||||||
|
const textRef = ambiguous ? `${s.name}!${o.name}` : o.name;
|
||||||
|
const cellRef = (ambiguous ? `${qualifier(s.name)}!` : '') + `${qualifier(o.name)}::A1`;
|
||||||
|
out.push({
|
||||||
|
name: o.name,
|
||||||
|
category: 'Tabellen',
|
||||||
|
syntax: `"${textRef}" ${cellRef}`,
|
||||||
|
description: `Tabelle auf Blatt „${s.name}" (${o.rows} × ${o.cols}). Als Text für Zeilen-/Spalten-/Tabellen-Aktionen, mit ::A1 als Zellbezug.`,
|
||||||
|
snippet: `"${textRef}"`,
|
||||||
|
example: `INSERTROW("${textRef}"; 0)\nSETVALUE(${cellRef}; "Neu")`,
|
||||||
|
keywords: 'tabelle table',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const nr of d.namedRanges) {
|
||||||
|
out.push({
|
||||||
|
name: nr.name,
|
||||||
|
category: 'Zellen',
|
||||||
|
syntax: nr.name,
|
||||||
|
description: 'Benannter Bereich — überall als Bezug nutzbar, auch als SETVALUE-Ziel.',
|
||||||
|
snippet: nr.name,
|
||||||
|
keywords: 'name bereich benannt',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const fn of d.customFunctions) {
|
||||||
|
out.push({
|
||||||
|
name: fn.name,
|
||||||
|
category: 'Formelfunktionen',
|
||||||
|
syntax: `${fn.name}(${fn.params.join('; ')})`,
|
||||||
|
description: 'Eigene Funktion dieses Dokuments — berechnet einen Wert, schreibt selbst nie.',
|
||||||
|
snippet: `${fn.name}(${fn.params.join('; ')})`,
|
||||||
|
keywords: 'eigene funktion benutzerdefiniert',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
const paramEntries = computed<Entry[]>(() =>
|
||||||
|
(props.params ?? [])
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((p) => ({
|
||||||
|
name: p,
|
||||||
|
category: 'Operatoren',
|
||||||
|
syntax: p,
|
||||||
|
description: 'Parameter dieser Funktion — steht für den Wert (oder Bereich), mit dem sie aufgerufen wird.',
|
||||||
|
snippet: p,
|
||||||
|
keywords: 'parameter argument',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const available = computed<Array<Entry & { group: Category }>>(() => [
|
||||||
|
...paramEntries.value.map((e) => ({ ...e, group: PARAM_CATEGORY as Category })),
|
||||||
|
...referenceFor(props.context).map((e) => ({ ...e, group: e.category as Category })),
|
||||||
|
...docEntries.value.map((e) => ({ ...e, group: DOC_CATEGORY as Category })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Only categories that have something in this context, in display order. */
|
||||||
|
const categories = computed<Category[]>(() =>
|
||||||
|
([PARAM_CATEGORY, ...REF_CATEGORIES, DOC_CATEGORY] as Category[]).filter((c) =>
|
||||||
|
available.value.some((e) => e.group === c),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// switching "Mit Schleife" -> "Einfache Formel" can remove the chosen category
|
||||||
|
watch(categories, (cs) => {
|
||||||
|
if (category.value && !cs.includes(category.value)) category.value = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const placeholder = computed(() =>
|
||||||
|
props.context === 'macro'
|
||||||
|
? 'Befehl suchen … (z. B. zeile, schleife, json)'
|
||||||
|
: 'Suchen … (z. B. summe, schleife, wenn)',
|
||||||
|
);
|
||||||
|
|
||||||
|
const entries = computed(() =>
|
||||||
|
available.value.filter((e) => (!category.value || e.group === category.value) && matchesQuery(e, query.value)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const grouped = computed(() =>
|
||||||
|
categories.value
|
||||||
|
.map((c) => ({ category: c, items: entries.value.filter((e) => e.group === c) }))
|
||||||
|
.filter((g) => g.items.length),
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyOf = (e: Entry & { group: Category }) => `${e.group}/${e.category}/${e.name}`;
|
||||||
|
|
||||||
|
function toggle(key: string) {
|
||||||
|
expanded.value = expanded.value === key ? null : key;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="ref" aria-label="Befehlsreferenz">
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
class="search"
|
||||||
|
type="search"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
aria-label="Befehle durchsuchen"
|
||||||
|
/>
|
||||||
|
<div class="chips">
|
||||||
|
<button class="chip" :class="{ on: !category }" @click="category = null">Alle</button>
|
||||||
|
<button
|
||||||
|
v-for="c in categories"
|
||||||
|
:key="c"
|
||||||
|
class="chip"
|
||||||
|
:class="{ on: category === c }"
|
||||||
|
@click="category = category === c ? null : c"
|
||||||
|
>
|
||||||
|
{{ c }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list">
|
||||||
|
<p v-if="!grouped.length" class="empty">Keine Treffer für „{{ query }}".</p>
|
||||||
|
<section v-for="g in grouped" :key="g.category">
|
||||||
|
<h4>{{ g.category }}</h4>
|
||||||
|
<div v-for="e in g.items" :key="keyOf(e)" class="entry" :class="{ open: expanded === keyOf(e) }">
|
||||||
|
<div class="head">
|
||||||
|
<button class="title" :aria-expanded="expanded === keyOf(e)" @click="toggle(keyOf(e))">
|
||||||
|
<code>{{ e.syntax }}</code>
|
||||||
|
</button>
|
||||||
|
<button class="ins" title="An der Cursorposition einfügen" @click="emit('insert', e.snippet)">
|
||||||
|
Einfügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="expanded === keyOf(e)" class="detail">
|
||||||
|
<p>{{ e.description }}</p>
|
||||||
|
<template v-if="e.example">
|
||||||
|
<pre>{{ e.example }}</pre>
|
||||||
|
<button class="ins" @click="emit('insert', e.example)">Beispiel einfügen</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ref {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 9px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.chip.on {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 60vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.entry {
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 6px 4px 8px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 2px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.title code {
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.title:hover code {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.ins {
|
||||||
|
flex: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ins:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.detail {
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.detail p {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.detail pre {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 11.5px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,9 +6,10 @@
|
|||||||
* and a form shared by both "new" and "edit". See the plan's "Bedienung"
|
* and a form shared by both "new" and "edit". See the plan's "Bedienung"
|
||||||
* section for the exact mockups this follows.
|
* section for the exact mockups this follows.
|
||||||
*/
|
*/
|
||||||
import { reactive, ref, watch } from 'vue';
|
import { nextTick, reactive, ref, watch } from 'vue';
|
||||||
import { useDocument } from '../store/document';
|
import { useDocument } from '../store/document';
|
||||||
import BaseModal from './BaseModal.vue';
|
import BaseModal from './BaseModal.vue';
|
||||||
|
import CodeReference from './CodeReference.vue';
|
||||||
|
|
||||||
const props = defineProps<{ open: boolean }>();
|
const props = defineProps<{ open: boolean }>();
|
||||||
const emit = defineEmits<{ (e: 'close'): void }>();
|
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||||
@@ -87,6 +88,20 @@ function removeFunction(id: string, name: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
/** Put a reference snippet at the caret (replacing any selection). */
|
||||||
|
async function insertSnippet(text: string) {
|
||||||
|
const el = bodyInput.value;
|
||||||
|
const start = el?.selectionStart ?? form.body.length;
|
||||||
|
const end = el?.selectionEnd ?? form.body.length;
|
||||||
|
form.body = form.body.slice(0, start) + text + form.body.slice(end);
|
||||||
|
await nextTick();
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(start + text.length, start + text.length);
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
emit('close');
|
emit('close');
|
||||||
}
|
}
|
||||||
@@ -95,6 +110,7 @@ function close() {
|
|||||||
<template>
|
<template>
|
||||||
<BaseModal
|
<BaseModal
|
||||||
:open="open"
|
:open="open"
|
||||||
|
:wide="mode === 'edit'"
|
||||||
:title="mode === 'list' ? 'Funktionen' : editingId ? 'Funktion bearbeiten' : 'Neue Funktion'"
|
:title="mode === 'list' ? 'Funktionen' : editingId ? 'Funktion bearbeiten' : 'Neue Funktion'"
|
||||||
@close="close"
|
@close="close"
|
||||||
>
|
>
|
||||||
@@ -119,7 +135,8 @@ function close() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ---- edit / create form ---- -->
|
<!-- ---- edit / create form ---- -->
|
||||||
<template v-else>
|
<div v-else class="edit-layout">
|
||||||
|
<div class="editor">
|
||||||
<label class="row">
|
<label class="row">
|
||||||
Name
|
Name
|
||||||
<input v-model="form.name" type="text" placeholder="z. B. MARGE" />
|
<input v-model="form.name" type="text" placeholder="z. B. MARGE" />
|
||||||
@@ -162,9 +179,11 @@ function close() {
|
|||||||
<label class="row body-row">
|
<label class="row body-row">
|
||||||
{{ form.kind === 'block' ? 'Code' : 'Formel' }}
|
{{ form.kind === 'block' ? 'Code' : 'Formel' }}
|
||||||
<textarea
|
<textarea
|
||||||
|
ref="bodyInput"
|
||||||
v-model="form.body"
|
v-model="form.body"
|
||||||
class="formula-input"
|
class="formula-input"
|
||||||
:rows="form.kind === 'block' ? 7 : 4"
|
spellcheck="false"
|
||||||
|
:rows="form.kind === 'block' ? 12 : 5"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
form.kind === 'block'
|
form.kind === 'block'
|
||||||
? 'ergebnis = 1\nFOR x IN bereich DO\n ergebnis = ergebnis * x\nEND\nRETURN ergebnis'
|
? 'ergebnis = 1\nFOR x IN bereich DO\n ergebnis = ergebnis * x\nEND\nRETURN ergebnis'
|
||||||
@@ -172,30 +191,21 @@ function close() {
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p v-if="form.kind === 'block'" class="hint">
|
<p class="hint">
|
||||||
Eine Folge aus <strong>Zuweisungen</strong> (<code>name = ausdruck</code>),
|
Befehle, Funktionen und die eigenen Parameter rechts suchen, aufklappen für
|
||||||
<strong>FOR-Schleifen</strong> (<code>FOR var IN bereich DO … END</code>,
|
Beschreibung und Beispiel, mit <strong>Einfügen</strong> an der Cursorposition
|
||||||
beliebig viele Anweisungen dazwischen) und <strong>IF/ELSE</strong>
|
übernehmen.
|
||||||
(<code>IF bedingung THEN … ELSE … END</code>, verschachtelbar in
|
|
||||||
jedem Block). <code>RETURN ausdruck</code> darf an beliebiger Stelle
|
|
||||||
stehen und bricht die Ausführung sofort ab — z. B. um eine Schleife
|
|
||||||
früh zu verlassen. Vergleiche mit <code>= <> > < >= <=</code>.
|
|
||||||
Jeder eingeführte Name bleibt für den Rest des Codes sichtbar, auch
|
|
||||||
nach seiner eigenen Schleife/seinem eigenen IF. Beispiel — Produkt
|
|
||||||
aller Elemente eines Bereichs:
|
|
||||||
<code>ergebnis = 1 / FOR x IN bereich DO ergebnis = ergebnis * x END / RETURN ergebnis</code>,
|
|
||||||
aufgerufen als <code>=PRODUCT(Umsatz!B2:E6)</code>.
|
|
||||||
</p>
|
|
||||||
<p v-else class="hint">
|
|
||||||
Ein Parameter kann auch ein <strong>Bereich</strong> sein, solange er
|
|
||||||
direkt an eine Funktion wie <code>SUM</code>/<code>AVG</code>/
|
|
||||||
<code>MIN</code>/<code>MAX</code> übergeben wird — z. B.
|
|
||||||
<code>SPANNE(bereich) = MAX(bereich) - MIN(bereich)</code>, aufgerufen
|
|
||||||
als <code>=SPANNE(Umsatz!B2:E6)</code>.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
|
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
|
||||||
</template>
|
</div>
|
||||||
|
<CodeReference
|
||||||
|
class="reference"
|
||||||
|
:context="form.kind"
|
||||||
|
:params="form.params"
|
||||||
|
@insert="insertSnippet"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<template v-if="mode === 'list'">
|
<template v-if="mode === 'list'">
|
||||||
@@ -355,6 +365,42 @@ function close() {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
.edit-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
/* side by side: fill the (viewport-capped) modal body and let the
|
||||||
|
reference list scroll inside it instead of the whole body */
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
/* stacked: let the modal body scroll as a whole */
|
||||||
|
.edit-layout {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.editor .body-row {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.editor .body-row .formula-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 70px;
|
||||||
|
max-height: none;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
.form-error {
|
.form-error {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
* toggle a macro doesn't have -- a macro is always the same write-capable
|
* toggle a macro doesn't have -- a macro is always the same write-capable
|
||||||
* statement language, parameterless (triggered by a click, no arguments).
|
* statement language, parameterless (triggered by a click, no arguments).
|
||||||
*/
|
*/
|
||||||
import { reactive, ref, watch } from 'vue';
|
import { nextTick, reactive, ref, watch } from 'vue';
|
||||||
import { useDocument } from '../store/document';
|
import { useDocument } from '../store/document';
|
||||||
import { useToasts } from '../composables/useToasts';
|
import { useToasts } from '../composables/useToasts';
|
||||||
import BaseModal from './BaseModal.vue';
|
import BaseModal from './BaseModal.vue';
|
||||||
|
import CodeReference from './CodeReference.vue';
|
||||||
|
|
||||||
const props = defineProps<{ open: boolean }>();
|
const props = defineProps<{ open: boolean }>();
|
||||||
const emit = defineEmits<{ (e: 'close'): void }>();
|
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||||
@@ -92,6 +93,20 @@ async function runMacro(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
/** Put a reference snippet at the caret (replacing any selection). */
|
||||||
|
async function insertSnippet(text: string) {
|
||||||
|
const el = bodyInput.value;
|
||||||
|
const start = el?.selectionStart ?? form.body.length;
|
||||||
|
const end = el?.selectionEnd ?? form.body.length;
|
||||||
|
form.body = form.body.slice(0, start) + text + form.body.slice(end);
|
||||||
|
await nextTick();
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(start + text.length, start + text.length);
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
emit('close');
|
emit('close');
|
||||||
}
|
}
|
||||||
@@ -100,6 +115,7 @@ function close() {
|
|||||||
<template>
|
<template>
|
||||||
<BaseModal
|
<BaseModal
|
||||||
:open="open"
|
:open="open"
|
||||||
|
:wide="mode === 'edit'"
|
||||||
:title="mode === 'list' ? 'Makros' : editingId ? 'Makro bearbeiten' : 'Neues Makro'"
|
:title="mode === 'list' ? 'Makros' : editingId ? 'Makro bearbeiten' : 'Neues Makro'"
|
||||||
@close="close"
|
@close="close"
|
||||||
>
|
>
|
||||||
@@ -129,7 +145,8 @@ function close() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ---- edit / create form ---- -->
|
<!-- ---- edit / create form ---- -->
|
||||||
<template v-else>
|
<div v-else class="edit-layout">
|
||||||
|
<div class="editor">
|
||||||
<label class="row">
|
<label class="row">
|
||||||
Name
|
Name
|
||||||
<input v-model="form.name" type="text" placeholder="z. B. Quartalsabschluss" />
|
<input v-model="form.name" type="text" placeholder="z. B. Quartalsabschluss" />
|
||||||
@@ -138,30 +155,24 @@ function close() {
|
|||||||
<label class="row body-row">
|
<label class="row body-row">
|
||||||
Aktionen
|
Aktionen
|
||||||
<textarea
|
<textarea
|
||||||
|
ref="bodyInput"
|
||||||
v-model="form.body"
|
v-model="form.body"
|
||||||
class="formula-input"
|
class="formula-input"
|
||||||
rows="7"
|
rows="14"
|
||||||
|
spellcheck="false"
|
||||||
placeholder="INSERTROW("Umsatz"; 1) SETVALUE(Umsatz::A2; "Neu")"
|
placeholder="INSERTROW("Umsatz"; 1) SETVALUE(Umsatz::A2; "Neu")"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Dieselbe Sprache wie „Mit Schleife"-Funktionen (<strong>Zuweisungen</strong>,
|
Befehle rechts suchen, aufklappen für Beschreibung und Beispiel, mit
|
||||||
<strong>FOR</strong>/<strong>IF/ELSE</strong>, <code>RETURN</code> als vorzeitiger
|
<strong>Einfügen</strong> an der Cursorposition übernehmen. Wichtig: <strong>jeder
|
||||||
Abbruch — ein <code>RETURN "text"</code> wird die Erfolgsmeldung), plus elf
|
Zellbezug nennt die Tabelle</strong> — <code>Umsatz::A1</code>, nie bloß <code>A1</code>.
|
||||||
<strong>Schreib-Aktionen</strong>:
|
|
||||||
<code>SETVALUE(Ziel; Wert)</code>,
|
|
||||||
<code>INSERTROW</code>/<code>DELETEROW</code>/<code>INSERTCOL</code>/<code>DELETECOL(Tabelle; Index)</code>,
|
|
||||||
<code>ADDTABLE(Blatt; Name)</code>, <code>RENAMETABLE</code>/<code>DELETETABLE(Tabelle)</code>,
|
|
||||||
<code>ADDSHEET(Name)</code>, <code>RENAMESHEET</code>/<code>DELETESHEET(Blatt)</code>.
|
|
||||||
Tabellen/Blätter werden als Text benannt (<code>"Umsatz"</code> oder
|
|
||||||
<code>"Blatt2!Kosten"</code>). <strong>Jeder Zell-/Bereichsbezug muss die Tabelle
|
|
||||||
nennen</strong> — <code>Umsatz::A1</code>, nie bloß <code>A1</code> — ein Makro hat
|
|
||||||
anders als eine Zelle keine eigene Tabelle. Beispiel:
|
|
||||||
<code>SETVALUE(Umsatz::B2; Umsatz::B2 + 1) INSERTROW("Umsatz"; 0)</code>.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
|
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
|
||||||
</template>
|
</div>
|
||||||
|
<CodeReference class="reference" context="macro" @insert="insertSnippet" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<template v-if="mode === 'list'">
|
<template v-if="mode === 'list'">
|
||||||
@@ -276,6 +287,46 @@ function close() {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
.edit-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
/* side by side: fill the (viewport-capped) modal body and let the
|
||||||
|
reference list scroll inside it instead of the whole body */
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
/* stacked: let the modal body scroll as a whole */
|
||||||
|
.edit-layout {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.editor .body-row {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.editor .body-row .formula-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 90px;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
.editor .formula-input {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
.form-error {
|
.form-error {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
|
|||||||
@@ -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,488 @@
|
|||||||
|
/**
|
||||||
|
* 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.',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user