waffensachkunde

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

/ app src main erklaerungen.ts

9,8 KB Rohdatei
app/src/main/erklaerungen.ts — 297 Zeilen
1 /**
2 * Laden und Prüfen der Erklärungstexte.
3 *
4 * Die Erklärungen sind eigener redaktioneller Inhalt und liegen deshalb in
5 * einer eigenen Datei neben dem amtlichen Katalog:
6 *
7 * - Entwicklung: `<repo>/content/erklaerungen.json`
8 * - Gepackt: `<app>/resources/erklaerungen.json`
9 *
10 * ## Fehlen ist zulässig
11 *
12 * Anders als der Fragenkatalog sind die Erklärungen **nicht** Voraussetzung
13 * für den Betrieb. Fehlt die Datei oder ist sie unbrauchbar, läuft die
14 * Anwendung ohne sie weiter – der Lernbetrieb hängt nicht daran. Ein Fehler
15 * wird vermerkt, damit er in der Oberfläche erklärt werden kann, aber er
16 * bringt nichts zum Stillstand.
17 *
18 * ## Was hier NICHT geprüft wird
19 *
20 * Ob eine Fundstelle im Gesetz tatsächlich existiert. Das ist Aufgabe von
21 * `data-pipeline/pruefe_erklaerungen.py` vor der Auslieferung: Dort steht der
22 * amtliche Gesetzestext zur Verfügung, hier nicht. Zur Laufzeit wird nur
23 * geprüft, dass die Daten die vereinbarte Form haben.
24 */
25
26 import { existsSync, readFileSync } from 'node:fs';
27 import { join } from 'node:path';
28
29 import { app } from 'electron';
30
31 import {
32 GESETZE,
33 type Erklaerung,
34 type Erklaerungen,
35 type Fundstelle,
36 type Gesetzeskuerzel,
37 } from '../shared/erklaerungen';
38 import { katalogLaden, sucheAufwaerts } from './katalog';
39
40 const DATEI = 'erklaerungen.json';
41
42 /**
43 * Normbezeichnung: „§ 12", „§ 12a" oder eine Anlage.
44 *
45 * Anlagen werden nicht einheitlich bezeichnet: WaffG und 1. SprengV zählen
46 * arabisch („Anlage 1"), BeschussV und SprengG römisch („Anlage II"), die
47 * AWaffV hat genau eine und nennt sie nur „Anlage".
48 */
49 const NORM_MUSTER = /^(§ \d+[a-z]?|Anlage( [0-9IVX]+)?)$/u;
50
51 /** Gliederungsangaben: Ziffern, optional mit einem Buchstaben. */
52 const GLIEDERUNG_MUSTER = /^[0-9]{1,3}[a-z]?$/u;
53
54 /** Buchstabenangabe: ein oder zwei Kleinbuchstaben. */
55 const BUCHSTABE_MUSTER = /^[a-z]{1,2}$/u;
56
57 function ungueltig(nachricht: string): never {
58 throw new Error(`Erklärungen ungültig: ${nachricht}`);
59 }
60
61 function objekt(wert: unknown, pfad: string): Record<string, unknown> {
62 if (typeof wert !== 'object' || wert === null || Array.isArray(wert)) {
63 ungueltig(`${pfad} ist kein Objekt.`);
64 }
65 return wert as Record<string, unknown>;
66 }
67
68 function pflichttext(wert: unknown, pfad: string): string {
69 if (typeof wert !== 'string' || wert.trim().length === 0) {
70 ungueltig(`${pfad} fehlt oder ist leer.`);
71 }
72 return wert;
73 }
74
75 function kuerteText(wert: unknown, pfad: string): string | undefined {
76 if (wert === undefined) {
77 return undefined;
78 }
79 return pflichttext(wert, pfad);
80 }
81
82 /** Optionale Gliederungsangabe, gegen ein Muster geprüft. */
83 function gliederung(wert: unknown, pfad: string, muster: RegExp): string | undefined {
84 if (wert === undefined) {
85 return undefined;
86 }
87 if (typeof wert !== 'string' || !muster.test(wert)) {
88 ungueltig(`${pfad} hat eine unerwartete Form.`);
89 }
90 return wert;
91 }
92
93 function fundstelleLesen(roh: unknown, pfad: string): Fundstelle {
94 const o = objekt(roh, pfad);
95
96 const gesetz = o['gesetz'];
97 if (typeof gesetz !== 'string' || !(GESETZE as readonly string[]).includes(gesetz)) {
98 ungueltig(`${pfad}.gesetz ist kein bekanntes Gesetz.`);
99 }
100
101 const norm = pflichttext(o['norm'], `${pfad}.norm`);
102 if (!NORM_MUSTER.test(norm)) {
103 ungueltig(`${pfad}.norm muss „§ 12“ oder „Anlage 1“ lauten.`);
104 }
105
106 return {
107 gesetz: gesetz as Gesetzeskuerzel,
108 norm,
109 ...optional('absatz', gliederung(o['absatz'], `${pfad}.absatz`, GLIEDERUNG_MUSTER)),
110 ...optional('nummer', gliederung(o['nummer'], `${pfad}.nummer`, GLIEDERUNG_MUSTER)),
111 ...optional('buchstabe', gliederung(o['buchstabe'], `${pfad}.buchstabe`, BUCHSTABE_MUSTER)),
112 ...optional('satz', gliederung(o['satz'], `${pfad}.satz`, GLIEDERUNG_MUSTER)),
113 ...optional('stelle', kuerteText(o['stelle'], `${pfad}.stelle`)),
114 };
115 }
116
117 /**
118 * Baut ein Feld nur ein, wenn es einen Wert hat.
119 *
120 * `exactOptionalPropertyTypes` unterscheidet „Feld fehlt“ von
121 * „Feld ist undefined“; ein pauschales Zuweisen wäre ein Typfehler.
122 */
123 function optional<K extends string>(
124 schluessel: K,
125 wert: string | undefined,
126 ): Record<K, string> | Record<string, never> {
127 return wert === undefined ? {} : ({ [schluessel]: wert } as Record<K, string>);
128 }
129
130 function erklaerungLesen(roh: unknown, pfad: string): Erklaerung {
131 const o = objekt(roh, pfad);
132
133 const fundstellenRoh = o['fundstellen'];
134 if (!Array.isArray(fundstellenRoh)) {
135 ungueltig(`${pfad}.fundstellen fehlt oder ist keine Liste.`);
136 }
137
138 const fundstellen = (fundstellenRoh as readonly unknown[]).map((eintrag, i) =>
139 fundstelleLesen(eintrag, `${pfad}.fundstellen[${String(i)}]`),
140 );
141
142 const grund = kuerteText(o['ohneFundstelleGrund'], `${pfad}.ohneFundstelleGrund`);
143 if (fundstellen.length === 0 && grund === undefined) {
144 ungueltig(`${pfad}: ohne Fundstelle muss ein Grund angegeben sein.`);
145 }
146
147 const merksatz = kuerteText(o['merksatz'], `${pfad}.merksatz`);
148
149 /* Dass die genannten Fragen existieren und die Verweise beidseitig sind,
150 prüft `pruefe_erklaerungen.py` vor der Auslieferung – dort steht der
151 Katalog zur Verfügung, hier nicht. Der Lader prüft die Form. */
152 const verwandtRoh = o['verwandt'];
153 let verwandt: string[] | undefined;
154 if (verwandtRoh !== undefined) {
155 if (!Array.isArray(verwandtRoh)) {
156 ungueltig(`${pfad}.verwandt ist keine Liste.`);
157 }
158 verwandt = (verwandtRoh as readonly unknown[]).map((eintrag, i) =>
159 pflichttext(eintrag, `${pfad}.verwandt[${String(i)}]`),
160 );
161 if (verwandt.length === 0) {
162 /* Eine leere Liste wäre eine Angabe, die nichts angibt – und die
163 Oberfläche zeigte eine Überschrift ohne Inhalt darunter. */
164 ungueltig(`${pfad}.verwandt ist leer; dann gehört das Feld weg.`);
165 }
166 }
167
168 /* Die Kernpunkte sind eine Einschaetzung dieser Software, keine Aussage
169 ueber den amtlichen Katalog. Der Lader prueft nur die Form; ob ein Punkt
170 traegt, entscheidet die redaktionelle Durchsicht. */
171 const kernpunkteRoh = o['kernpunkte'];
172 let kernpunkte: string[] | undefined;
173 if (kernpunkteRoh !== undefined) {
174 if (!Array.isArray(kernpunkteRoh)) {
175 ungueltig(`${pfad}.kernpunkte ist keine Liste.`);
176 }
177 kernpunkte = (kernpunkteRoh as readonly unknown[]).map((eintrag, i) =>
178 pflichttext(eintrag, `${pfad}.kernpunkte[${String(i)}]`),
179 );
180 if (kernpunkte.length < 2) {
181 /* Eine Pruefliste mit einem Punkt waere kein Werkzeug, sondern Beiwerk –
182 und eine leere waere eine Ueberschrift ohne Inhalt. */
183 ungueltig(`${pfad}.kernpunkte braucht mindestens zwei Punkte.`);
184 }
185 }
186
187 return {
188 kurz: pflichttext(o['kurz'], `${pfad}.kurz`),
189 text: pflichttext(o['text'], `${pfad}.text`),
190 fundstellen,
191 ...(grund === undefined ? {} : { ohneFundstelleGrund: grund }),
192 ...(merksatz === undefined ? {} : { merksatz }),
193 ...(verwandt === undefined ? {} : { verwandt }),
194 ...(kernpunkte === undefined ? {} : { kernpunkte }),
195 };
196 }
197
198 function erklaerungenValidieren(roh: unknown): Erklaerungen {
199 const o = objekt(roh, 'Die Datei');
200 const meta = objekt(o['meta'], 'meta');
201 const zuFrageRoh = objekt(o['zuFrage'], 'zuFrage');
202
203 const version = meta['version'];
204 if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
205 ungueltig('meta.version fehlt oder ist keine ganze Zahl ab 1.');
206 }
207
208 const gesetzesstandRoh = objekt(meta['gesetzesstand'], 'meta.gesetzesstand');
209 const gesetzesstand: Record<string, string> = {};
210 for (const [kuerzel, stand] of Object.entries(gesetzesstandRoh)) {
211 gesetzesstand[kuerzel] = pflichttext(stand, `meta.gesetzesstand.${kuerzel}`);
212 }
213
214 /* Erklärungen zu Fragen, die es nicht gibt, werden verworfen statt
215 abgewiesen: Ein solcher Rest ist harmlos, und ein Programm, das deswegen
216 gar keine Erklärungen zeigt, wäre die schlechtere Antwort darauf. */
217 const bekannt = new Set(katalogLaden().fragen.map((frage) => frage.id));
218 const zuFrage: Record<string, Erklaerung> = {};
219 let verworfen = 0;
220
221 for (const [frageId, eintrag] of Object.entries(zuFrageRoh)) {
222 if (!bekannt.has(frageId)) {
223 verworfen += 1;
224 continue;
225 }
226 zuFrage[frageId] = erklaerungLesen(eintrag, `zuFrage.${frageId}`);
227 }
228
229 if (verworfen > 0) {
230 console.warn(
231 `Erklärungen: ${String(verworfen)} Eintrag/Einträge ohne passende Frage übergangen.`,
232 );
233 }
234
235 return {
236 meta: {
237 version,
238 stand: pflichttext(meta['stand'], 'meta.stand'),
239 gesetzesstand,
240 hinweis: pflichttext(meta['hinweis'], 'meta.hinweis'),
241 },
242 zuFrage,
243 };
244 }
245
246 /** Pfad der Erklärungsdatei – gepackt wie ungepackt. */
247 export function erklaerungenPfad(): string {
248 if (app.isPackaged) {
249 return join(process.resourcesPath, DATEI);
250 }
251
252 const relativ = join('content', DATEI);
253 for (const start of [app.getAppPath(), __dirname, process.cwd()]) {
254 const treffer = sucheAufwaerts(start, relativ);
255 if (treffer) {
256 return treffer;
257 }
258 }
259 return join(app.getAppPath(), '..', relativ);
260 }
261
262 /** Leerer Bestand – die Anwendung läuft auch ohne Erklärungen. */
263 const LEER: Erklaerungen = Object.freeze({
264 meta: Object.freeze({
265 version: 0,
266 stand: '',
267 gesetzesstand: Object.freeze({}),
268 hinweis: '',
269 }),
270 zuFrage: Object.freeze({}),
271 });
272
273 let speicher: Erklaerungen | null = null;
274
275 export function erklaerungenLaden(): Erklaerungen {
276 if (speicher !== null) {
277 return speicher;
278 }
279
280 const pfad = erklaerungenPfad();
281 if (!existsSync(pfad)) {
282 speicher = LEER;
283 return speicher;
284 }
285
286 try {
287 speicher = erklaerungenValidieren(JSON.parse(readFileSync(pfad, 'utf8')));
288 } catch (fehler: unknown) {
289 /* Bewusst kein erneuter Versuch bei jedem Aufruf: Eine kaputte Datei
290 wird zwischen zwei Aufrufen nicht heil, und die Meldung soll einmal
291 erscheinen, nicht bei jeder Frage. */
292 console.error('Erklärungen konnten nicht gelesen werden:', fehler);
293 speicher = LEER;
294 }
295
296 return speicher;
297 }