waffensachkunde

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

/ app src main normtexte.ts

6,4 KB Rohdatei
app/src/main/normtexte.ts — 225 Zeilen
1 /**
2 * Laden und Prüfen der mitgelieferten Normtexte.
3 *
4 * Wie Erklärungen und Glossar eine eigene Datei:
5 *
6 * - Entwicklung: `<repo>/content/normtexte.json`
7 * - Gepackt: `<app>/resources/normtexte.json`
8 *
9 * Fehlt sie oder ist sie unbrauchbar, läuft die Anwendung ohne. Die
10 * Fundstellen stehen dann wie bisher als Zitat da – der Lernbetrieb hängt
11 * nicht daran.
12 *
13 * Erzeugt wird die Datei von `data-pipeline/normtexte_bauen.py` aus
14 * `content/gesetze/index.json`. Die Prüfung hier betrifft nur die Form; dass
15 * der Wortlaut der amtliche ist, sichert die Pipeline, die ihn unverändert
16 * übernimmt und die verlustfreie Zerlegung der Anlagen nachrechnet.
17 */
18
19 import { existsSync, readFileSync } from 'node:fs';
20 import { join } from 'node:path';
21
22 import { app } from 'electron';
23
24 import {
25 NORMTEXTE_LEER,
26 type Anlagenblock,
27 type Gesetzestext,
28 type Normtext,
29 type Normtexte,
30 } from '../shared/normtexte';
31 import { sucheAufwaerts } from './katalog';
32
33 const DATEI = 'normtexte.json';
34
35 function ungueltig(nachricht: string): never {
36 throw new Error(`Normtexte ungültig: ${nachricht}`);
37 }
38
39 function objekt(wert: unknown, pfad: string): Record<string, unknown> {
40 if (typeof wert !== 'object' || wert === null || Array.isArray(wert)) {
41 ungueltig(`${pfad} ist kein Objekt.`);
42 }
43 return wert as Record<string, unknown>;
44 }
45
46 function pflichttext(wert: unknown, pfad: string): string {
47 if (typeof wert !== 'string' || wert.length === 0) {
48 ungueltig(`${pfad} fehlt oder ist leer.`);
49 }
50 return wert;
51 }
52
53 function textabbildung(wert: unknown, pfad: string): Record<string, string> {
54 const roh = objekt(wert, pfad);
55 const abbildung: Record<string, string> = {};
56 for (const [schluessel, text] of Object.entries(roh)) {
57 /* Leere Absätze gibt es im amtlichen Text: „(weggefallen)" steht dort
58 teils als leere Zeichenkette. Sie zu verwerfen wäre eine Lücke, die
59 niemand erwartet – sie durchzulassen ist die Wahrheit. */
60 if (typeof text !== 'string') {
61 ungueltig(`${pfad}.${schluessel} ist kein Text.`);
62 }
63 abbildung[schluessel] = text;
64 }
65 return abbildung;
66 }
67
68 function blockLesen(roh: unknown, pfad: string): Anlagenblock {
69 const o = objekt(roh, pfad);
70
71 const marke = o['marke'];
72 if (marke !== null && typeof marke !== 'string') {
73 ungueltig(`${pfad}.marke muss Text oder null sein.`);
74 }
75
76 const wegRoh = o['pfad'];
77 if (!Array.isArray(wegRoh)) {
78 ungueltig(`${pfad}.pfad fehlt oder ist keine Liste.`);
79 }
80 const weg = (wegRoh as readonly unknown[]).map((teil, i) =>
81 pflichttext(teil, `${pfad}.pfad[${String(i)}]`),
82 );
83
84 const text = o['text'];
85 if (typeof text !== 'string') {
86 ungueltig(`${pfad}.text fehlt.`);
87 }
88
89 return { marke, pfad: weg, text };
90 }
91
92 function normLesen(roh: unknown, pfad: string): Normtext {
93 const o = objekt(roh, pfad);
94
95 const istAnlage = o['istAnlage'];
96 if (typeof istAnlage !== 'boolean') {
97 ungueltig(`${pfad}.istAnlage fehlt.`);
98 }
99
100 /* Der Titel darf leer sein: Manche Anlagen tragen im amtlichen XML gar
101 keinen – SprengG Anlage II etwa. Einen zu erfinden wäre schlechter. */
102 const titel = o['titel'];
103 if (typeof titel !== 'string') {
104 ungueltig(`${pfad}.titel fehlt.`);
105 }
106
107 if (istAnlage) {
108 const bloeckeRoh = o['bloecke'];
109 if (!Array.isArray(bloeckeRoh)) {
110 ungueltig(`${pfad}.bloecke fehlt oder ist keine Liste.`);
111 }
112 return {
113 titel,
114 istAnlage: true,
115 bloecke: (bloeckeRoh as readonly unknown[]).map((block, i) =>
116 blockLesen(block, `${pfad}.bloecke[${String(i)}]`),
117 ),
118 };
119 }
120
121 const text = o['text'];
122 if (text !== undefined && typeof text !== 'string') {
123 ungueltig(`${pfad}.text ist kein Text.`);
124 }
125
126 return {
127 titel,
128 istAnlage: false,
129 absaetze: textabbildung(o['absaetze'], `${pfad}.absaetze`),
130 ...(text === undefined ? {} : { text }),
131 };
132 }
133
134 function gesetzLesen(roh: unknown, pfad: string): Gesetzestext {
135 const o = objekt(roh, pfad);
136 const normenRoh = objekt(o['normen'], `${pfad}.normen`);
137
138 const normen: Record<string, Normtext> = {};
139 for (const [name, norm] of Object.entries(normenRoh)) {
140 normen[name] = normLesen(norm, `${pfad}.normen["${name}"]`);
141 }
142
143 return {
144 bezeichnung: pflichttext(o['bezeichnung'], `${pfad}.bezeichnung`),
145 stand: pflichttext(o['stand'], `${pfad}.stand`),
146 quelle: pflichttext(o['quelle'], `${pfad}.quelle`),
147 normen,
148 };
149 }
150
151 function normtexteValidieren(roh: unknown): Normtexte {
152 const o = objekt(roh, 'Die Datei');
153 const meta = objekt(o['meta'], 'meta');
154
155 const version = meta['version'];
156 if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
157 ungueltig('meta.version fehlt oder ist keine ganze Zahl ab 1.');
158 }
159
160 const standRoh = objekt(meta['gesetzesstand'], 'meta.gesetzesstand');
161 const gesetzesstand: Record<string, string> = {};
162 for (const [kuerzel, stand] of Object.entries(standRoh)) {
163 gesetzesstand[kuerzel] = pflichttext(stand, `meta.gesetzesstand.${kuerzel}`);
164 }
165
166 const gesetzeRoh = objekt(o['gesetze'], 'gesetze');
167 const gesetze: Record<string, Gesetzestext> = {};
168 for (const [kuerzel, gesetz] of Object.entries(gesetzeRoh)) {
169 gesetze[kuerzel] = gesetzLesen(gesetz, `gesetze.${kuerzel}`);
170 }
171
172 return {
173 meta: {
174 version,
175 stand: pflichttext(meta['stand'], 'meta.stand'),
176 hinweis: pflichttext(meta['hinweis'], 'meta.hinweis'),
177 gesetzesstand,
178 },
179 gesetze,
180 };
181 }
182
183 /** Pfad der Normtextdatei – gepackt wie ungepackt. */
184 export function normtextePfad(): string {
185 if (app.isPackaged) {
186 return join(process.resourcesPath, DATEI);
187 }
188
189 const relativ = join('content', DATEI);
190 for (const start of [app.getAppPath(), __dirname, process.cwd()]) {
191 const treffer = sucheAufwaerts(start, relativ);
192 if (treffer) {
193 return treffer;
194 }
195 }
196 return join(app.getAppPath(), '..', relativ);
197 }
198
199 let speicher: Normtexte | null = null;
200
201 export function normtexteLaden(): Normtexte {
202 if (speicher !== null) {
203 return speicher;
204 }
205
206 const pfad = normtextePfad();
207 if (!existsSync(pfad)) {
208 speicher = NORMTEXTE_LEER;
209 return speicher;
210 }
211
212 try {
213 speicher = normtexteValidieren(JSON.parse(readFileSync(pfad, 'utf8')));
214 } catch (fehler: unknown) {
215 console.error('Normtexte konnten nicht gelesen werden:', fehler);
216 speicher = NORMTEXTE_LEER;
217 }
218
219 return speicher;
220 }
221
222 /** Gegenstück für Tests. */
223 export function normtexteZuruecksetzen(): void {
224 speicher = null;
225 }