/** * Laden und Prüfen der Erklärungstexte. * * Die Erklärungen sind eigener redaktioneller Inhalt und liegen deshalb in * einer eigenen Datei neben dem amtlichen Katalog: * * - Entwicklung: `/content/erklaerungen.json` * - Gepackt: `/resources/erklaerungen.json` * * ## Fehlen ist zulässig * * Anders als der Fragenkatalog sind die Erklärungen **nicht** Voraussetzung * für den Betrieb. Fehlt die Datei oder ist sie unbrauchbar, läuft die * Anwendung ohne sie weiter – der Lernbetrieb hängt nicht daran. Ein Fehler * wird vermerkt, damit er in der Oberfläche erklärt werden kann, aber er * bringt nichts zum Stillstand. * * ## Was hier NICHT geprüft wird * * Ob eine Fundstelle im Gesetz tatsächlich existiert. Das ist Aufgabe von * `data-pipeline/pruefe_erklaerungen.py` vor der Auslieferung: Dort steht der * amtliche Gesetzestext zur Verfügung, hier nicht. Zur Laufzeit wird nur * geprüft, dass die Daten die vereinbarte Form haben. */ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { app } from 'electron'; import { GESETZE, type Erklaerung, type Erklaerungen, type Fundstelle, type Gesetzeskuerzel, } from '../shared/erklaerungen'; import { katalogLaden, sucheAufwaerts } from './katalog'; const DATEI = 'erklaerungen.json'; /** * Normbezeichnung: „§ 12", „§ 12a" oder eine Anlage. * * Anlagen werden nicht einheitlich bezeichnet: WaffG und 1. SprengV zählen * arabisch („Anlage 1"), BeschussV und SprengG römisch („Anlage II"), die * AWaffV hat genau eine und nennt sie nur „Anlage". */ const NORM_MUSTER = /^(§ \d+[a-z]?|Anlage( [0-9IVX]+)?)$/u; /** Gliederungsangaben: Ziffern, optional mit einem Buchstaben. */ const GLIEDERUNG_MUSTER = /^[0-9]{1,3}[a-z]?$/u; /** Buchstabenangabe: ein oder zwei Kleinbuchstaben. */ const BUCHSTABE_MUSTER = /^[a-z]{1,2}$/u; function ungueltig(nachricht: string): never { throw new Error(`Erklärungen 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 pflichttext(wert: unknown, pfad: string): string { if (typeof wert !== 'string' || wert.trim().length === 0) { ungueltig(`${pfad} fehlt oder ist leer.`); } return wert; } function kuerteText(wert: unknown, pfad: string): string | undefined { if (wert === undefined) { return undefined; } return pflichttext(wert, pfad); } /** Optionale Gliederungsangabe, gegen ein Muster geprüft. */ function gliederung(wert: unknown, pfad: string, muster: RegExp): string | undefined { if (wert === undefined) { return undefined; } if (typeof wert !== 'string' || !muster.test(wert)) { ungueltig(`${pfad} hat eine unerwartete Form.`); } return wert; } function fundstelleLesen(roh: unknown, pfad: string): Fundstelle { const o = objekt(roh, pfad); const gesetz = o['gesetz']; if (typeof gesetz !== 'string' || !(GESETZE as readonly string[]).includes(gesetz)) { ungueltig(`${pfad}.gesetz ist kein bekanntes Gesetz.`); } const norm = pflichttext(o['norm'], `${pfad}.norm`); if (!NORM_MUSTER.test(norm)) { ungueltig(`${pfad}.norm muss „§ 12“ oder „Anlage 1“ lauten.`); } return { gesetz: gesetz as Gesetzeskuerzel, norm, ...optional('absatz', gliederung(o['absatz'], `${pfad}.absatz`, GLIEDERUNG_MUSTER)), ...optional('nummer', gliederung(o['nummer'], `${pfad}.nummer`, GLIEDERUNG_MUSTER)), ...optional('buchstabe', gliederung(o['buchstabe'], `${pfad}.buchstabe`, BUCHSTABE_MUSTER)), ...optional('satz', gliederung(o['satz'], `${pfad}.satz`, GLIEDERUNG_MUSTER)), ...optional('stelle', kuerteText(o['stelle'], `${pfad}.stelle`)), }; } /** * Baut ein Feld nur ein, wenn es einen Wert hat. * * `exactOptionalPropertyTypes` unterscheidet „Feld fehlt“ von * „Feld ist undefined“; ein pauschales Zuweisen wäre ein Typfehler. */ function optional( schluessel: K, wert: string | undefined, ): Record | Record { return wert === undefined ? {} : ({ [schluessel]: wert } as Record); } function erklaerungLesen(roh: unknown, pfad: string): Erklaerung { const o = objekt(roh, pfad); const fundstellenRoh = o['fundstellen']; if (!Array.isArray(fundstellenRoh)) { ungueltig(`${pfad}.fundstellen fehlt oder ist keine Liste.`); } const fundstellen = (fundstellenRoh as readonly unknown[]).map((eintrag, i) => fundstelleLesen(eintrag, `${pfad}.fundstellen[${String(i)}]`), ); const grund = kuerteText(o['ohneFundstelleGrund'], `${pfad}.ohneFundstelleGrund`); if (fundstellen.length === 0 && grund === undefined) { ungueltig(`${pfad}: ohne Fundstelle muss ein Grund angegeben sein.`); } const merksatz = kuerteText(o['merksatz'], `${pfad}.merksatz`); /* Dass die genannten Fragen existieren und die Verweise beidseitig sind, prüft `pruefe_erklaerungen.py` vor der Auslieferung – dort steht der Katalog zur Verfügung, hier nicht. Der Lader prüft die Form. */ const verwandtRoh = o['verwandt']; let verwandt: string[] | undefined; if (verwandtRoh !== undefined) { if (!Array.isArray(verwandtRoh)) { ungueltig(`${pfad}.verwandt ist keine Liste.`); } verwandt = (verwandtRoh as readonly unknown[]).map((eintrag, i) => pflichttext(eintrag, `${pfad}.verwandt[${String(i)}]`), ); if (verwandt.length === 0) { /* Eine leere Liste wäre eine Angabe, die nichts angibt – und die Oberfläche zeigte eine Überschrift ohne Inhalt darunter. */ ungueltig(`${pfad}.verwandt ist leer; dann gehört das Feld weg.`); } } /* Die Kernpunkte sind eine Einschaetzung dieser Software, keine Aussage ueber den amtlichen Katalog. Der Lader prueft nur die Form; ob ein Punkt traegt, entscheidet die redaktionelle Durchsicht. */ const kernpunkteRoh = o['kernpunkte']; let kernpunkte: string[] | undefined; if (kernpunkteRoh !== undefined) { if (!Array.isArray(kernpunkteRoh)) { ungueltig(`${pfad}.kernpunkte ist keine Liste.`); } kernpunkte = (kernpunkteRoh as readonly unknown[]).map((eintrag, i) => pflichttext(eintrag, `${pfad}.kernpunkte[${String(i)}]`), ); if (kernpunkte.length < 2) { /* Eine Pruefliste mit einem Punkt waere kein Werkzeug, sondern Beiwerk – und eine leere waere eine Ueberschrift ohne Inhalt. */ ungueltig(`${pfad}.kernpunkte braucht mindestens zwei Punkte.`); } } return { kurz: pflichttext(o['kurz'], `${pfad}.kurz`), text: pflichttext(o['text'], `${pfad}.text`), fundstellen, ...(grund === undefined ? {} : { ohneFundstelleGrund: grund }), ...(merksatz === undefined ? {} : { merksatz }), ...(verwandt === undefined ? {} : { verwandt }), ...(kernpunkte === undefined ? {} : { kernpunkte }), }; } function erklaerungenValidieren(roh: unknown): Erklaerungen { const o = objekt(roh, 'Die Datei'); const meta = objekt(o['meta'], 'meta'); const zuFrageRoh = objekt(o['zuFrage'], 'zuFrage'); const version = meta['version']; if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) { ungueltig('meta.version fehlt oder ist keine ganze Zahl ab 1.'); } const gesetzesstandRoh = objekt(meta['gesetzesstand'], 'meta.gesetzesstand'); const gesetzesstand: Record = {}; for (const [kuerzel, stand] of Object.entries(gesetzesstandRoh)) { gesetzesstand[kuerzel] = pflichttext(stand, `meta.gesetzesstand.${kuerzel}`); } /* Erklärungen zu Fragen, die es nicht gibt, werden verworfen statt abgewiesen: Ein solcher Rest ist harmlos, und ein Programm, das deswegen gar keine Erklärungen zeigt, wäre die schlechtere Antwort darauf. */ const bekannt = new Set(katalogLaden().fragen.map((frage) => frage.id)); const zuFrage: Record = {}; let verworfen = 0; for (const [frageId, eintrag] of Object.entries(zuFrageRoh)) { if (!bekannt.has(frageId)) { verworfen += 1; continue; } zuFrage[frageId] = erklaerungLesen(eintrag, `zuFrage.${frageId}`); } if (verworfen > 0) { console.warn( `Erklärungen: ${String(verworfen)} Eintrag/Einträge ohne passende Frage übergangen.`, ); } return { meta: { version, stand: pflichttext(meta['stand'], 'meta.stand'), gesetzesstand, hinweis: pflichttext(meta['hinweis'], 'meta.hinweis'), }, zuFrage, }; } /** Pfad der Erklärungsdatei – gepackt wie ungepackt. */ export function erklaerungenPfad(): string { if (app.isPackaged) { return join(process.resourcesPath, DATEI); } const relativ = join('content', DATEI); for (const start of [app.getAppPath(), __dirname, process.cwd()]) { const treffer = sucheAufwaerts(start, relativ); if (treffer) { return treffer; } } return join(app.getAppPath(), '..', relativ); } /** Leerer Bestand – die Anwendung läuft auch ohne Erklärungen. */ const LEER: Erklaerungen = Object.freeze({ meta: Object.freeze({ version: 0, stand: '', gesetzesstand: Object.freeze({}), hinweis: '', }), zuFrage: Object.freeze({}), }); let speicher: Erklaerungen | null = null; export function erklaerungenLaden(): Erklaerungen { if (speicher !== null) { return speicher; } const pfad = erklaerungenPfad(); if (!existsSync(pfad)) { speicher = LEER; return speicher; } try { speicher = erklaerungenValidieren(JSON.parse(readFileSync(pfad, 'utf8'))); } catch (fehler: unknown) { /* Bewusst kein erneuter Versuch bei jedem Aufruf: Eine kaputte Datei wird zwischen zwei Aufrufen nicht heil, und die Meldung soll einmal erscheinen, nicht bei jeder Frage. */ console.error('Erklärungen konnten nicht gelesen werden:', fehler); speicher = LEER; } return speicher; }