waffensachkunde

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

/ app src shared normtexte.ts

9,4 KB Rohdatei
app/src/shared/normtexte.ts — 251 Zeilen
1 /**
2 * Die mitgelieferten Normtexte – Vertrag und Auflösung einer Fundstelle.
3 *
4 * ## Wozu
5 *
6 * Jede Erklärungstafel schließt mit „Im Gesetz nachlesen" und einer Liste von
7 * Fundstellen. Bis 0.22.0 waren das reine Textzitate ohne Sprungziel, und die
8 * Anwendung lieferte keinen einzigen Normtext mit – der Lernende einer
9 * ausdrücklich vollständig offline arbeitenden Software konnte die
10 * Aufforderung also gerade nicht offline erfüllen.
11 *
12 * `content/normtexte.json` schließt die Lücke. Die Datei entsteht mit
13 * `data-pipeline/normtexte_bauen.py` aus `content/gesetze/index.json` und
14 * enthält **nur die zitierten Normen** – der Index selbst führt alle sieben
15 * Gesetze vollständig, auch katalogfremde Normen wie § 173 StGB.
16 *
17 * ## Wortlaut
18 *
19 * Der Text ist der amtliche, unverändert. Gesetzestexte sind nach § 5 Abs. 1
20 * UrhG gemeinfrei; verfälschen darf man sie deshalb trotzdem nicht.
21 *
22 * ## Warum Anlagen anders sind
23 *
24 * Anlagen kennen keine Absatzgliederung und sind seitenlang – Anlage 1 des
25 * WaffG hat 29 000 Zeichen und wird 381-mal zitiert. Sie werden deshalb in
26 * ihre eigenen Gliederungsblöcke zerlegt, und zwar **verlustfrei**: Die Blöcke
27 * ergeben aneinandergehängt wieder Zeichen für Zeichen den Ausgangstext (die
28 * Pipeline bricht ab, wenn das nicht stimmt). Ein Block ist ein Sprungziel,
29 * kein Ausschnitt. Beschnitten wird nichts – ein falsch gesetzter Schnitt wäre
30 * ein verfälschtes Gesetzeszitat, der schlimmste Fehler, den diese Anwendung
31 * machen könnte.
32 *
33 * Der `pfad` ist dabei nicht schmückend: In Anlage 1 des WaffG kommt die Marke
34 * „1.1" **vier**mal vor, in verschiedenen Unterabschnitten. Ohne ihn spränge
35 * eine Fundstelle unbemerkt an die falsche Stelle des Gesetzes.
36 */
37
38 import type { Fundstelle, Gesetzeskuerzel } from './erklaerungen';
39
40 /** Ein Gliederungsblock einer Anlage. */
41 export interface Anlagenblock {
42 /** „1.3.1.3", „Abschnitt 2" – oder `null` für den Vorspann. */
43 readonly marke: string | null;
44 /** Die Abschnitte, in denen der Block steht, von außen nach innen. */
45 readonly pfad: readonly string[];
46 readonly text: string;
47 }
48
49 export interface Normtext {
50 readonly titel: string;
51 readonly istAnlage: boolean;
52 /** Nur bei Paragrafen: Absatznummer -> Text. Leer bei Normen ohne Gliederung. */
53 readonly absaetze?: Readonly<Record<string, string>>;
54 /** Nur bei Paragrafen ohne Absatzgliederung: der ganze Text. */
55 readonly text?: string;
56 /** Nur bei Anlagen. */
57 readonly bloecke?: readonly Anlagenblock[];
58 }
59
60 export interface Gesetzestext {
61 readonly bezeichnung: string;
62 /** Änderungsstand, wörtlich aus dem amtlichen XML. */
63 readonly stand: string;
64 readonly quelle: string;
65 readonly normen: Readonly<Record<string, Normtext>>;
66 }
67
68 export interface NormtexteMeta {
69 readonly version: number;
70 readonly stand: string;
71 readonly hinweis: string;
72 readonly gesetzesstand: Readonly<Record<string, string>>;
73 }
74
75 export interface Normtexte {
76 readonly meta: NormtexteMeta;
77 readonly gesetze: Readonly<Record<string, Gesetzestext>>;
78 }
79
80 export const NORMTEXTE_LEER: Normtexte = Object.freeze({
81 meta: Object.freeze({
82 version: 0,
83 stand: '',
84 hinweis: '',
85 gesetzesstand: Object.freeze({}),
86 }),
87 gesetze: Object.freeze({}),
88 });
89
90 /** Die Norm zu einer Fundstelle – oder `null`, wenn sie nicht mitgeliefert wird. */
91 export function normFinden(normtexte: Normtexte, fundstelle: Fundstelle): Normtext | null {
92 return normtexte.gesetze[fundstelle.gesetz]?.normen[fundstelle.norm] ?? null;
93 }
94
95 /** Das Gesetz zu einer Fundstelle, für Bezeichnung, Stand und Quelle. */
96 export function gesetzFinden(normtexte: Normtexte, gesetz: Gesetzeskuerzel): Gesetzestext | null {
97 return normtexte.gesetze[gesetz] ?? null;
98 }
99
100 const ABSCHNITT = /(?<!Unter)[Aa]bschnitt\s+([0-9IVX]+)/u;
101 const UNTERABSCHNITT = /Unterabschnitt\s+([0-9IVX]+)/u;
102 const NUMMER = /(?:Nr\.|Nummer)\s*(\d+(?:\.\d+)*)/gu;
103
104 /**
105 * Sucht den Block, den eine Anlagen-Fundstelle meint.
106 *
107 * Die Regel ist dieselbe wie in `data-pipeline/normtexte_bauen.py`, nur
108 * andersherum gelesen: Aus der Stellenangabe werden Abschnitt, Unterabschnitt
109 * und die **tiefste** Nummer herausgelesen; gesucht wird der Block mit genau
110 * dieser Marke, dessen Pfad die genannten Abschnitte enthält.
111 *
112 * Gibt es **keinen oder mehr als einen** Treffer, ist das Ergebnis `null` –
113 * und die Anzeige zeigt die ganze Anlage und sagt, dass sie die Stelle nicht
114 * genau treffen konnte. Lieber zu viel zeigen als an die falsche Stelle
115 * springen: Von 632 Anlagenzitaten lösen sich 588 eindeutig auf, die
116 * verbleibenden 44 sind auf eine bis auf einen Fall die Abbildungen der
117 * Anlage II BeschussV (43 von 44), die im amtlichen XML nur als
118 * Bildunterschrift stehen. Nachgemessen am 30.08.2026 mit `auszugBilden`
119 * über alle 2137 Fundstellen aus Erklärungen und Glossar.
120 */
121 export function blockFinden(bloecke: readonly Anlagenblock[], stelle: string): Anlagenblock | null {
122 const abschnitt = ABSCHNITT.exec(stelle);
123 const unterabschnitt = UNTERABSCHNITT.exec(stelle);
124
125 const verlangt: string[] = [];
126 if (abschnitt !== null) {
127 verlangt.push(`Abschnitt ${abschnitt[1] ?? ''}`);
128 }
129 if (unterabschnitt !== null) {
130 verlangt.push(`Unterabschnitt ${unterabschnitt[1] ?? ''}`);
131 }
132
133 /* Die tiefste Nummer gewinnt: „Abschnitt 1 Unterabschnitt 1 Nr. 1.3.1.3“
134 meint den Block 1.3.1.3, nicht den Abschnitt. */
135 const nummern = [...stelle.matchAll(NUMMER)].map((treffer) => treffer[1] ?? '');
136 const letzte = nummern.at(-1);
137
138 const ziel = letzte ?? verlangt.at(-1);
139 if (ziel === undefined) {
140 return null;
141 }
142 // Ohne Nummer ist der letzte genannte Abschnitt selbst das Ziel; er steht
143 // über seinem Pfad, nicht darin.
144 const aussen = letzte === undefined ? verlangt.slice(0, -1) : verlangt;
145
146 const treffer = bloecke.filter(
147 (block) => block.marke === ziel && aussen.every((teil) => block.pfad.includes(teil)),
148 );
149
150 return treffer.length === 1 ? (treffer[0] ?? null) : null;
151 }
152
153 /**
154 * Zeigt die Stellenangabe auf eine Abbildung?
155 *
156 * Anlage II der BeschussV besteht aus den Beschuss- und Prüfzeichen. Das
157 * amtliche XML führt sie nur als Bildunterschrift, ohne Bild – deshalb sind
158 * fast alle nicht auflösbaren Anlagenzitate von dieser Art, und deshalb ist
159 * es ehrlicher, den Grund zu nennen, als den Leser suchen zu lassen. Der
160 * Befund steht in `docs/stand.md` 7.22.
161 */
162 export function zeigtAufAbbildung(stelle: string): boolean {
163 return /Abbildung/iu.test(stelle);
164 }
165
166 /** Was die Anzeige zu einer Fundstelle zeigen soll. */
167 export type Normauszug =
168 /** Der zitierte Absatz eines Paragrafen. */
169 | {
170 readonly art: 'absatz';
171 readonly titel: string;
172 readonly absatz: string;
173 readonly text: string;
174 }
175 /** Ein Paragraf, der als Ganzes zitiert wird oder keine Absätze hat. */
176 | { readonly art: 'norm'; readonly titel: string; readonly text: string }
177 /** Der getroffene Block einer Anlage. */
178 | { readonly art: 'block'; readonly titel: string; readonly block: Anlagenblock }
179 /** Die Anlage, deren Stelle sich nicht eindeutig auflösen ließ. */
180 | {
181 readonly art: 'anlage';
182 readonly titel: string;
183 readonly bloecke: readonly Anlagenblock[];
184 /** Die Stellenangabe, die nicht traf – die Anzeige nennt sie. */
185 readonly stelle: string;
186 /**
187 * Ob die Stelle auf eine Abbildung zeigt.
188 *
189 * Das ist der Regelfall der 44 nicht auflösbaren Zitate: Anlage II der
190 * BeschussV besteht aus den Beschuss- und Prüfzeichen, und das amtliche
191 * XML führt sie nur als Bildunterschrift ohne Bild. Die Anzeige sagt
192 * das, statt den Leser suchen zu lassen.
193 */
194 readonly abbildung: boolean;
195 }
196 /** Zu dieser Fundstelle liegt kein Text vor. */
197 | { readonly art: 'fehlt' };
198
199 /**
200 * Löst eine Fundstelle in den anzuzeigenden Auszug auf.
201 *
202 * Reine Funktion: Sie entscheidet, **was** dasteht, nicht wie es aussieht.
203 * Fehlt etwas, sagt sie das – erfunden wird nichts, und ein leerer Absatz
204 * wird nicht als vorhandener ausgegeben.
205 */
206 export function auszugBilden(normtexte: Normtexte, fundstelle: Fundstelle): Normauszug {
207 const norm = normFinden(normtexte, fundstelle);
208 if (norm === null) {
209 return { art: 'fehlt' };
210 }
211
212 if (norm.istAnlage) {
213 const bloecke = norm.bloecke ?? [];
214 if (bloecke.length === 0) {
215 return { art: 'fehlt' };
216 }
217 const stelle = fundstelle.stelle ?? '';
218 const block = stelle.length > 0 ? blockFinden(bloecke, stelle) : null;
219 return block === null
220 ? {
221 art: 'anlage',
222 titel: norm.titel,
223 bloecke,
224 stelle,
225 abbildung: zeigtAufAbbildung(stelle),
226 }
227 : { art: 'block', titel: norm.titel, block };
228 }
229
230 const absaetze = norm.absaetze ?? {};
231 const gesucht = fundstelle.absatz;
232 if (gesucht !== undefined) {
233 const text = absaetze[gesucht];
234 return text === undefined
235 ? { art: 'fehlt' }
236 : { art: 'absatz', titel: norm.titel, absatz: gesucht, text };
237 }
238
239 /* Ohne Absatzangabe: Normen ohne Gliederung tragen ihren Text direkt,
240 gegliederte werden der Reihe nach zusammengesetzt. Die Absatznummern
241 stehen im amtlichen Text ohnehin in Klammern voran. */
242 if (norm.text !== undefined && norm.text.length > 0) {
243 return { art: 'norm', titel: norm.titel, text: norm.text };
244 }
245 const zusammen = Object.entries(absaetze)
246 .map(([nummer, text]) => `(${nummer}) ${text}`)
247 .join('\n\n');
248 return zusammen.length === 0
249 ? { art: 'fehlt' }
250 : { art: 'norm', titel: norm.titel, text: zusammen };
251 }