waffensachkunde

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

/ app src main glossar.ts

7,5 KB Rohdatei
app/src/main/glossar.ts — 236 Zeilen
1 /**
2 * Laden und Prüfen des Glossars.
3 *
4 * Wie die Erklärungen eigener redaktioneller Inhalt, wie diese in einer
5 * eigenen Datei:
6 *
7 * - Entwicklung: `<repo>/content/glossar.json`
8 * - Gepackt: `<app>/resources/glossar.json`
9 *
10 * Fehlt es oder ist es unbrauchbar, läuft die Anwendung ohne. Der Lernbetrieb
11 * hängt nicht daran.
12 *
13 * Die Prüfung hier betrifft nur die Form. Ob eine Fundstelle im Gesetz
14 * existiert, klärt `data-pipeline/pruefe_glossar.py` vor der Auslieferung –
15 * dort steht der Gesetzestext 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 { GESETZE, type Fundstelle, type Gesetzeskuerzel } from '../shared/erklaerungen';
24 import {
25 GLOSSAR_LEER,
26 type Begriffsart,
27 type Glossar,
28 type Glossareintrag,
29 } from '../shared/glossar';
30 import { sucheAufwaerts } from './katalog';
31
32 const DATEI = 'glossar.json';
33
34 const NORM_MUSTER = /^(§ \d+[a-z]?|Anlage( [0-9IVX]+)?)$/u;
35 const GLIEDERUNG_MUSTER = /^[0-9]{1,3}[a-z]?$/u;
36 const BUCHSTABE_MUSTER = /^[a-z]{1,2}$/u;
37 const ARTEN: readonly Begriffsart[] = ['begriff', 'abkuerzung'];
38
39 function ungueltig(nachricht: string): never {
40 throw new Error(`Glossar ungültig: ${nachricht}`);
41 }
42
43 function objekt(wert: unknown, pfad: string): Record<string, unknown> {
44 if (typeof wert !== 'object' || wert === null || Array.isArray(wert)) {
45 ungueltig(`${pfad} ist kein Objekt.`);
46 }
47 return wert as Record<string, unknown>;
48 }
49
50 function pflichttext(wert: unknown, pfad: string): string {
51 if (typeof wert !== 'string' || wert.trim().length === 0) {
52 ungueltig(`${pfad} fehlt oder ist leer.`);
53 }
54 return wert;
55 }
56
57 function kuerteText(wert: unknown, pfad: string): string | undefined {
58 return wert === undefined ? undefined : pflichttext(wert, pfad);
59 }
60
61 function textliste(wert: unknown, pfad: string): string[] {
62 if (!Array.isArray(wert)) {
63 ungueltig(`${pfad} fehlt oder ist keine Liste.`);
64 }
65 return (wert as readonly unknown[]).map((eintrag, i) =>
66 pflichttext(eintrag, `${pfad}[${String(i)}]`),
67 );
68 }
69
70 function gliederung(wert: unknown, pfad: string, muster: RegExp): string | undefined {
71 if (wert === undefined) {
72 return undefined;
73 }
74 if (typeof wert !== 'string' || !muster.test(wert)) {
75 ungueltig(`${pfad} hat eine unerwartete Form.`);
76 }
77 return wert;
78 }
79
80 /** Baut ein Feld nur ein, wenn es einen Wert hat (`exactOptionalPropertyTypes`). */
81 function optional<K extends string>(
82 schluessel: K,
83 wert: string | undefined,
84 ): Record<K, string> | Record<string, never> {
85 return wert === undefined ? {} : ({ [schluessel]: wert } as Record<K, string>);
86 }
87
88 function fundstelleLesen(roh: unknown, pfad: string): Fundstelle {
89 const o = objekt(roh, pfad);
90
91 const gesetz = o['gesetz'];
92 if (typeof gesetz !== 'string' || !(GESETZE as readonly string[]).includes(gesetz)) {
93 ungueltig(`${pfad}.gesetz ist kein bekanntes Gesetz.`);
94 }
95
96 const norm = pflichttext(o['norm'], `${pfad}.norm`);
97 if (!NORM_MUSTER.test(norm)) {
98 ungueltig(`${pfad}.norm muss „§ 12“ oder „Anlage 1“ lauten.`);
99 }
100
101 return {
102 gesetz: gesetz as Gesetzeskuerzel,
103 norm,
104 ...optional('absatz', gliederung(o['absatz'], `${pfad}.absatz`, GLIEDERUNG_MUSTER)),
105 ...optional('nummer', gliederung(o['nummer'], `${pfad}.nummer`, GLIEDERUNG_MUSTER)),
106 ...optional('buchstabe', gliederung(o['buchstabe'], `${pfad}.buchstabe`, BUCHSTABE_MUSTER)),
107 ...optional('satz', gliederung(o['satz'], `${pfad}.satz`, GLIEDERUNG_MUSTER)),
108 ...optional('stelle', kuerteText(o['stelle'], `${pfad}.stelle`)),
109 };
110 }
111
112 function eintragLesen(roh: unknown, pfad: string): Glossareintrag {
113 const o = objekt(roh, pfad);
114
115 const art = o['art'];
116 if (typeof art !== 'string' || !(ARTEN as readonly string[]).includes(art)) {
117 ungueltig(`${pfad}.art muss „begriff“ oder „abkuerzung“ sein.`);
118 }
119
120 /* Leer ist zulässig: Der Eintrag erscheint dann nur in der Glossaransicht
121 und wird an keiner Frage angezeigt. Bei Abkürzungen, die nur in den
122 Erklärungen vorkommen, ist genau das richtig – eine erfundene Wortform
123 wäre schlechter. Die Begriffssuche übergeht solche Einträge. */
124 const varianten = textliste(o['varianten'], `${pfad}.varianten`);
125
126 const fundstellenRoh = o['fundstellen'];
127 if (!Array.isArray(fundstellenRoh)) {
128 ungueltig(`${pfad}.fundstellen fehlt oder ist keine Liste.`);
129 }
130 const fundstellen = (fundstellenRoh as readonly unknown[]).map((eintrag, i) =>
131 fundstelleLesen(eintrag, `${pfad}.fundstellen[${String(i)}]`),
132 );
133
134 const grund = kuerteText(o['ohneFundstelleGrund'], `${pfad}.ohneFundstelleGrund`);
135 if (fundstellen.length === 0 && grund === undefined) {
136 ungueltig(`${pfad}: ohne Fundstelle muss ein Grund angegeben sein.`);
137 }
138
139 const text = kuerteText(o['text'], `${pfad}.text`);
140 const siehe = o['siehe'] === undefined ? undefined : textliste(o['siehe'], `${pfad}.siehe`);
141
142 return {
143 begriff: pflichttext(o['begriff'], `${pfad}.begriff`),
144 art: art as Begriffsart,
145 kurz: pflichttext(o['kurz'], `${pfad}.kurz`),
146 varianten,
147 fundstellen,
148 ...(text === undefined ? {} : { text }),
149 ...(grund === undefined ? {} : { ohneFundstelleGrund: grund }),
150 ...(siehe === undefined ? {} : { siehe }),
151 };
152 }
153
154 function glossarValidieren(roh: unknown): Glossar {
155 const o = objekt(roh, 'Die Datei');
156 const meta = objekt(o['meta'], 'meta');
157
158 const version = meta['version'];
159 if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
160 ungueltig('meta.version fehlt oder ist keine ganze Zahl ab 1.');
161 }
162
163 const gesetzesstandRoh = objekt(meta['gesetzesstand'], 'meta.gesetzesstand');
164 const gesetzesstand: Record<string, string> = {};
165 for (const [kuerzel, stand] of Object.entries(gesetzesstandRoh)) {
166 gesetzesstand[kuerzel] = pflichttext(stand, `meta.gesetzesstand.${kuerzel}`);
167 }
168
169 const eintraegeRoh = o['eintraege'];
170 if (!Array.isArray(eintraegeRoh)) {
171 ungueltig('eintraege fehlt oder ist keine Liste.');
172 }
173
174 const eintraege = (eintraegeRoh as readonly unknown[]).map((eintrag, i) =>
175 eintragLesen(eintrag, `eintraege[${String(i)}]`),
176 );
177
178 /* Doppelte Begriffe wären in der Anzeige verwirrend und in „siehe“
179 mehrdeutig. */
180 const gesehen = new Set<string>();
181 for (const eintrag of eintraege) {
182 if (gesehen.has(eintrag.begriff)) {
183 ungueltig(`„${eintrag.begriff}“ kommt mehrfach vor.`);
184 }
185 gesehen.add(eintrag.begriff);
186 }
187
188 return {
189 meta: {
190 version,
191 stand: pflichttext(meta['stand'], 'meta.stand'),
192 gesetzesstand,
193 hinweis: pflichttext(meta['hinweis'], 'meta.hinweis'),
194 },
195 eintraege,
196 };
197 }
198
199 /** Pfad der Glossardatei – gepackt wie ungepackt. */
200 export function glossarPfad(): string {
201 if (app.isPackaged) {
202 return join(process.resourcesPath, DATEI);
203 }
204
205 const relativ = join('content', DATEI);
206 for (const start of [app.getAppPath(), __dirname, process.cwd()]) {
207 const treffer = sucheAufwaerts(start, relativ);
208 if (treffer) {
209 return treffer;
210 }
211 }
212 return join(app.getAppPath(), '..', relativ);
213 }
214
215 let speicher: Glossar | null = null;
216
217 export function glossarLaden(): Glossar {
218 if (speicher !== null) {
219 return speicher;
220 }
221
222 const pfad = glossarPfad();
223 if (!existsSync(pfad)) {
224 speicher = GLOSSAR_LEER;
225 return speicher;
226 }
227
228 try {
229 speicher = glossarValidieren(JSON.parse(readFileSync(pfad, 'utf8')));
230 } catch (fehler: unknown) {
231 console.error('Glossar konnte nicht gelesen werden:', fehler);
232 speicher = GLOSSAR_LEER;
233 }
234
235 return speicher;
236 }