/** * Laden, Prüfen und Bereitstellen des amtlichen Fragenkatalogs. * * Der Katalog wird beim ersten Zugriff einmalig von der Platte gelesen, * streng validiert und danach im Speicher gehalten. 575 Fragen sind rund * 0,9 MB JSON (880.466 Byte, gemessen am 03.09.2026) – ein zweites Parsen * wäre reine Verschwendung. * * Ablageort der Daten: * - Entwicklung: `/content/katalog/` * - Gepackt: `/resources/katalog/` * * Der gepackte Pfad entsteht durch den `extraResources`-Eintrag in * `electron-builder.yml`. Beide Fälle werden über `app.isPackaged` * unterschieden. * * Sicherheitsgrundsatz für Bilder: der Renderer nennt ausschließlich * Bild-IDs. Ein Pfad aus dem Renderer wird niemals verwendet – die Datei * ergibt sich immer aus dem geprüften Katalogeintrag. */ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { app } from 'electron'; import { entschaerft } from './eingaben'; import type { Antwortoption, Frage, Fragetyp, Kapitel, Katalog, KatalogBild, KatalogMeta, RichText, TextSegment, } from '../shared/katalog'; /** Name des Verzeichnisses mit den Katalogdaten – gepackt wie ungepackt. */ const KATALOG_ORDNER = 'katalog'; /** Unterverzeichnis der Prüfzeichen innerhalb des Katalogverzeichnisses. */ const ASSET_ORDNER = 'assets'; const KATALOG_DATEI = 'katalog.json'; const FRAGETYPEN: readonly Fragetyp[] = ['mc', 'freitext', 'lueckentext']; /** Amtliche Antwortlabels. Der Katalog nutzt derzeit „a“ bis „g“. */ const LABEL_MUSTER = /^[a-h]$/u; /** * Erlaubte Dateinamen für Prüfzeichen: reiner Basisname, keine Trenner, * kein `..`. Damit kann aus einem Katalogeintrag kein Pfad ausbrechen, * selbst wenn die JSON-Datei manipuliert wurde. */ const DATEINAME_MUSTER = /^[A-Za-z0-9._-]+\.png$/u; /** ISO-Datum `JJJJ-MM-TT`. */ const ISO_DATUM_MUSTER = /^\d{4}-\d{2}-\d{2}$/u; // ─── Validierung ──────────────────────────────────────────────────────────── /** Bricht die Validierung mit einer sprechenden deutschen Meldung ab. */ function ungueltig(nachricht: string): never { throw new Error(`Fragenkatalog ungültig: ${nachricht}`); } function objekt(wert: unknown, pfad: string): Record { if (typeof wert !== 'object' || wert === null || Array.isArray(wert)) { ungueltig(`${pfad} ist kein Objekt.`); } return wert as Record; } function liste(wert: unknown, pfad: string): readonly unknown[] { if (!Array.isArray(wert)) { ungueltig(`${pfad} ist keine Liste.`); } return wert as readonly unknown[]; } /** Zeichenkette mit Inhalt – leere Angaben gelten als fehlendes Pflichtfeld. */ function pflichtText(wert: unknown, pfad: string): string { if (typeof wert !== 'string' || wert.trim().length === 0) { ungueltig(`${pfad} fehlt oder ist keine nicht-leere Zeichenkette.`); } return wert; } function textOderNull(wert: unknown, pfad: string): string | null { if (wert === null || wert === undefined) { return null; } if (typeof wert !== 'string') { ungueltig(`${pfad} ist weder Zeichenkette noch null.`); } return wert; } function ganzzahl(wert: unknown, pfad: string, mindestens: number): number { if (typeof wert !== 'number' || !Number.isInteger(wert) || wert < mindestens) { ungueltig(`${pfad} ist keine ganze Zahl ab ${String(mindestens)}.`); } return wert; } function textliste(wert: unknown, pfad: string): string[] { return liste(wert, pfad).map((eintrag, i) => pflichtText(eintrag, `${pfad}[${String(i)}]`)); } function segment(wert: unknown, pfad: string): TextSegment { const roh = objekt(wert, pfad); const t = roh['t']; if (typeof t !== 'string') { ungueltig(`${pfad}.t ist keine Zeichenkette.`); } return roh['h'] === true ? { t, h: true } : { t }; } function richText(wert: unknown, pfad: string): RichText { const roh = objekt(wert, pfad); const text = roh['text']; if (typeof text !== 'string') { ungueltig(`${pfad}.text ist keine Zeichenkette.`); } const segmente = liste(roh['segmente'], `${pfad}.segmente`).map((eintrag, i) => segment(eintrag, `${pfad}.segmente[${String(i)}]`), ); return { text, segmente }; } function meta(wert: unknown): KatalogMeta { const roh = objekt(wert, 'meta'); const stand = pflichtText(roh['stand'], 'meta.stand'); if (!ISO_DATUM_MUSTER.test(stand)) { ungueltig(`meta.stand ist kein ISO-Datum (JJJJ-MM-TT): „${stand}“.`); } return { titel: pflichtText(roh['titel'], 'meta.titel'), herausgeber: pflichtText(roh['herausgeber'], 'meta.herausgeber'), stand, quellenangabe: pflichtText(roh['quellenangabe'], 'meta.quellenangabe'), quelle_url: pflichtText(roh['quelle_url'], 'meta.quelle_url'), quelldatei_sha256: pflichtText(roh['quelldatei_sha256'], 'meta.quelldatei_sha256'), fragen_gesamt: ganzzahl(roh['fragen_gesamt'], 'meta.fragen_gesamt', 1), }; } function kapitel(wert: unknown, pfad: string): Kapitel { const roh = objekt(wert, pfad); const abschnitte = liste(roh['abschnitte'], `${pfad}.abschnitte`).map((eintrag, i) => { const ab = objekt(eintrag, `${pfad}.abschnitte[${String(i)}]`); return { id: pflichtText(ab['id'], `${pfad}.abschnitte[${String(i)}].id`), titel: pflichtText(ab['titel'], `${pfad}.abschnitte[${String(i)}].titel`), }; }); return { id: pflichtText(roh['id'], `${pfad}.id`), titel: pflichtText(roh['titel'], `${pfad}.titel`), abschnitte, }; } function bild(wert: unknown, pfad: string): KatalogBild { const roh = objekt(wert, pfad); const datei = pflichtText(roh['datei'], `${pfad}.datei`); if (!DATEINAME_MUSTER.test(datei)) { ungueltig(`${pfad}.datei ist kein einfacher PNG-Dateiname: „${datei}“.`); } return { id: pflichtText(roh['id'], `${pfad}.id`), datei, breite: ganzzahl(roh['breite'], `${pfad}.breite`, 1), hoehe: ganzzahl(roh['hoehe'], `${pfad}.hoehe`, 1), alt: textOderNull(roh['alt'], `${pfad}.alt`), beschreibung: textOderNull(roh['beschreibung'], `${pfad}.beschreibung`), }; } function option(wert: unknown, pfad: string): Antwortoption { const roh = objekt(wert, pfad); const label = pflichtText(roh['label'], `${pfad}.label`); if (!LABEL_MUSTER.test(label)) { ungueltig(`${pfad}.label ist kein amtliches Label („a“ bis „h“): „${label}“.`); } if (typeof roh['korrekt'] !== 'boolean') { ungueltig(`${pfad}.korrekt ist kein Wahrheitswert.`); } return { label, inhalt: richText(roh['inhalt'], `${pfad}.inhalt`), korrekt: roh['korrekt'], bilder: textliste(roh['bilder'], `${pfad}.bilder`), }; } function frage(wert: unknown, pfad: string): Frage { const roh = objekt(wert, pfad); const typ = pflichtText(roh['typ'], `${pfad}.typ`); if (!(FRAGETYPEN as readonly string[]).includes(typ)) { ungueltig(`${pfad}.typ ist unbekannt: „${typ}“.`); } const optionenRoh = roh['optionen']; const optionen = optionenRoh === undefined || optionenRoh === null ? undefined : liste(optionenRoh, `${pfad}.optionen`).map((eintrag, i) => option(eintrag, `${pfad}.optionen[${String(i)}]`), ); if (typ === 'mc') { if (optionen === undefined || optionen.length === 0) { ungueltig(`${pfad} ist Multiple Choice, hat aber keine Antwortoptionen.`); } if (!optionen.some((o) => o.korrekt)) { ungueltig(`${pfad} ist Multiple Choice, hat aber keine richtige Antwortoption.`); } const labels = new Set(optionen.map((o) => o.label)); if (labels.size !== optionen.length) { ungueltig(`${pfad} enthält doppelte Antwortlabels.`); } } const musterantwortRoh = roh['musterantwort']; const musterantwort = musterantwortRoh === undefined || musterantwortRoh === null ? undefined : richText(musterantwortRoh, `${pfad}.musterantwort`); if (typ !== 'mc' && musterantwort === undefined) { ungueltig(`${pfad} ist eine offene Frage, hat aber keine Musterantwort.`); } const warnungenRoh = roh['warnungen']; const warnungen = warnungenRoh === undefined || warnungenRoh === null ? undefined : textliste(warnungenRoh, `${pfad}.warnungen`); const grund = { id: pflichtText(roh['id'], `${pfad}.id`), amtliche_nummer: pflichtText(roh['amtliche_nummer'], `${pfad}.amtliche_nummer`), kapitel: pflichtText(roh['kapitel'], `${pfad}.kapitel`), abschnitt: textOderNull(roh['abschnitt'], `${pfad}.abschnitt`), typ: typ as Fragetyp, seite: ganzzahl(roh['seite'], `${pfad}.seite`, 1), frage: richText(roh['frage'], `${pfad}.frage`), bilder: textliste(roh['bilder'], `${pfad}.bilder`), }; // `exactOptionalPropertyTypes` verbietet `optionen: undefined` – die // optionalen Felder werden deshalb nur bei Vorhandensein gesetzt. return { ...grund, ...(optionen === undefined ? {} : { optionen }), ...(musterantwort === undefined ? {} : { musterantwort }), ...(warnungen === undefined ? {} : { warnungen }), }; } /** * Prüft eine beliebige Eingabe und liefert einen typsicheren Katalog. * * Wirft bei jedem Verstoß einen `Error` mit deutscher Meldung, die den * betroffenen Pfad nennt. Geprüft werden Pflichtfelder, Wertebereiche, * Querverweise (Kapitel, Abschnitte, Bilder) und die Kopfzahl * `meta.fragen_gesamt`. */ export function katalogValidieren(roh: unknown): Katalog { const wurzel = objekt(roh, 'Katalog'); const kopf = meta(wurzel['meta']); const kapitelListe = liste(wurzel['kapitel'], 'kapitel').map((eintrag, i) => kapitel(eintrag, `kapitel[${String(i)}]`), ); if (kapitelListe.length === 0) { ungueltig('kapitel ist leer.'); } const bildListe = liste(wurzel['bilder'], 'bilder').map((eintrag, i) => bild(eintrag, `bilder[${String(i)}]`), ); const fragenListe = liste(wurzel['fragen'], 'fragen').map((eintrag, i) => frage(eintrag, `fragen[${String(i)}]`), ); // ── Kopfzahl ────────────────────────────────────────────────────────── if (fragenListe.length !== kopf.fragen_gesamt) { ungueltig( `meta.fragen_gesamt meldet ${String(kopf.fragen_gesamt)} Fragen, ` + `enthalten sind aber ${String(fragenListe.length)}.`, ); } // ── Querverweise ────────────────────────────────────────────────────── const kapitelIds = new Set(kapitelListe.map((k) => k.id)); const abschnittIds = new Set(kapitelListe.flatMap((k) => k.abschnitte.map((a) => a.id))); const bildIds = new Set(bildListe.map((b) => b.id)); if (bildIds.size !== bildListe.length) { ungueltig('bilder enthält doppelte IDs.'); } const gesehen = new Set(); for (const f of fragenListe) { if (gesehen.has(f.id)) { ungueltig(`fragen enthält die ID „${f.id}“ mehrfach.`); } gesehen.add(f.id); if (!kapitelIds.has(f.kapitel)) { ungueltig(`Frage „${f.id}“ verweist auf das unbekannte Kapitel „${f.kapitel}“.`); } if (f.abschnitt !== null && !abschnittIds.has(f.abschnitt)) { ungueltig(`Frage „${f.id}“ verweist auf den unbekannten Abschnitt „${f.abschnitt}“.`); } const referenzen = [...f.bilder, ...(f.optionen ?? []).flatMap((o) => o.bilder)]; for (const referenz of referenzen) { if (!bildIds.has(referenz)) { ungueltig(`Frage „${f.id}“ verweist auf das unbekannte Bild „${referenz}“.`); } } } return { meta: kopf, kapitel: kapitelListe, bilder: bildListe, fragen: fragenListe }; } // ─── Bildzugriff ──────────────────────────────────────────────────────────── /** * Löst eine vom Renderer gelieferte Bild-ID gegen die im Katalog bekannten * IDs auf. * * Das ist die einzige zugelassene Brücke vom Renderer zu einer Datei: es * wird ausschließlich exakt verglichen, niemals zusammengesetzt. Eine ID wie * `../../etc/passwd` findet schlicht keinen Treffer. */ export function bildAufloesen(katalog: Katalog, bildId: unknown): KatalogBild { if (typeof bildId !== 'string' || bildId.length === 0) { throw new Error('Ungültige Bild-ID: es wurde eine nicht-leere Zeichenkette erwartet.'); } const treffer = katalog.bilder.find((b) => b.id === bildId); if (!treffer) { throw new Error(`Unbekannte Bild-ID: „${entschaerft(bildId)}“.`); } // Doppelter Boden: der Dateiname wurde beim Laden geprüft, wird vor dem // Zusammensetzen des Pfades aber erneut geprüft. if (!DATEINAME_MUSTER.test(treffer.datei)) { throw new Error(`Unzulässiger Dateiname im Katalog: „${entschaerft(treffer.datei)}“.`); } return treffer; } // ─── Dateizugriff ─────────────────────────────────────────────────────────── /** Höchstzahl der Verzeichnisebenen, die aufwärts durchsucht werden. */ const SUCHTIEFE = 6; /** * Sucht vom Startverzeichnis aus aufwärts nach einem relativen Pfad. * * Nötig, weil im ungepackten Betrieb nicht feststeht, aus welchem Verzeichnis * die Anwendung gestartet wurde: `app.getAppPath()` zeigt beim Start aus dem * Quellbaum auf `app/`, beim Start des gebauten Einstiegspunkts dagegen auf * `app/out/main`. Eine feste Anzahl von „..“ wäre in einem der beiden Fälle * immer falsch. * * Exportiert, weil `lizenzen.ts` dieselbe Auflösung für Dateien im * Projektwurzelverzeichnis braucht. */ export function sucheAufwaerts(start: string, relativ: string): string | null { let verzeichnis = start; for (let ebene = 0; ebene < SUCHTIEFE; ebene += 1) { const kandidat = join(verzeichnis, relativ); if (existsSync(kandidat)) { return kandidat; } const eltern = dirname(verzeichnis); if (eltern === verzeichnis) { break; // Wurzel des Dateisystems erreicht } verzeichnis = eltern; } return null; } /** Verzeichnis mit `katalog.json` und `assets/`. */ export function katalogVerzeichnis(): string { if (app.isPackaged) { // Kommt aus `extraResources` in electron-builder.yml. return join(process.resourcesPath, KATALOG_ORDNER); } const relativ = join('content', KATALOG_ORDNER); for (const start of [app.getAppPath(), __dirname, process.cwd()]) { const treffer = sucheAufwaerts(start, join(relativ, KATALOG_DATEI)); if (treffer) { return dirname(treffer); } } // Nichts gefunden: den erwarteten Ort zurückgeben, damit die Fehlermeldung // den Pfad nennt, an dem die Datei liegen müsste. return join(app.getAppPath(), '..', relativ); } function fehlerText(fehler: unknown): string { return fehler instanceof Error ? fehler.message : String(fehler); } let katalogSpeicher: Katalog | null = null; const bildSpeicher = new Map(); function vonPlatteLaden(): Katalog { const verzeichnis = katalogVerzeichnis(); const pfad = join(verzeichnis, KATALOG_DATEI); if (!existsSync(pfad)) { throw new Error( `Fragenkatalog nicht gefunden: ${pfad}. ` + 'In der Entwicklung muss content/katalog/katalog.json vorhanden sein, ' + 'im gepackten Build sorgt der extraResources-Eintrag in electron-builder.yml dafür.', ); } let roh: unknown; try { roh = JSON.parse(readFileSync(pfad, 'utf8')); } catch (fehler) { // `cause`: die lesbare Meldung für den Nutzer, der ursprüngliche Fehler // (Systemfehlercode, Aufrufliste, Position im JSON) für die Fehlersuche. throw new Error(`Fragenkatalog nicht lesbar (${pfad}): ${fehlerText(fehler)}`, { cause: fehler, }); } return katalogValidieren(roh); } /** Lädt den Katalog beim ersten Aufruf und liefert danach die Kopie im Speicher. */ export function katalogLaden(): Katalog { katalogSpeicher ??= vonPlatteLaden(); return katalogSpeicher; } /** * Liefert ein Prüfzeichen als Data-URL. * * Data-URL statt Dateipfad, weil die Content-Security-Policy dem Renderer * jeden Datei- und Netzzugriff verwehrt. Die Ergebnisse werden * zwischengespeichert – es sind 17 kleine PNG (gezählt in * `content/katalog/assets/`; die Pipeline schreibt nur die Zeichen heraus, * auf die auch eine Frage verweist). */ export function katalogBild(bildId: unknown): string { const eintrag = bildAufloesen(katalogLaden(), bildId); const vorhanden = bildSpeicher.get(eintrag.id); if (vorhanden !== undefined) { return vorhanden; } const pfad = join(katalogVerzeichnis(), ASSET_ORDNER, eintrag.datei); let daten: Buffer; try { daten = readFileSync(pfad); } catch (fehler) { // Siehe oben: Meldung für den Nutzer, Ursache für die Fehlersuche. throw new Error(`Prüfzeichen „${eintrag.id}“ nicht lesbar: ${fehlerText(fehler)}`, { cause: fehler, }); } const datenUrl = `data:image/png;base64,${daten.toString('base64')}`; bildSpeicher.set(eintrag.id, datenUrl); return datenUrl; } /** Leert die Zwischenspeicher – für Tests und einen sauberen Neustart. */ export function katalogZuruecksetzen(): void { katalogSpeicher = null; bildSpeicher.clear(); }