Files
Laufzettel/src/lib/editor-aufbau.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

528 lines
19 KiB
TypeScript

import { EINGEBAUTE_PLATZHALTER, type EingebauterPlatzhalter } from "@/lib/platzhalter";
import {
SCHRITT_TYPEN,
VARIABLEN_TYPEN,
VariableSchema,
platzhalterDerStruktur,
platzhalterFinden,
type SchrittKonfig,
type Struktur,
} from "@/lib/vorlagen-format";
/**
* Der Aufbau einer Version, wie ihn der Editor im Zustand haelt (#010).
*
* Framework-frei und ohne React, damit sich Umsortieren, Nummerierung, der
* Umgang mit `konfig` beim Typwechsel und die Beispielwerte einzeln pruefen
* lassen — fuer Komponenten gibt es keine DOM-Testumgebung.
*
* Unterschied zur `Struktur` aus `vorlagen-format.ts`: jeder Eintrag traegt eine
* `kennung`. Die Reihenfolge einer Liste ist die fachliche Reihenfolge, aber
* React und `@dnd-kit` brauchen einen Schluessel, der beim Umsortieren am
* Eintrag bleibt und nicht am Index haengt. Die Kennung entsteht im Browser und
* wird nie gespeichert.
*/
export type SchrittTyp = (typeof SCHRITT_TYPEN)[number];
export type VariablenTyp = (typeof VARIABLEN_TYPEN)[number];
export type EditorSchritt = {
readonly kennung: string;
readonly titel: string;
/** Leer statt `undefined`: ein Formularfeld hat immer einen Wert. */
readonly hilfetext: string;
readonly typ: SchrittTyp;
readonly pflicht: boolean;
readonly konfig: SchrittKonfig;
readonly dokuUrl: string;
};
export type EditorAbschnitt = {
readonly kennung: string;
readonly titel: string;
readonly schritte: readonly EditorSchritt[];
/** Nur fuer diese Abteilungen; leer heisst fuer alle (#031). */
readonly nurFuerAbteilungen: readonly string[];
/**
* Gesetzt, wenn dieser Abschnitt ein eingesetzter **Baustein** ist (#030).
*
* Dann sind `titel` und `schritte` nur Anzeige: beim Veroeffentlichen setzt
* der Dienst die Schritte des Bausteins ein. Bearbeiten laesst sich ein
* solcher Abschnitt hier nicht — das geschieht am Baustein.
*/
readonly baustein?: {
readonly versionId: string;
readonly name: string;
readonly version: number;
};
};
export type EditorVariable = {
readonly kennung: string;
readonly schluessel: string;
readonly beschriftung: string;
readonly typ: VariablenTyp;
readonly optionen: readonly string[];
readonly pflicht: boolean;
};
export type EditorAufbau = {
readonly variablen: readonly EditorVariable[];
readonly abschnitte: readonly EditorAbschnitt[];
};
let zaehler = 0;
/**
* Eine Kennung, die nur im Browser gilt.
*
* Sie dient als React-`key` und als `id` fuer `@dnd-kit` und wird **nie** in ein
* Attribut gerendert. Deshalb darf sie sich zwischen Vorrendern auf dem Server
* und Hydrieren im Browser unterscheiden — der Zaehler steht im Modul und laeuft
* im Serverprozess ueber alle Anfragen weiter, im Browser faengt er bei null an.
* Gebraucht wird nur Eindeutigkeit innerhalb eines Editors, und die liefert er.
*/
export function neueKennung(praefix: string): string {
zaehler += 1;
return `${praefix}-${zaehler}`;
}
/** Die angezeigte Abschnittsnummer, `01` fuer den ersten. Aus der Position, nie gespeichert. */
export function abschnittNummer(index: number): string {
return String(index + 1).padStart(2, "0");
}
/**
* Eine vorangestellte Nummer aus einem Abschnittstitel entfernen.
*
* Die Nummer wird aus der Position abgeleitet und daneben angezeigt. Traegt ein
* importierter Titel sie zusaetzlich im Text — `01 · Vorbereitung`, wie es die
* Doku bisher vorschlug —, stuende sonst „01 · 01 · Vorbereitung“ da, und beim
* Verschieben wuerde die falsche Nummer mitwandern.
*
* Erkannt wird **nur** der Mittelpunkt als Trenner. Mit `.`, `)` oder `-` waere
* aus „2. Etage prüfen“ stillschweigend „Etage prüfen“ geworden: echter
* Textverlust an einer Stelle, die niemand kontrolliert.
*
* Und auch beim Mittelpunkt muss hinter dem Trenner etwas stehen, das weder
* Ziffer noch Leerraum ist: „24 · 7 Bereitschaft“ ist eine Servicezeit und keine
* Nummerierung. Der Leerraum gehoert in die Bedingung, weil `\s*` sonst
* zurueckspringt und die Vorausschau am Leerzeichen statt an der Ziffer prueft.
* Ohne diese Bedingung waere daraus „7 Bereitschaft“ geworden — derselbe stille
* Verlust, nur seltener.
*/
export function titelOhneNummer(titel: string): string {
return titel.replace(/^\s*\d{1,2}\s*·\s*(?=[^\s\d])/u, "");
}
/**
* Einen Eintrag von einer Position auf eine andere schieben.
*
* Gibt die unveraenderte Liste zurueck, wenn nichts zu tun oder der Index
* ausserhalb ist — so koennen die Knoepfe „nach oben“ und „nach unten“ ohne
* eigene Randpruefung rechnen.
*/
export function umsortieren<T>(liste: readonly T[], von: number, nach: number): readonly T[] {
if (von === nach) return liste;
if (von < 0 || nach < 0 || von >= liste.length || nach >= liste.length) return liste;
const kopie = [...liste];
const [eintrag] = kopie.splice(von, 1);
// Kann nicht eintreten, der Index ist oben geprueft; TypeScript weiss das nicht.
if (eintrag === undefined) return liste;
kopie.splice(nach, 0, eintrag);
return kopie;
}
/**
* Welche `konfig`-Schluessel zu welchem Schritt-Typ gehoeren.
*
* Dieselbe Aufteilung, die `docs/vorlagen-format.md` beschreibt — hier als
* Daten, damit das typabhaengige Formular und die Aufraeumung beim Typwechsel
* aus einer Quelle lesen.
*/
export const KONFIG_SCHLUESSEL: Record<SchrittTyp, readonly string[]> = {
HAKEN: [],
TEXT: ["mehrzeilig"],
ZAHL: ["min", "max", "einheit"],
AUSWAHL: ["optionen"],
DATUM: [],
BEFEHL: ["shell", "befehl"],
};
const ALLE_KONFIG_SCHLUESSEL: ReadonlySet<string> = new Set(Object.values(KONFIG_SCHLUESSEL).flat());
/**
* Die `konfig` beim Wechsel des Schritt-Typs aufraeumen.
*
* Entfernt wird, was einem der bekannten Typen gehoert, aber nicht dem neuen:
* bliebe der `befehl` an einem Schritt haengen, der inzwischen `HAKEN` ist,
* waere er unsichtbarer Ballast — und seine Platzhalter zaehlten weiter zur
* Warnung, ohne dass irgendwo ein Feld zu sehen ist, das man korrigieren
* koennte. Das Format erlaubt fremde Schluessel ausdruecklich (sie sollen einen
* Rundlauf durch eine aeltere Fassung ueberleben), deshalb bleibt alles
* Unbekannte stehen: es gehoert keinem Typ, den diese Fassung kennt, und nichts
* hier wertet es aus.
*/
export function konfigFuerTyp(konfig: SchrittKonfig, typ: SchrittTyp): SchrittKonfig {
const erlaubt = new Set(KONFIG_SCHLUESSEL[typ]);
const gesaeubert: Record<string, unknown> = {};
for (const [schluessel, wert] of Object.entries(konfig)) {
if (erlaubt.has(schluessel) || !ALLE_KONFIG_SCHLUESSEL.has(schluessel)) {
gesaeubert[schluessel] = wert;
}
}
return gesaeubert as SchrittKonfig;
}
/**
* Beschriftungen der Typen — in `src/lib`, weil Editor und Vorschau sie im
* Browser brauchen und nichts aus `src/server` dorthin gehoert.
*/
export const SCHRITT_TYP_TEXT: Record<SchrittTyp, string> = {
HAKEN: "Haken",
TEXT: "Text",
ZAHL: "Zahl",
AUSWAHL: "Auswahl",
DATUM: "Datum",
BEFEHL: "Befehl",
};
export const VARIABLEN_TYP_TEXT: Record<VariablenTyp, string> = {
TEXT: "Text",
ZAHL: "Zahl",
AUSWAHL: "Auswahl",
DATUM: "Datum",
};
/**
* Die Shell, die ein `BEFEHL`-Schritt ohne Angabe bedeutet.
*
* Formular und Vorschau greifen auf dieselbe Annahme zurueck, damit die Vorschau
* nicht etwas anderes zeigt als das Feld darueber. Gespeichert wird sie, sobald
* jemand den Typ auf `BEFEHL` stellt; bei einer importierten Datei ohne `shell`
* bleibt sie eine Anzeige-Annahme.
*/
export const STANDARD_SHELL = "powershell";
/** Ein leerer Schritt, wie ihn „Schritt hinzufügen“ anlegt. */
export function neuerSchritt(): EditorSchritt {
return {
kennung: neueKennung("schritt"),
titel: "",
hilfetext: "",
typ: "HAKEN",
pflicht: false,
konfig: {},
dokuUrl: "",
};
}
export function neuerAbschnitt(): EditorAbschnitt {
return { kennung: neueKennung("abschnitt"), titel: "", schritte: [], nurFuerAbteilungen: [] };
}
/**
* Ein Abschnitt, der einen Baustein einsetzt (#030).
*
* Titel und Schritte kommen zur Anzeige mit; gespeichert wird nur der Verweis,
* und beim Veroeffentlichen setzt der Dienst die Schritte des Bausteins ein.
*/
export function neuerBausteinAbschnitt(baustein: {
readonly bausteinVersionId: string;
readonly name: string;
readonly titel: string;
readonly version: number;
readonly anzahlSchritte: number;
}): EditorAbschnitt {
return {
kennung: neueKennung("abschnitt"),
titel: baustein.titel,
baustein: { versionId: baustein.bausteinVersionId, name: baustein.name, version: baustein.version },
// Ein Baustein-Abschnitt laesst sich einschraenken wie jeder andere: die
// Einschraenkung gehoert zur Vorlage, nicht zum Baustein.
nurFuerAbteilungen: [],
// Platzhalter, damit die Anzahl stimmt. Gespeichert werden sie nicht.
schritte: Array.from({ length: baustein.anzahlSchritte }, () => neuerSchritt()),
};
}
/**
* Traegt Name und Fassung der verwiesenen Bausteine nach.
*
* Die gespeicherte Struktur kennt nur die Kennung; die Seite holt die Liste der
* einsetzbaren Bausteine getrennt und reicht sie hier ein. Ein Verweis, dessen
* Baustein nicht mehr einsetzbar ist (archiviert, oder eine aeltere Fassung),
* behaelt seine Kennung und bekommt einen erkennbaren Namen — verschwinden darf
* er nicht, sonst waere er beim naechsten Speichern still weg.
*/
export function bausteineAnreichern(
aufbau: EditorAufbau,
bekannte: readonly {
readonly bausteinVersionId: string;
readonly name: string;
readonly version: number;
}[],
): EditorAufbau {
const nachId = new Map(bekannte.map((eintrag) => [eintrag.bausteinVersionId, eintrag]));
return {
...aufbau,
abschnitte: aufbau.abschnitte.map((abschnitt) => {
if (abschnitt.baustein === undefined) return abschnitt;
const treffer = nachId.get(abschnitt.baustein.versionId);
return {
...abschnitt,
baustein: {
versionId: abschnitt.baustein.versionId,
name: treffer?.name ?? "(nicht mehr verfügbar)",
version: treffer?.version ?? 0,
},
};
}),
};
}
export function neueVariable(): EditorVariable {
return {
kennung: neueKennung("variable"),
schluessel: "",
beschriftung: "",
typ: "TEXT",
optionen: [],
pflicht: false,
};
}
/** Die geladene Struktur in den Editor-Zustand uebernehmen. */
export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
return {
variablen: struktur.variablen.map((variable) => ({
kennung: neueKennung("variable"),
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: [...variable.optionen],
pflicht: variable.pflicht,
})),
abschnitte: struktur.abschnitte.map((abschnitt) => ({
kennung: neueKennung("abschnitt"),
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
titel: titelOhneNummer(abschnitt.titel),
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
// Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie
// getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030).
...(abschnitt.bausteinVersionId === undefined
? {}
: { baustein: { versionId: abschnitt.bausteinVersionId, name: "", version: 0 } }),
schritte: abschnitt.schritte.map((schritt) => ({
kennung: neueKennung("schritt"),
titel: schritt.titel,
hilfetext: schritt.hilfetext ?? "",
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl ?? "",
})),
})),
};
}
/**
* Der Zustand zurueck in die Form, die gespeichert und exportiert wird.
*
* Kennungen fallen weg, leere Textfelder werden zu `undefined` und damit im
* JSON weggelassen — ein leerer Doku-Link ist keine Adresse, und das Schema
* wuerde ihn zu Recht beanstanden.
*/
export function alsStruktur(aufbau: EditorAufbau): Struktur {
return {
variablen: aufbau.variablen.map((variable) => ({
schluessel: variable.schluessel.trim(),
beschriftung: variable.beschriftung,
typ: variable.typ,
// Optionen gehoeren nur zu AUSWAHL; sonst waeren sie beim Typwechsel
// derselbe unsichtbare Ballast wie eine nicht aufgeraeumte konfig.
optionen: variable.typ === "AUSWAHL" ? [...variable.optionen] : [],
pflicht: variable.pflicht,
})),
abschnitte: aufbau.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
...(abschnitt.baustein === undefined ? {} : { bausteinVersionId: abschnitt.baustein.versionId }),
// Leere Namen und Dubletten fallen weg: beides entsteht beim Tippen im
// Freitextfeld und bedeutet dasselbe wie "nicht genannt".
nurFuerAbteilungen: [
...new Set(abschnitt.nurFuerAbteilungen.map((name) => name.trim()).filter(Boolean)),
],
/*
* Ein Baustein-Abschnitt gibt **keine** Schritte mit: sie kommen beim
* Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur
* Platzhalter, damit die Anzahl in der Karte stimmt — und sie haben keinen
* Titel. Wuerden sie mitgeschickt, wiese `SchrittSchema` den ganzen
* Entwurf ab, und das Speichern scheiterte (#030, gefunden beim
* Durchklicken des gebauten Stands).
*/
schritte: (abschnitt.baustein === undefined ? abschnitt.schritte : []).map((schritt) => ({
titel: schritt.titel,
hilfetext: schritt.hilfetext.trim() === "" ? undefined : schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl.trim() === "" ? undefined : schritt.dokuUrl.trim(),
})),
})),
};
}
/** Alle Platzhalter, die im aktuellen Zustand vorkommen. */
export function verwendetePlatzhalter(aufbau: EditorAufbau): readonly string[] {
return platzhalterDerStruktur(alsStruktur(aufbau));
}
/**
* Ist der Variablenschluessel gueltig — und wenn nicht, warum?
*
* Fragt das Schema selbst, statt sein Muster abzuschreiben: sonst gaebe es zwei
* Regeln, und die im Editor waere die, die niemand nachzieht.
*/
export function schluesselFehler(schluessel: string): string | undefined {
const geprueft = VariableSchema.shape.schluessel.safeParse(schluessel);
if (geprueft.success) return undefined;
return geprueft.error.issues[0]?.message ?? "Der Schlüssel ist ungültig.";
}
/** Die Regel als Satz, fuer den Hinweis unter dem Feld. Kommt aus demselben Schema. */
export const SCHLUESSEL_REGEL: string = schluesselFehler("") ?? "";
/**
* Schluessel, die mehr als einmal vorkommen.
*
* Der Dienst lehnt das beim Speichern ab. Besser, es faellt schon beim Tippen
* auf — mit der doppelten Zeile im Blick.
*/
export function doppelteSchluessel(variablen: readonly EditorVariable[]): readonly string[] {
const gesehen = new Set<string>();
const doppelt = new Set<string>();
for (const variable of variablen) {
const schluessel = variable.schluessel.trim();
if (schluessel === "") continue;
if (gesehen.has(schluessel)) doppelt.add(schluessel);
gesehen.add(schluessel);
}
return [...doppelt];
}
/**
* Beispieltext fuer `{{generiertes_passwort}}`.
*
* Fest und erkennbar unecht. Ein echtes Passwort darf hier nicht entstehen: es
* wird je Anzeige neu erzeugt und nirgends gespeichert (#012), und eine
* Vorschau, die eines zeigt, faengt an, sich auf etwas zu verlassen, das es
* noch nicht gibt.
*/
export const BEISPIEL_PASSWORT = "BEISPIEL-PASSWORT-NICHT-ECHT";
/**
* Beispielwerte der eingebauten Platzhalter.
*
* Erkennbar erfunden, aber in der richtigen Form: ein `dn` sieht aus wie ein
* `dn`, damit in der Vorschau eines PowerShell-Befehls zu sehen ist, was der
* Techniker spaeter wirklich vor sich hat. `datum` fehlt hier bewusst und kommt
* von aussen — siehe `beispielwerte`.
*/
const BEISPIELWERTE: Record<string, string> = {
hostname: "NB-MUSTER-014",
seriennummer: "SN-2026-000123",
inventarnummer: "INV-004711",
ticket: "T-12345",
techniker: "Tina Technik",
kunde: "Muster GmbH",
kundenkuerzel: "MUS",
mitarbeiter: "Anna Beispiel",
benutzer: "Anna Beispiel",
vorname: "Anna",
nachname: "Beispiel",
upn: "anna.beispiel@muster.example",
sam: "a.beispiel",
mail: "anna.beispiel@muster.example",
abteilung: "Vertrieb",
titel: "Sachbearbeiterin",
vorgesetzter: "Bernd Beispiel",
vorgesetzter_sam: "b.beispiel",
dn: "CN=Anna Beispiel,OU=Vertrieb,DC=muster,DC=example",
ou: "OU=Vertrieb,DC=muster,DC=example",
gruppen: "G-Vertrieb, G-Drucker-EG",
ou_gesperrt: "OU=Gesperrt,OU=Benutzer,DC=muster,DC=example",
generiertes_passwort: BEISPIEL_PASSWORT,
};
function beispielFuerVariable(variable: EditorVariable, beispielDatum: string): string {
switch (variable.typ) {
case "AUSWAHL":
return variable.optionen[0] ?? "Beispielauswahl";
case "ZAHL":
return "42";
case "DATUM":
return beispielDatum;
default:
return `Beispiel-${variable.schluessel.trim() || "wert"}`;
}
}
/**
* Die Beispielwerte fuer die Vorschau.
*
* `beispielDatum` kommt von der Serverseite und nicht aus `new Date()`: der
* Editor wird serverseitig vorgerendert, und zwei verschiedene Uhrzeiten in
* denselben Text ergaeben einen Hydrierungsfehler.
*
* Gibt es einen Schluessel als Variable **und** als eingebauten Wert — etwa
* `abteilung` —, zeigt die Vorschau den Beispielwert der Variablen: sie wird im
* Startformular des Laufs abgefragt und ist damit die konkretere Angabe. Fuer
* Beispieltexte ist der Unterschied ohnehin nur ein anderer erfundener Name.
*/
export function beispielwerte(
variablen: readonly EditorVariable[],
beispielDatum: string,
): Record<string, string> {
const werte: Record<string, string> = { ...BEISPIELWERTE, datum: beispielDatum };
for (const variable of variablen) {
const schluessel = variable.schluessel.trim();
if (schluessel !== "") werte[schluessel] = beispielFuerVariable(variable, beispielDatum);
}
return werte;
}
/**
* Platzhalter im Text durch Beispielwerte ersetzen.
*
* Ein unbekannter Platzhalter bleibt als `{{schluessel}}` stehen — genau so wie
* im Lauf, und die Warnung im Editor sagt dazu, was fehlt. Die Namen kommen aus
* `platzhalterFinden` und bestehen damit nur aus Kleinbuchstaben, Ziffern und
* Unterstrich; im **Suchausdruck** ist daran nichts zu entschaerfen.
*
* Im **Ersatztext** sehr wohl: `String.replace` deutet dort `$&`, `` $` ``, `$'`
* und `$1` als Rueckverweise. Ein Auswahlwert „Kosten $& Nutzen“ haette den
* gefundenen Platzhalter wieder eingesetzt. Eine Ersatzfunktion liefert den Wert
* unveraendert — wichtig spaetestens fuer #012, wo derselbe Weg einen
* PowerShell-Befehl zusammensetzt.
*/
export function mitBeispielwerten(text: string, werte: Record<string, string>): string {
let ergebnis = text;
for (const name of platzhalterFinden(text)) {
const wert = werte[name];
if (wert === undefined) continue;
ergebnis = ergebnis.replace(new RegExp(`\\{\\{\\s*${name}\\s*\\}\\}`, "g"), () => wert);
}
return ergebnis;
}
/** Die eingebauten Platzhalter nach Quelle gruppiert — fuer die Hilfe im Editor. */
export function platzhalterNachQuelle(): ReadonlyMap<string, readonly EingebauterPlatzhalter[]> {
const gruppen = new Map<string, EingebauterPlatzhalter[]>();
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
const vorhanden = gruppen.get(platzhalter.quelle);
if (vorhanden) vorhanden.push(platzhalter);
else gruppen.set(platzhalter.quelle, [platzhalter]);
}
return gruppen;
}