Files
Laufzettel/src/lib/vorlagen-format.ts
T
florian.wilde 855164066c
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(vorlagen): Abschnitte nur fuer bestimmte Abteilungen (#58)
2026-10-04 18:04:51 +00:00

284 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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),
/**
* Verweis auf eine veroeffentlichte Baustein-Version (#030).
*
* Nur im **Editor** von Belang: beim Veroeffentlichen setzt der Dienst hier die
* Schritte des Bausteins ein. Eine **Austauschdatei** trägt das Feld nicht —
* `vorlageExportieren` schreibt es nicht, und `vorlageImportieren` liest es
* nicht. Eine Kennung aus einer anderen Installation bedeutet hier nichts, und
* ein Verweis auf einen Baustein, den es dort nicht gibt, wäre eine Vorlage,
* die sich nicht veroeffentlichen laesst.
*/
bausteinVersionId: leerAlsUndefiniert(z.string().trim().max(60)),
/**
* Nur fuer diese Abteilungen (#031). Leer heisst: fuer alle.
*
* **Namen und nicht Kennungen** — der Abschnitt gehoert zur Version und muss
* ein Umbenennen oder Archivieren einer Abteilung ueberleben. Deshalb wandert
* das Feld auch durch Export und Import: ein Abteilungsname bedeutet in einer
* anderen Installation dasselbe, eine Kennung nicht.
*/
nurFuerAbteilungen: z
.array(z.string().trim().min(1).max(200))
.max(50, "Höchstens 50 Abteilungen je Abschnitt.")
.default([]),
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<typeof AbschnittSchema>;
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<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),
/**
* 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<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.
*/
/**
* 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<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];
}