waffensachkunde

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

/ app src main themen.ts

4,5 KB Rohdatei
app/src/main/themen.ts — 156 Zeilen
1 /**
2 * Laden und Prüfen der Themengliederung.
3 *
4 * Wie Erklärungen, Glossar und Normtexte eine eigene Datei:
5 *
6 * - Entwicklung: `<repo>/content/themen.json`
7 * - Gepackt: `<app>/resources/themen.json`
8 *
9 * Fehlt sie oder ist sie unbrauchbar, läuft die Anwendung ohne. Dann steht in
10 * den Kapiteln II bis IV nur der Knopf für das ganze Kapitel – also genau der
11 * Zustand vor 0.22.0.
12 *
13 * Die Prüfung hier betrifft nur die Form. Dass jede Frage der Kapitel II bis
14 * IV in genau einer Gruppe liegt, sichert `data-pipeline/themen_bauen.py` vor
15 * der Auslieferung – dort steht der Katalog zur Verfügung, hier nicht.
16 */
17
18 import { existsSync, readFileSync } from 'node:fs';
19 import { join } from 'node:path';
20
21 import { app } from 'electron';
22
23 import { THEMEN_LEER, type Themen, type Themengruppe } from '../shared/themen';
24 import { sucheAufwaerts } from './katalog';
25
26 const DATEI = 'themen.json';
27
28 function ungueltig(nachricht: string): never {
29 throw new Error(`Themen ungültig: ${nachricht}`);
30 }
31
32 function objekt(wert: unknown, pfad: string): Record<string, unknown> {
33 if (typeof wert !== 'object' || wert === null || Array.isArray(wert)) {
34 ungueltig(`${pfad} ist kein Objekt.`);
35 }
36 return wert as Record<string, unknown>;
37 }
38
39 function pflichttext(wert: unknown, pfad: string): string {
40 if (typeof wert !== 'string' || wert.trim().length === 0) {
41 ungueltig(`${pfad} fehlt oder ist leer.`);
42 }
43 return wert;
44 }
45
46 function gruppeLesen(roh: unknown, pfad: string): Themengruppe {
47 const o = objekt(roh, pfad);
48
49 const fragenRoh = o['fragen'];
50 if (!Array.isArray(fragenRoh)) {
51 ungueltig(`${pfad}.fragen fehlt oder ist keine Liste.`);
52 }
53 const fragen = (fragenRoh as readonly unknown[]).map((eintrag, i) =>
54 pflichttext(eintrag, `${pfad}.fragen[${String(i)}]`),
55 );
56 if (fragen.length === 0) {
57 /* Eine leere Gruppe erschiene als Knopf, der eine Sitzung ohne Fragen
58 startet – schlimmer als eine fehlende Gruppe. */
59 ungueltig(`${pfad}.fragen ist leer.`);
60 }
61
62 return {
63 id: pflichttext(o['id'], `${pfad}.id`),
64 kapitel: pflichttext(o['kapitel'], `${pfad}.kapitel`),
65 titel: pflichttext(o['titel'], `${pfad}.titel`),
66 fragen,
67 };
68 }
69
70 function themenValidieren(roh: unknown): Themen {
71 const o = objekt(roh, 'Die Datei');
72 const meta = objekt(o['meta'], 'meta');
73
74 const version = meta['version'];
75 if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
76 ungueltig('meta.version fehlt oder ist keine ganze Zahl ab 1.');
77 }
78
79 const gruppenRoh = o['gruppen'];
80 if (!Array.isArray(gruppenRoh)) {
81 ungueltig('gruppen fehlt oder ist keine Liste.');
82 }
83 const gruppen = (gruppenRoh as readonly unknown[]).map((gruppe, i) =>
84 gruppeLesen(gruppe, `gruppen[${String(i)}]`),
85 );
86
87 /* Doppelte Kennungen wären in der Anzeige mehrdeutig, doppelte Fragen
88 hießen, dass ein Thema Fragen eines anderen mitzieht. */
89 const kennungen = new Set<string>();
90 const fragen = new Set<string>();
91 for (const gruppe of gruppen) {
92 if (kennungen.has(gruppe.id)) {
93 ungueltig(`Die Gruppenkennung „${gruppe.id}“ kommt mehrfach vor.`);
94 }
95 kennungen.add(gruppe.id);
96 for (const frageId of gruppe.fragen) {
97 if (fragen.has(frageId)) {
98 ungueltig(`${frageId} steht in mehr als einer Gruppe.`);
99 }
100 fragen.add(frageId);
101 }
102 }
103
104 return {
105 meta: {
106 version,
107 stand: pflichttext(meta['stand'], 'meta.stand'),
108 hinweis: pflichttext(meta['hinweis'], 'meta.hinweis'),
109 },
110 gruppen,
111 };
112 }
113
114 /** Pfad der Themendatei – gepackt wie ungepackt. */
115 export function themenPfad(): string {
116 if (app.isPackaged) {
117 return join(process.resourcesPath, DATEI);
118 }
119
120 const relativ = join('content', DATEI);
121 for (const start of [app.getAppPath(), __dirname, process.cwd()]) {
122 const treffer = sucheAufwaerts(start, relativ);
123 if (treffer) {
124 return treffer;
125 }
126 }
127 return join(app.getAppPath(), '..', relativ);
128 }
129
130 let speicher: Themen | null = null;
131
132 export function themenLaden(): Themen {
133 if (speicher !== null) {
134 return speicher;
135 }
136
137 const pfad = themenPfad();
138 if (!existsSync(pfad)) {
139 speicher = THEMEN_LEER;
140 return speicher;
141 }
142
143 try {
144 speicher = themenValidieren(JSON.parse(readFileSync(pfad, 'utf8')));
145 } catch (fehler: unknown) {
146 console.error('Themen konnten nicht gelesen werden:', fehler);
147 speicher = THEMEN_LEER;
148 }
149
150 return speicher;
151 }
152
153 /** Gegenstück für Tests. */
154 export function themenZuruecksetzen(): void {
155 speicher = null;
156 }