feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch (#23)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #23.
This commit is contained in:
2026-09-25 06:10:01 +00:00
parent ecc3c579df
commit d8d5444e9f
41 changed files with 4006 additions and 104 deletions
+203
View File
@@ -0,0 +1,203 @@
import { z } from "@/lib/validierung";
/**
* Aufbau einer Vorlagen-Version (#009).
*
* Ein Schema fuer drei Zwecke: der Editor schickt den Entwurf in dieser Form,
* der JSON-Export schreibt sie, der Import liest sie. Gaebe es drei Schemata,
* liefen sie frueher oder spaeter auseinander — und der Import waere der Ort, an
* dem das erst auffiele.
*
* Framework-frei und ohne Datenbank, damit es sich ohne Next testen laesst.
*/
/**
* Ein leeres Formularfeld kommt als `""` an, nicht als `undefined`.
*
* Ohne diese Vorstufe scheiterte ein nicht ausgefuelltes optionales Feld an
* seiner eigenen Pruefung — beim Doku-Link etwa mit „keine gültige Adresse“,
* obwohl gar keine angegeben werden sollte.
*/
function leerAlsUndefiniert<T extends z.ZodType>(schema: T) {
return z.preprocess(
(wert) => (typeof wert === "string" && wert.trim() === "" ? undefined : wert),
schema.optional(),
);
}
/** Die Schritt-Typen des MVP. `AKTION` kommt in Phase 4 dazu. */
export const SCHRITT_TYPEN = ["HAKEN", "TEXT", "ZAHL", "AUSWAHL", "DATUM", "BEFEHL"] as const;
export const VARIABLEN_TYPEN = ["TEXT", "ZAHL", "AUSWAHL", "DATUM"] as const;
export const SHELLS = ["powershell", "bash", "cmd"] as const;
/**
* Obergrenzen fuer ein Austauschdokument.
*
* Ohne sie nimmt der Import beliebig grosse Listen an, laeuft in die
* Transaktions-Zeitgrenze von Postgres und meldet einen Fehler, der nichts ueber
* die Ursache sagt. Die Werte sind grosszuegig gewaehlt: die groesste Vorlage im
* Seed hat sieben Abschnitte und rund dreissig Schritte.
*/
export const GRENZEN = {
abschnitte: 200,
schritteJeAbschnitt: 500,
variablen: 200,
text: 20_000,
optionen: 200,
} as const;
/**
* Typabhaengige Einzelheiten eines Schritts.
*
* Bewusst nachsichtig: unbekannte Schluessel bleiben erhalten, damit ein
* Export aus einer spaeteren Fassung hier nicht stillschweigend Angaben
* verliert. Was die Anwendung kennt, wird geprueft.
*/
export const SchrittKonfigSchema = z.looseObject({
/** AUSWAHL: die Optionen in der angezeigten Reihenfolge. */
optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).optional(),
/** ZAHL: Grenzen und Einheit. Ein Wert ausserhalb wird markiert, nicht blockiert. */
min: z.number().optional(),
max: z.number().optional(),
einheit: z.string().trim().max(40).optional(),
/** TEXT: mehrzeilig statt einzeilig. */
mehrzeilig: z.boolean().optional(),
/** BEFEHL: Shell, Mehrzeiligkeit und der Befehlstext mit Platzhaltern. */
shell: z.enum(SHELLS).optional(),
befehl: z.string().max(GRENZEN.text).optional(),
});
export type SchrittKonfig = z.infer<typeof SchrittKonfigSchema>;
export const SchrittSchema = z.object({
titel: z.string().trim().min(1, "Ein Schritt braucht einen Titel.").max(GRENZEN.text),
/** Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. */
hilfetext: leerAlsUndefiniert(z.string().trim().max(GRENZEN.text)),
typ: z.enum(SCHRITT_TYPEN).default("HAKEN"),
pflicht: z.boolean().default(false),
konfig: SchrittKonfigSchema.default({}),
/**
* Nur http und https. `z.url()` allein liesse `javascript:` und `data:` durch —
* und daraus wuerde in der Abarbeitungsansicht ein `<a href>`, also ein
* gespeicherter Angriff, eingeschleust ueber eine importierte Datei.
*/
dokuUrl: leerAlsUndefiniert(
z.url({
protocol: /^https?$/,
message: "Der Doku-Link muss eine http- oder https-Adresse sein.",
}),
),
});
export type SchrittEingabe = z.input<typeof SchrittSchema>;
export const AbschnittSchema = z.object({
titel: z.string().trim().min(1, "Ein Abschnitt braucht einen Titel.").max(GRENZEN.text),
schritte: z
.array(SchrittSchema)
.max(
GRENZEN.schritteJeAbschnitt,
`Ein Abschnitt fasst höchstens ${GRENZEN.schritteJeAbschnitt} Schritte.`,
)
.default([]),
});
export const VariableSchema = z.object({
/**
* Der Schluessel steht als `{{schluessel}}` in Titeln, Hilfetexten und
* Befehlen. Deshalb nur Kleinbuchstaben, Ziffern und Unterstrich — alles
* andere liesse sich im Platzhalter nicht sauber wiedererkennen.
*/
schluessel: z
.string()
.trim()
.regex(
/^[a-z][a-z0-9_]{0,39}$/,
"Ein Variablenschlüssel beginnt mit einem Kleinbuchstaben und besteht aus Kleinbuchstaben, Ziffern und Unterstrich.",
),
beschriftung: z.string().trim().min(1, "Eine Variable braucht eine Beschriftung.").max(GRENZEN.text),
typ: z.enum(VARIABLEN_TYPEN).default("TEXT"),
optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).default([]),
pflicht: z.boolean().default(false),
});
export type VariableEingabe = z.input<typeof VariableSchema>;
/**
* Der gesamte Aufbau einer Version. Die Reihenfolge steckt in der Reihenfolge
* der Listen; `position` wird daraus abgeleitet und nicht mitgeschickt — sonst
* gaebe es zwei Wahrheiten, die auseinanderlaufen koennen.
*/
export const StrukturSchema = z.object({
variablen: z
.array(VariableSchema)
.max(GRENZEN.variablen, `Eine Vorlage fasst höchstens ${GRENZEN.variablen} Variablen.`)
.default([]),
abschnitte: z
.array(AbschnittSchema)
.max(GRENZEN.abschnitte, `Eine Vorlage fasst höchstens ${GRENZEN.abschnitte} Abschnitte.`)
.default([]),
});
export type Struktur = z.infer<typeof StrukturSchema>;
export type StrukturEingabe = z.input<typeof StrukturSchema>;
/** Fassung des Austauschformats. Wird beim Import geprueft. */
export const FORMAT_VERSION = 1;
/**
* Was eine exportierte Datei enthaelt.
*
* Bewusst ohne Kennungen, Zeitstempel und Kundenbezug: eine exportierte Vorlage
* soll sich in eine andere Laufzettel-Installation einspielen lassen, ohne
* Verweise mitzubringen, die dort nichts bedeuten.
*/
export const AustauschSchema = z.object({
format: z.literal(FORMAT_VERSION, {
message: `Unbekanntes Format. Diese Fassung liest Format ${FORMAT_VERSION}.`,
}),
name: z.string().trim().min(1, "Die Vorlage braucht einen Namen.").max(200),
beschreibung: leerAlsUndefiniert(z.string().trim().max(GRENZEN.text)),
kategorie: leerAlsUndefiniert(z.string().trim().max(100)),
schlagworte: z.array(z.string().trim().min(1).max(100)).max(50).default([]),
/** Nur zur Information, welche Version der Quelle exportiert wurde. */
version: z.number().int().positive().optional(),
struktur: StrukturSchema,
});
export type Austausch = z.infer<typeof AustauschSchema>;
/**
* Alle Platzhalter `{{key}}` aus einem Text.
*
* Steht hier und nicht in #012, weil der Editor schon beim Bearbeiten warnen
* soll, wenn ein Platzhalter zu keiner Variablen und zu keinem eingebauten Wert
* gehoert. Das Ersetzen selbst kommt in #012.
*/
export function platzhalterFinden(text: string): readonly string[] {
const gefunden = new Set<string>();
for (const treffer of text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)) {
if (treffer[1]) gefunden.add(treffer[1]);
}
return [...gefunden];
}
/** Alle Platzhalter, die irgendwo in einer Struktur vorkommen. */
export function platzhalterDerStruktur(struktur: Struktur): readonly string[] {
const gefunden = new Set<string>();
const sammeln = (text: string | undefined) => {
if (text) for (const name of platzhalterFinden(text)) gefunden.add(name);
};
for (const abschnitt of struktur.abschnitte) {
sammeln(abschnitt.titel);
for (const schritt of abschnitt.schritte) {
sammeln(schritt.titel);
sammeln(schritt.hilfetext);
const befehl = schritt.konfig["befehl"];
if (typeof befehl === "string") sammeln(befehl);
}
}
return [...gefunden];
}