242 lines
9.3 KiB
TypeScript
242 lines
9.3 KiB
TypeScript
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),
|
||
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),
|
||
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];
|
||
}
|