feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch (#23)
This commit was merged in pull request #23.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Zod mit deutschen Standardmeldungen (#009).
|
||||
*
|
||||
* Ohne diese Einstellung kommen bei fehlenden oder falsch typisierten Feldern
|
||||
* englische Saetze heraus („Invalid input: expected string“), und die landen
|
||||
* ueber die Server Actions unmittelbar in der Oberflaeche. Eigene `message`-Texte
|
||||
* helfen nur dort, wo man an sie gedacht hat — die Lokalisierung deckt auch alles
|
||||
* ab, woran niemand gedacht hat.
|
||||
*
|
||||
* Deshalb wird `z` **hier** eingestellt und von hier weitergereicht: ein Import
|
||||
* direkt aus "zod" umginge die Einstellung. Die ESLint-Regel
|
||||
* `no-restricted-imports` sorgt dafuer, dass das auffaellt.
|
||||
*/
|
||||
z.config(z.locales.de());
|
||||
|
||||
export { z };
|
||||
|
||||
// Was sonst noch aus Zod gebraucht wird, laeuft ebenfalls ueber diesen Pfad –
|
||||
// sonst muesste doch wieder jemand direkt aus "zod" importieren.
|
||||
export { ZodError } from "zod";
|
||||
export type { ZodIssue, ZodType } from "zod";
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user