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