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(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 = { HAKEN: [], TEXT: ["mehrzeilig"], ZAHL: ["min", "max", "einheit"], AUSWAHL: ["optionen"], DATUM: [], BEFEHL: ["shell", "befehl"], }; const ALLE_KONFIG_SCHLUESSEL: ReadonlySet = 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 = {}; 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 = { HAKEN: "Haken", TEXT: "Text", ZAHL: "Zahl", AUSWAHL: "Auswahl", DATUM: "Datum", BEFEHL: "Befehl", }; export const VARIABLEN_TYP_TEXT: Record = { 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(); const doppelt = new Set(); 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 = { 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 { const werte: Record = { ...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 { 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 { const gruppen = new Map(); 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; }