waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app src main katalog.ts

17,1 KB Rohdatei
app/src/main/katalog.ts — 490 Zeilen
1 /**
2 * Laden, Prüfen und Bereitstellen des amtlichen Fragenkatalogs.
3 *
4 * Der Katalog wird beim ersten Zugriff einmalig von der Platte gelesen,
5 * streng validiert und danach im Speicher gehalten. 575 Fragen sind rund
6 * 0,9 MB JSON (880.466 Byte, gemessen am 03.09.2026) – ein zweites Parsen
7 * wäre reine Verschwendung.
8 *
9 * Ablageort der Daten:
10 * - Entwicklung: `<repo>/content/katalog/`
11 * - Gepackt: `<app>/resources/katalog/`
12 *
13 * Der gepackte Pfad entsteht durch den `extraResources`-Eintrag in
14 * `electron-builder.yml`. Beide Fälle werden über `app.isPackaged`
15 * unterschieden.
16 *
17 * Sicherheitsgrundsatz für Bilder: der Renderer nennt ausschließlich
18 * Bild-IDs. Ein Pfad aus dem Renderer wird niemals verwendet – die Datei
19 * ergibt sich immer aus dem geprüften Katalogeintrag.
20 */
21
22 import { existsSync, readFileSync } from 'node:fs';
23 import { dirname, join } from 'node:path';
24
25 import { app } from 'electron';
26
27 import { entschaerft } from './eingaben';
28 import type {
29 Antwortoption,
30 Frage,
31 Fragetyp,
32 Kapitel,
33 Katalog,
34 KatalogBild,
35 KatalogMeta,
36 RichText,
37 TextSegment,
38 } from '../shared/katalog';
39
40 /** Name des Verzeichnisses mit den Katalogdaten – gepackt wie ungepackt. */
41 const KATALOG_ORDNER = 'katalog';
42
43 /** Unterverzeichnis der Prüfzeichen innerhalb des Katalogverzeichnisses. */
44 const ASSET_ORDNER = 'assets';
45
46 const KATALOG_DATEI = 'katalog.json';
47
48 const FRAGETYPEN: readonly Fragetyp[] = ['mc', 'freitext', 'lueckentext'];
49
50 /** Amtliche Antwortlabels. Der Katalog nutzt derzeit „a“ bis „g“. */
51 const LABEL_MUSTER = /^[a-h]$/u;
52
53 /**
54 * Erlaubte Dateinamen für Prüfzeichen: reiner Basisname, keine Trenner,
55 * kein `..`. Damit kann aus einem Katalogeintrag kein Pfad ausbrechen,
56 * selbst wenn die JSON-Datei manipuliert wurde.
57 */
58 const DATEINAME_MUSTER = /^[A-Za-z0-9._-]+\.png$/u;
59
60 /** ISO-Datum `JJJJ-MM-TT`. */
61 const ISO_DATUM_MUSTER = /^\d{4}-\d{2}-\d{2}$/u;
62
63 // ─── Validierung ────────────────────────────────────────────────────────────
64
65 /** Bricht die Validierung mit einer sprechenden deutschen Meldung ab. */
66 function ungueltig(nachricht: string): never {
67 throw new Error(`Fragenkatalog ungültig: ${nachricht}`);
68 }
69
70 function objekt(wert: unknown, pfad: string): Record<string, unknown> {
71 if (typeof wert !== 'object' || wert === null || Array.isArray(wert)) {
72 ungueltig(`${pfad} ist kein Objekt.`);
73 }
74 return wert as Record<string, unknown>;
75 }
76
77 function liste(wert: unknown, pfad: string): readonly unknown[] {
78 if (!Array.isArray(wert)) {
79 ungueltig(`${pfad} ist keine Liste.`);
80 }
81 return wert as readonly unknown[];
82 }
83
84 /** Zeichenkette mit Inhalt – leere Angaben gelten als fehlendes Pflichtfeld. */
85 function pflichtText(wert: unknown, pfad: string): string {
86 if (typeof wert !== 'string' || wert.trim().length === 0) {
87 ungueltig(`${pfad} fehlt oder ist keine nicht-leere Zeichenkette.`);
88 }
89 return wert;
90 }
91
92 function textOderNull(wert: unknown, pfad: string): string | null {
93 if (wert === null || wert === undefined) {
94 return null;
95 }
96 if (typeof wert !== 'string') {
97 ungueltig(`${pfad} ist weder Zeichenkette noch null.`);
98 }
99 return wert;
100 }
101
102 function ganzzahl(wert: unknown, pfad: string, mindestens: number): number {
103 if (typeof wert !== 'number' || !Number.isInteger(wert) || wert < mindestens) {
104 ungueltig(`${pfad} ist keine ganze Zahl ab ${String(mindestens)}.`);
105 }
106 return wert;
107 }
108
109 function textliste(wert: unknown, pfad: string): string[] {
110 return liste(wert, pfad).map((eintrag, i) => pflichtText(eintrag, `${pfad}[${String(i)}]`));
111 }
112
113 function segment(wert: unknown, pfad: string): TextSegment {
114 const roh = objekt(wert, pfad);
115 const t = roh['t'];
116 if (typeof t !== 'string') {
117 ungueltig(`${pfad}.t ist keine Zeichenkette.`);
118 }
119 return roh['h'] === true ? { t, h: true } : { t };
120 }
121
122 function richText(wert: unknown, pfad: string): RichText {
123 const roh = objekt(wert, pfad);
124 const text = roh['text'];
125 if (typeof text !== 'string') {
126 ungueltig(`${pfad}.text ist keine Zeichenkette.`);
127 }
128 const segmente = liste(roh['segmente'], `${pfad}.segmente`).map((eintrag, i) =>
129 segment(eintrag, `${pfad}.segmente[${String(i)}]`),
130 );
131 return { text, segmente };
132 }
133
134 function meta(wert: unknown): KatalogMeta {
135 const roh = objekt(wert, 'meta');
136 const stand = pflichtText(roh['stand'], 'meta.stand');
137 if (!ISO_DATUM_MUSTER.test(stand)) {
138 ungueltig(`meta.stand ist kein ISO-Datum (JJJJ-MM-TT): „${stand}“.`);
139 }
140 return {
141 titel: pflichtText(roh['titel'], 'meta.titel'),
142 herausgeber: pflichtText(roh['herausgeber'], 'meta.herausgeber'),
143 stand,
144 quellenangabe: pflichtText(roh['quellenangabe'], 'meta.quellenangabe'),
145 quelle_url: pflichtText(roh['quelle_url'], 'meta.quelle_url'),
146 quelldatei_sha256: pflichtText(roh['quelldatei_sha256'], 'meta.quelldatei_sha256'),
147 fragen_gesamt: ganzzahl(roh['fragen_gesamt'], 'meta.fragen_gesamt', 1),
148 };
149 }
150
151 function kapitel(wert: unknown, pfad: string): Kapitel {
152 const roh = objekt(wert, pfad);
153 const abschnitte = liste(roh['abschnitte'], `${pfad}.abschnitte`).map((eintrag, i) => {
154 const ab = objekt(eintrag, `${pfad}.abschnitte[${String(i)}]`);
155 return {
156 id: pflichtText(ab['id'], `${pfad}.abschnitte[${String(i)}].id`),
157 titel: pflichtText(ab['titel'], `${pfad}.abschnitte[${String(i)}].titel`),
158 };
159 });
160 return {
161 id: pflichtText(roh['id'], `${pfad}.id`),
162 titel: pflichtText(roh['titel'], `${pfad}.titel`),
163 abschnitte,
164 };
165 }
166
167 function bild(wert: unknown, pfad: string): KatalogBild {
168 const roh = objekt(wert, pfad);
169 const datei = pflichtText(roh['datei'], `${pfad}.datei`);
170 if (!DATEINAME_MUSTER.test(datei)) {
171 ungueltig(`${pfad}.datei ist kein einfacher PNG-Dateiname: „${datei}“.`);
172 }
173 return {
174 id: pflichtText(roh['id'], `${pfad}.id`),
175 datei,
176 breite: ganzzahl(roh['breite'], `${pfad}.breite`, 1),
177 hoehe: ganzzahl(roh['hoehe'], `${pfad}.hoehe`, 1),
178 alt: textOderNull(roh['alt'], `${pfad}.alt`),
179 beschreibung: textOderNull(roh['beschreibung'], `${pfad}.beschreibung`),
180 };
181 }
182
183 function option(wert: unknown, pfad: string): Antwortoption {
184 const roh = objekt(wert, pfad);
185 const label = pflichtText(roh['label'], `${pfad}.label`);
186 if (!LABEL_MUSTER.test(label)) {
187 ungueltig(`${pfad}.label ist kein amtliches Label („a“ bis „h“): „${label}“.`);
188 }
189 if (typeof roh['korrekt'] !== 'boolean') {
190 ungueltig(`${pfad}.korrekt ist kein Wahrheitswert.`);
191 }
192 return {
193 label,
194 inhalt: richText(roh['inhalt'], `${pfad}.inhalt`),
195 korrekt: roh['korrekt'],
196 bilder: textliste(roh['bilder'], `${pfad}.bilder`),
197 };
198 }
199
200 function frage(wert: unknown, pfad: string): Frage {
201 const roh = objekt(wert, pfad);
202 const typ = pflichtText(roh['typ'], `${pfad}.typ`);
203 if (!(FRAGETYPEN as readonly string[]).includes(typ)) {
204 ungueltig(`${pfad}.typ ist unbekannt: „${typ}“.`);
205 }
206
207 const optionenRoh = roh['optionen'];
208 const optionen =
209 optionenRoh === undefined || optionenRoh === null
210 ? undefined
211 : liste(optionenRoh, `${pfad}.optionen`).map((eintrag, i) =>
212 option(eintrag, `${pfad}.optionen[${String(i)}]`),
213 );
214
215 if (typ === 'mc') {
216 if (optionen === undefined || optionen.length === 0) {
217 ungueltig(`${pfad} ist Multiple Choice, hat aber keine Antwortoptionen.`);
218 }
219 if (!optionen.some((o) => o.korrekt)) {
220 ungueltig(`${pfad} ist Multiple Choice, hat aber keine richtige Antwortoption.`);
221 }
222 const labels = new Set(optionen.map((o) => o.label));
223 if (labels.size !== optionen.length) {
224 ungueltig(`${pfad} enthält doppelte Antwortlabels.`);
225 }
226 }
227
228 const musterantwortRoh = roh['musterantwort'];
229 const musterantwort =
230 musterantwortRoh === undefined || musterantwortRoh === null
231 ? undefined
232 : richText(musterantwortRoh, `${pfad}.musterantwort`);
233
234 if (typ !== 'mc' && musterantwort === undefined) {
235 ungueltig(`${pfad} ist eine offene Frage, hat aber keine Musterantwort.`);
236 }
237
238 const warnungenRoh = roh['warnungen'];
239 const warnungen =
240 warnungenRoh === undefined || warnungenRoh === null
241 ? undefined
242 : textliste(warnungenRoh, `${pfad}.warnungen`);
243
244 const grund = {
245 id: pflichtText(roh['id'], `${pfad}.id`),
246 amtliche_nummer: pflichtText(roh['amtliche_nummer'], `${pfad}.amtliche_nummer`),
247 kapitel: pflichtText(roh['kapitel'], `${pfad}.kapitel`),
248 abschnitt: textOderNull(roh['abschnitt'], `${pfad}.abschnitt`),
249 typ: typ as Fragetyp,
250 seite: ganzzahl(roh['seite'], `${pfad}.seite`, 1),
251 frage: richText(roh['frage'], `${pfad}.frage`),
252 bilder: textliste(roh['bilder'], `${pfad}.bilder`),
253 };
254
255 // `exactOptionalPropertyTypes` verbietet `optionen: undefined` – die
256 // optionalen Felder werden deshalb nur bei Vorhandensein gesetzt.
257 return {
258 ...grund,
259 ...(optionen === undefined ? {} : { optionen }),
260 ...(musterantwort === undefined ? {} : { musterantwort }),
261 ...(warnungen === undefined ? {} : { warnungen }),
262 };
263 }
264
265 /**
266 * Prüft eine beliebige Eingabe und liefert einen typsicheren Katalog.
267 *
268 * Wirft bei jedem Verstoß einen `Error` mit deutscher Meldung, die den
269 * betroffenen Pfad nennt. Geprüft werden Pflichtfelder, Wertebereiche,
270 * Querverweise (Kapitel, Abschnitte, Bilder) und die Kopfzahl
271 * `meta.fragen_gesamt`.
272 */
273 export function katalogValidieren(roh: unknown): Katalog {
274 const wurzel = objekt(roh, 'Katalog');
275
276 const kopf = meta(wurzel['meta']);
277 const kapitelListe = liste(wurzel['kapitel'], 'kapitel').map((eintrag, i) =>
278 kapitel(eintrag, `kapitel[${String(i)}]`),
279 );
280 if (kapitelListe.length === 0) {
281 ungueltig('kapitel ist leer.');
282 }
283
284 const bildListe = liste(wurzel['bilder'], 'bilder').map((eintrag, i) =>
285 bild(eintrag, `bilder[${String(i)}]`),
286 );
287 const fragenListe = liste(wurzel['fragen'], 'fragen').map((eintrag, i) =>
288 frage(eintrag, `fragen[${String(i)}]`),
289 );
290
291 // ── Kopfzahl ──────────────────────────────────────────────────────────
292 if (fragenListe.length !== kopf.fragen_gesamt) {
293 ungueltig(
294 `meta.fragen_gesamt meldet ${String(kopf.fragen_gesamt)} Fragen, ` +
295 `enthalten sind aber ${String(fragenListe.length)}.`,
296 );
297 }
298
299 // ── Querverweise ──────────────────────────────────────────────────────
300 const kapitelIds = new Set(kapitelListe.map((k) => k.id));
301 const abschnittIds = new Set(kapitelListe.flatMap((k) => k.abschnitte.map((a) => a.id)));
302 const bildIds = new Set(bildListe.map((b) => b.id));
303
304 if (bildIds.size !== bildListe.length) {
305 ungueltig('bilder enthält doppelte IDs.');
306 }
307
308 const gesehen = new Set<string>();
309 for (const f of fragenListe) {
310 if (gesehen.has(f.id)) {
311 ungueltig(`fragen enthält die ID „${f.id}“ mehrfach.`);
312 }
313 gesehen.add(f.id);
314
315 if (!kapitelIds.has(f.kapitel)) {
316 ungueltig(`Frage „${f.id}“ verweist auf das unbekannte Kapitel „${f.kapitel}“.`);
317 }
318 if (f.abschnitt !== null && !abschnittIds.has(f.abschnitt)) {
319 ungueltig(`Frage „${f.id}“ verweist auf den unbekannten Abschnitt „${f.abschnitt}“.`);
320 }
321
322 const referenzen = [...f.bilder, ...(f.optionen ?? []).flatMap((o) => o.bilder)];
323 for (const referenz of referenzen) {
324 if (!bildIds.has(referenz)) {
325 ungueltig(`Frage „${f.id}“ verweist auf das unbekannte Bild „${referenz}“.`);
326 }
327 }
328 }
329
330 return { meta: kopf, kapitel: kapitelListe, bilder: bildListe, fragen: fragenListe };
331 }
332
333 // ─── Bildzugriff ────────────────────────────────────────────────────────────
334
335 /**
336 * Löst eine vom Renderer gelieferte Bild-ID gegen die im Katalog bekannten
337 * IDs auf.
338 *
339 * Das ist die einzige zugelassene Brücke vom Renderer zu einer Datei: es
340 * wird ausschließlich exakt verglichen, niemals zusammengesetzt. Eine ID wie
341 * `../../etc/passwd` findet schlicht keinen Treffer.
342 */
343 export function bildAufloesen(katalog: Katalog, bildId: unknown): KatalogBild {
344 if (typeof bildId !== 'string' || bildId.length === 0) {
345 throw new Error('Ungültige Bild-ID: es wurde eine nicht-leere Zeichenkette erwartet.');
346 }
347
348 const treffer = katalog.bilder.find((b) => b.id === bildId);
349 if (!treffer) {
350 throw new Error(`Unbekannte Bild-ID: „${entschaerft(bildId)}“.`);
351 }
352
353 // Doppelter Boden: der Dateiname wurde beim Laden geprüft, wird vor dem
354 // Zusammensetzen des Pfades aber erneut geprüft.
355 if (!DATEINAME_MUSTER.test(treffer.datei)) {
356 throw new Error(`Unzulässiger Dateiname im Katalog: „${entschaerft(treffer.datei)}“.`);
357 }
358
359 return treffer;
360 }
361
362 // ─── Dateizugriff ───────────────────────────────────────────────────────────
363
364 /** Höchstzahl der Verzeichnisebenen, die aufwärts durchsucht werden. */
365 const SUCHTIEFE = 6;
366
367 /**
368 * Sucht vom Startverzeichnis aus aufwärts nach einem relativen Pfad.
369 *
370 * Nötig, weil im ungepackten Betrieb nicht feststeht, aus welchem Verzeichnis
371 * die Anwendung gestartet wurde: `app.getAppPath()` zeigt beim Start aus dem
372 * Quellbaum auf `app/`, beim Start des gebauten Einstiegspunkts dagegen auf
373 * `app/out/main`. Eine feste Anzahl von „..“ wäre in einem der beiden Fälle
374 * immer falsch.
375 *
376 * Exportiert, weil `lizenzen.ts` dieselbe Auflösung für Dateien im
377 * Projektwurzelverzeichnis braucht.
378 */
379 export function sucheAufwaerts(start: string, relativ: string): string | null {
380 let verzeichnis = start;
381 for (let ebene = 0; ebene < SUCHTIEFE; ebene += 1) {
382 const kandidat = join(verzeichnis, relativ);
383 if (existsSync(kandidat)) {
384 return kandidat;
385 }
386 const eltern = dirname(verzeichnis);
387 if (eltern === verzeichnis) {
388 break; // Wurzel des Dateisystems erreicht
389 }
390 verzeichnis = eltern;
391 }
392 return null;
393 }
394
395 /** Verzeichnis mit `katalog.json` und `assets/`. */
396 export function katalogVerzeichnis(): string {
397 if (app.isPackaged) {
398 // Kommt aus `extraResources` in electron-builder.yml.
399 return join(process.resourcesPath, KATALOG_ORDNER);
400 }
401
402 const relativ = join('content', KATALOG_ORDNER);
403 for (const start of [app.getAppPath(), __dirname, process.cwd()]) {
404 const treffer = sucheAufwaerts(start, join(relativ, KATALOG_DATEI));
405 if (treffer) {
406 return dirname(treffer);
407 }
408 }
409 // Nichts gefunden: den erwarteten Ort zurückgeben, damit die Fehlermeldung
410 // den Pfad nennt, an dem die Datei liegen müsste.
411 return join(app.getAppPath(), '..', relativ);
412 }
413
414 function fehlerText(fehler: unknown): string {
415 return fehler instanceof Error ? fehler.message : String(fehler);
416 }
417
418 let katalogSpeicher: Katalog | null = null;
419 const bildSpeicher = new Map<string, string>();
420
421 function vonPlatteLaden(): Katalog {
422 const verzeichnis = katalogVerzeichnis();
423 const pfad = join(verzeichnis, KATALOG_DATEI);
424
425 if (!existsSync(pfad)) {
426 throw new Error(
427 `Fragenkatalog nicht gefunden: ${pfad}. ` +
428 'In der Entwicklung muss content/katalog/katalog.json vorhanden sein, ' +
429 'im gepackten Build sorgt der extraResources-Eintrag in electron-builder.yml dafür.',
430 );
431 }
432
433 let roh: unknown;
434 try {
435 roh = JSON.parse(readFileSync(pfad, 'utf8'));
436 } catch (fehler) {
437 // `cause`: die lesbare Meldung für den Nutzer, der ursprüngliche Fehler
438 // (Systemfehlercode, Aufrufliste, Position im JSON) für die Fehlersuche.
439 throw new Error(`Fragenkatalog nicht lesbar (${pfad}): ${fehlerText(fehler)}`, {
440 cause: fehler,
441 });
442 }
443
444 return katalogValidieren(roh);
445 }
446
447 /** Lädt den Katalog beim ersten Aufruf und liefert danach die Kopie im Speicher. */
448 export function katalogLaden(): Katalog {
449 katalogSpeicher ??= vonPlatteLaden();
450 return katalogSpeicher;
451 }
452
453 /**
454 * Liefert ein Prüfzeichen als Data-URL.
455 *
456 * Data-URL statt Dateipfad, weil die Content-Security-Policy dem Renderer
457 * jeden Datei- und Netzzugriff verwehrt. Die Ergebnisse werden
458 * zwischengespeichert – es sind 17 kleine PNG (gezählt in
459 * `content/katalog/assets/`; die Pipeline schreibt nur die Zeichen heraus,
460 * auf die auch eine Frage verweist).
461 */
462 export function katalogBild(bildId: unknown): string {
463 const eintrag = bildAufloesen(katalogLaden(), bildId);
464
465 const vorhanden = bildSpeicher.get(eintrag.id);
466 if (vorhanden !== undefined) {
467 return vorhanden;
468 }
469
470 const pfad = join(katalogVerzeichnis(), ASSET_ORDNER, eintrag.datei);
471 let daten: Buffer;
472 try {
473 daten = readFileSync(pfad);
474 } catch (fehler) {
475 // Siehe oben: Meldung für den Nutzer, Ursache für die Fehlersuche.
476 throw new Error(`Prüfzeichen „${eintrag.id}“ nicht lesbar: ${fehlerText(fehler)}`, {
477 cause: fehler,
478 });
479 }
480
481 const datenUrl = `data:image/png;base64,${daten.toString('base64')}`;
482 bildSpeicher.set(eintrag.id, datenUrl);
483 return datenUrl;
484 }
485
486 /** Leert die Zwischenspeicher – für Tests und einen sauberen Neustart. */
487 export function katalogZuruecksetzen(): void {
488 katalogSpeicher = null;
489 bildSpeicher.clear();
490 }