import { istAlsVariableErlaubt } from "@/lib/platzhalter"; 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(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; 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 ``, 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; 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([]), }); /** Ein Abschnitt so, wie ihn ein Editor oder eine Datei liefert – vor dem Pruefen. */ export type AbschnittEingabe = z.input; 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.", ) /* * Ein eingebauter Name ist als Variable gesperrt – mit einer Ausnahme, * `hostname` (siehe `UEBERSCHREIBBARE_PLATZHALTER`). Sonst hiesse eine * Variable `techniker`, und der fertige Befehl unterschriebe mit einem * Namen, der nicht stimmt. Die Aufloesung wehrt das noch einmal ab; hier * faellt es dem Redakteur auf, **bevor** er die Vorlage veroeffentlicht. */ .refine(istAlsVariableErlaubt, { message: "Diesen Namen gibt es schon als eingebauten Platzhalter. Bitte einen anderen wählen — " + "sonst stünde im fertigen Befehl etwas anderes, als der eingebaute Wert sagt.", }), 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; /** * 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; export type StrukturEingabe = z.input; /** 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), /** * Kennung einer **mitgelieferten** Vorlage (#021). * * Nur fuer die Dateien unter `prisma/seed-daten/vorlagen/` gedacht, damit der * Seed seine Vorlagen am Schluessel erkennt und nicht am Namen, der sich * aendern darf. Der **Import uebernimmt sie nicht** — eine eingespielte Kopie * soll nicht den Platz des Originals einnehmen —, und der Export schreibt sie * nicht, weil ein Feld, das beim Einlesen verfaellt, nur verwirrt. */ seedSchluessel: leerAlsUndefiniert( z .string() .trim() .max(60) .regex( /^[a-z0-9-]+$/, "Der Seed-Schlüssel darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.", ), ), 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; /** * 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. */ /** * Aus einem Zod-Pfad eine Stelle machen, die man auf dem Bildschirm wiederfindet. * * `struktur.abschnitte.2.schritte.0.titel` ist fuer den Aufbau richtig und fuer * den Redakteur nutzlos — er muesste von null an zaehlen und wuesste immer noch * nicht, ob „2“ den dritten Abschnitt meint. Daraus wird „Abschnitt 03, * Schritt 1: “. Der Rest der Meldung kommt unveraendert aus dem Schema. */ export function stelleBenennen(pfad: readonly PropertyKey[]): string { const teile: string[] = []; for (let i = 0; i < pfad.length; i += 1) { const name = String(pfad[i]); const index = Number(pfad[i + 1]); if (!Number.isInteger(index)) continue; if (name === "abschnitte") teile.push(`Abschnitt ${String(index + 1).padStart(2, "0")}`); else if (name === "schritte") teile.push(`Schritt ${index + 1}`); else if (name === "variablen") teile.push(`Variable ${index + 1}`); else if (name === "optionen") teile.push(`Option ${index + 1}`); } return teile.length > 0 ? `${teile.join(", ")}: ` : ""; } export function platzhalterFinden(text: string): readonly string[] { const gefunden = new Set(); 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(); 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]; }