feat(vorlagen): Editor, Liste und Versionsgeschichte (#24)
This commit was merged in pull request #24.
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
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[];
|
||||
};
|
||||
|
||||
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: [] };
|
||||
}
|
||||
|
||||
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),
|
||||
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,
|
||||
schritte: 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",
|
||||
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;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export type NavEintrag = {
|
||||
export const NAVIGATION: readonly NavEintrag[] = [
|
||||
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
|
||||
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: false, ausIssue: 13 },
|
||||
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: false, ausIssue: 10 },
|
||||
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: true },
|
||||
{
|
||||
pfad: "/mitarbeiter",
|
||||
beschriftung: "Mitarbeiter & Geräte",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Die eingebauten Platzhalter (#010, aufgeloest in #012).
|
||||
*
|
||||
* Ein Platzhalter `{{schluessel}}` in Titel, Hilfetext oder Befehl wird beim
|
||||
* Abarbeiten eines Laufs ersetzt. Woraus, steht hier: entweder aus einer
|
||||
* Variablen der Vorlage oder aus einer der eingebauten Quellen. Ein Schluessel,
|
||||
* der zu keiner von beiden passt, bleibt im Lauf als `{{schluessel}}` stehen —
|
||||
* sichtbar, aber nutzlos. Deshalb warnt der Editor davor.
|
||||
*
|
||||
* Die Liste ist bewusst Daten und kein Code: der Editor zeigt sie als Hilfe an,
|
||||
* die Warnung vergleicht dagegen, und #012 baut die Aufloesung darauf auf. Eine
|
||||
* zweite Aufzaehlung an anderer Stelle wuerde unweigerlich auseinanderlaufen.
|
||||
*/
|
||||
|
||||
export type PlatzhalterQuelle = "GERAET_LAUF" | "MITARBEITER" | "ABTEILUNG" | "BESONDERS";
|
||||
|
||||
export type EingebauterPlatzhalter = {
|
||||
readonly schluessel: string;
|
||||
readonly quelle: PlatzhalterQuelle;
|
||||
readonly erklaerung: string;
|
||||
/** Nur im Modus DIENSTLEISTUNG gefuellt; im Modus INTERN bleibt der Wert leer. */
|
||||
readonly nurDienstleistung?: true;
|
||||
};
|
||||
|
||||
export const EINGEBAUTE_PLATZHALTER: readonly EingebauterPlatzhalter[] = [
|
||||
// Aus Geraet und Lauf
|
||||
{ schluessel: "hostname", quelle: "GERAET_LAUF", erklaerung: "Hostname des Geräts im Lauf" },
|
||||
{ schluessel: "seriennummer", quelle: "GERAET_LAUF", erklaerung: "Seriennummer des Geräts" },
|
||||
{ schluessel: "inventarnummer", quelle: "GERAET_LAUF", erklaerung: "Inventarnummer des Geräts" },
|
||||
{ schluessel: "ticket", quelle: "GERAET_LAUF", erklaerung: "Ticketnummer am Lauf" },
|
||||
{ schluessel: "datum", quelle: "GERAET_LAUF", erklaerung: "heutiges Datum" },
|
||||
{ schluessel: "techniker", quelle: "GERAET_LAUF", erklaerung: "Name des Bearbeiters" },
|
||||
{
|
||||
schluessel: "kunde",
|
||||
quelle: "GERAET_LAUF",
|
||||
erklaerung: "Name des Kunden",
|
||||
nurDienstleistung: true,
|
||||
},
|
||||
{
|
||||
schluessel: "kundenkuerzel",
|
||||
quelle: "GERAET_LAUF",
|
||||
erklaerung: "Kürzel des Kunden",
|
||||
nurDienstleistung: true,
|
||||
},
|
||||
|
||||
// Aus dem Mitarbeiter
|
||||
{ schluessel: "mitarbeiter", quelle: "MITARBEITER", erklaerung: "vollständiger Name" },
|
||||
{ schluessel: "benutzer", quelle: "MITARBEITER", erklaerung: "gleichbedeutend mit mitarbeiter" },
|
||||
{ schluessel: "vorname", quelle: "MITARBEITER", erklaerung: "Vorname" },
|
||||
{ schluessel: "nachname", quelle: "MITARBEITER", erklaerung: "Nachname" },
|
||||
{ schluessel: "upn", quelle: "MITARBEITER", erklaerung: "User Principal Name" },
|
||||
{ schluessel: "sam", quelle: "MITARBEITER", erklaerung: "sAMAccountName, höchstens 20 Zeichen" },
|
||||
{ schluessel: "mail", quelle: "MITARBEITER", erklaerung: "Mail-Adresse" },
|
||||
{ schluessel: "abteilung", quelle: "MITARBEITER", erklaerung: "Name der Abteilung" },
|
||||
{ schluessel: "titel", quelle: "MITARBEITER", erklaerung: "Funktion oder Titel" },
|
||||
{ schluessel: "vorgesetzter", quelle: "MITARBEITER", erklaerung: "Name des Vorgesetzten" },
|
||||
{ schluessel: "vorgesetzter_sam", quelle: "MITARBEITER", erklaerung: "sAMAccountName des Vorgesetzten" },
|
||||
{ schluessel: "dn", quelle: "MITARBEITER", erklaerung: "Distinguished Name im Verzeichnis" },
|
||||
|
||||
// Aus der Abteilung
|
||||
{
|
||||
schluessel: "ou",
|
||||
quelle: "ABTEILUNG",
|
||||
erklaerung: "Ziel-Container: Eltern-Container des dn, sonst die Ziel-OU der Abteilung",
|
||||
},
|
||||
{ schluessel: "gruppen", quelle: "ABTEILUNG", erklaerung: "Standardgruppen, kommagetrennt" },
|
||||
|
||||
// Besonders
|
||||
{
|
||||
schluessel: "generiertes_passwort",
|
||||
quelle: "BESONDERS",
|
||||
erklaerung:
|
||||
"20 Zeichen, bei jeder Anzeige neu erzeugt und nirgends gespeichert — auch nicht im Protokoll",
|
||||
},
|
||||
];
|
||||
|
||||
export const QUELLEN_TEXT: Record<PlatzhalterQuelle, string> = {
|
||||
GERAET_LAUF: "Gerät und Lauf",
|
||||
MITARBEITER: "Mitarbeiter",
|
||||
ABTEILUNG: "Abteilung",
|
||||
BESONDERS: "Besonders",
|
||||
};
|
||||
|
||||
const EINGEBAUTE_SCHLUESSEL: ReadonlySet<string> = new Set(
|
||||
EINGEBAUTE_PLATZHALTER.map((platzhalter) => platzhalter.schluessel),
|
||||
);
|
||||
|
||||
export function istEingebaut(schluessel: string): boolean {
|
||||
return EINGEBAUTE_SCHLUESSEL.has(schluessel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Platzhalter, die weder eingebaut noch als Variable definiert sind.
|
||||
*
|
||||
* Das ist eine **Warnung**, keine Ablehnung: ein Tippfehler faellt so im Editor
|
||||
* auf, statt erst dem Techniker im Lauf. Speichern und Veroeffentlichen bleiben
|
||||
* erlaubt — es kann gute Gruende geben, einen Platzhalter stehen zu lassen, etwa
|
||||
* wenn die Variable noch kommt.
|
||||
*/
|
||||
export function unbekanntePlatzhalter(
|
||||
verwendete: readonly string[],
|
||||
variablenSchluessel: readonly string[],
|
||||
): readonly string[] {
|
||||
const bekannt = new Set(variablenSchluessel);
|
||||
return verwendete.filter((schluessel) => !bekannt.has(schluessel) && !istEingebaut(schluessel));
|
||||
}
|
||||
|
||||
/** Variablen, die definiert sind, aber nirgends verwendet werden. */
|
||||
export function unbenutzteVariablen(
|
||||
verwendete: readonly string[],
|
||||
variablenSchluessel: readonly string[],
|
||||
): readonly string[] {
|
||||
const benutzt = new Set(verwendete);
|
||||
return variablenSchluessel.filter((schluessel) => !benutzt.has(schluessel));
|
||||
}
|
||||
@@ -104,6 +104,9 @@ export const AbschnittSchema = z.object({
|
||||
.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
|
||||
@@ -176,6 +179,28 @@ export type Austausch = z.infer<typeof AustauschSchema>;
|
||||
* 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)) {
|
||||
|
||||
Reference in New Issue
Block a user