waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 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 | /* |
| 103 | Der Buchstabenzusatz gehört zur Nummer. |
| 104 | |
| 105 | Bis 0.27.2 las das Muster nur Ziffern und Punkte. Aus „Nr. 2a“ wurde die |
| 106 | Zahl 2, und gesucht wurde der Block mit der Marke „2“ – die Überschrift eine |
| 107 | Ebene darüber. Acht Zitate in Erklärungen und Glossar nennen „Nr. 2a“ oder |
| 108 | „Nr. 2b“, und kein einziger Block im Bestand trägt eine Marke mit |
| 109 | Buchstaben: Richtig ist deshalb, die Stelle als nicht auflösbar zu behandeln |
| 110 | und die ganze Anlage mit dem Hinweis zu zeigen – nicht, an die nächstbeste |
| 111 | Stelle zu springen. |
| 112 | */ |
| 113 | const NUMMER = /(?:Nr\.|Nummer)\s*(\d+[a-z]?(?:\.\d+[a-z]?)*)/gu; |
| 114 | |
| 115 | /** |
| 116 | * Sucht den Block, den eine Anlagen-Fundstelle meint. |
| 117 | * |
| 118 | * Die Regel ist dieselbe wie in `data-pipeline/normtexte_bauen.py`, nur |
| 119 | * andersherum gelesen: Aus der Stellenangabe werden Abschnitt, Unterabschnitt |
| 120 | * und die **tiefste** Nummer herausgelesen; gesucht wird der Block mit genau |
| 121 | * dieser Marke, dessen Pfad die genannten Abschnitte enthält. |
| 122 | * |
| 123 | * Gibt es **keinen oder mehr als einen** Treffer, ist das Ergebnis `null` – |
| 124 | * und die Anzeige zeigt die ganze Anlage und sagt, dass sie die Stelle nicht |
| 125 | * genau treffen konnte. Lieber zu viel zeigen als an die falsche Stelle |
| 126 | * springen: Von 632 Anlagenzitaten lösen sich 580 eindeutig auf. Von den |
| 127 | * verbleibenden 52 nennen 32 ausdrücklich eine **Abbildung** – Anlage II |
| 128 | * BeschussV besteht aus den Beschuss- und Prüfzeichen, die im amtlichen XML |
| 129 | * nur als Bildunterschrift stehen. Die übrigen 20 sind gewöhnliche Nummern, |
| 130 | * darunter die acht Zitate mit Buchstabenzusatz („Nr. 2a“, „Nr. 2b“), für die |
| 131 | * es im Bestand keinen eigenen Block gibt; sie zeigen die ganze Anlage mit |
| 132 | * dem Hinweis, dass die Stelle nicht genau zu treffen war. |
| 133 | * |
| 134 | * Nachgemessen am 03.09.2026 mit `auszugBilden` über alle 2138 Fundstellen |
| 135 | * aus Erklärungen und Glossar. Der Kommentar nannte bis 0.27.2 „588 von 632“ |
| 136 | * und „43 von 44 Abbildungen“ – die erste Zahl stammte aus der Zeit, als |
| 137 | * „Nr. 2a“ noch fälschlich auf den Block „2“ traf, die zweite war schon |
| 138 | * damals falsch: Zwölf der 44 waren gewöhnliche Nummern. |
| 139 | */ |
| 140 | export function blockFinden(bloecke: readonly Anlagenblock[], stelle: string): Anlagenblock | null { |
| 141 | const abschnitt = ABSCHNITT.exec(stelle); |
| 142 | const unterabschnitt = UNTERABSCHNITT.exec(stelle); |
| 143 | |
| 144 | const verlangt: string[] = []; |
| 145 | if (abschnitt !== null) { |
| 146 | verlangt.push(`Abschnitt ${abschnitt[1] ?? ''}`); |
| 147 | } |
| 148 | if (unterabschnitt !== null) { |
| 149 | verlangt.push(`Unterabschnitt ${unterabschnitt[1] ?? ''}`); |
| 150 | } |
| 151 | |
| 152 | /* Die tiefste Nummer gewinnt: „Abschnitt 1 Unterabschnitt 1 Nr. 1.3.1.3“ |
| 153 | meint den Block 1.3.1.3, nicht den Abschnitt. */ |
| 154 | const nummern = [...stelle.matchAll(NUMMER)].map((treffer) => treffer[1] ?? ''); |
| 155 | const letzte = nummern.at(-1); |
| 156 | |
| 157 | const ziel = letzte ?? verlangt.at(-1); |
| 158 | if (ziel === undefined) { |
| 159 | return null; |
| 160 | } |
| 161 | // Ohne Nummer ist der letzte genannte Abschnitt selbst das Ziel; er steht |
| 162 | // über seinem Pfad, nicht darin. |
| 163 | const aussen = letzte === undefined ? verlangt.slice(0, -1) : verlangt; |
| 164 | |
| 165 | const treffer = bloecke.filter( |
| 166 | (block) => block.marke === ziel && aussen.every((teil) => block.pfad.includes(teil)), |
| 167 | ); |
| 168 | |
| 169 | return treffer.length === 1 ? (treffer[0] ?? null) : null; |
| 170 | } |
| 171 | |
| 172 | /** |
| 173 | * Zeigt die Stellenangabe auf eine Abbildung? |
| 174 | * |
| 175 | * Anlage II der BeschussV besteht aus den Beschuss- und Prüfzeichen. Das |
| 176 | * amtliche XML führt sie nur als Bildunterschrift, ohne Bild – deshalb sind |
| 177 | * fast alle nicht auflösbaren Anlagenzitate von dieser Art, und deshalb ist |
| 178 | * es ehrlicher, den Grund zu nennen, als den Leser suchen zu lassen. Der |
| 179 | * Befund steht in `docs/stand.md` 7.22. |
| 180 | */ |
| 181 | export function zeigtAufAbbildung(stelle: string): boolean { |
| 182 | return /Abbildung/iu.test(stelle); |
| 183 | } |
| 184 | |
| 185 | /** Was die Anzeige zu einer Fundstelle zeigen soll. */ |
| 186 | export type Normauszug = |
| 187 | /** Der zitierte Absatz eines Paragrafen. */ |
| 188 | | { |
| 189 | readonly art: 'absatz'; |
| 190 | readonly titel: string; |
| 191 | readonly absatz: string; |
| 192 | readonly text: string; |
| 193 | } |
| 194 | /** Ein Paragraf, der als Ganzes zitiert wird oder keine Absätze hat. */ |
| 195 | | { readonly art: 'norm'; readonly titel: string; readonly text: string } |
| 196 | /** Der getroffene Block einer Anlage. */ |
| 197 | | { readonly art: 'block'; readonly titel: string; readonly block: Anlagenblock } |
| 198 | /** Die Anlage, deren Stelle sich nicht eindeutig auflösen ließ. */ |
| 199 | | { |
| 200 | readonly art: 'anlage'; |
| 201 | readonly titel: string; |
| 202 | readonly bloecke: readonly Anlagenblock[]; |
| 203 | /** Die Stellenangabe, die nicht traf – die Anzeige nennt sie. */ |
| 204 | readonly stelle: string; |
| 205 | /** |
| 206 | * Ob die Stelle auf eine Abbildung zeigt. |
| 207 | * |
| 208 | * Der Regelfall unter den 52 nicht auflösbaren Zitaten: 32 von ihnen |
| 209 | * nennen eine Abbildung, meist die Beschuss- und Prüfzeichen der |
| 210 | * Anlage II BeschussV, die das amtliche XML nur als Bildunterschrift |
| 211 | * ohne Bild führt. Die Anzeige sagt das, statt den Leser suchen zu |
| 212 | * lassen. |
| 213 | */ |
| 214 | readonly abbildung: boolean; |
| 215 | } |
| 216 | /** Zu dieser Fundstelle liegt kein Text vor. */ |
| 217 | | { readonly art: 'fehlt' }; |
| 218 | |
| 219 | /** |
| 220 | * Löst eine Fundstelle in den anzuzeigenden Auszug auf. |
| 221 | * |
| 222 | * Reine Funktion: Sie entscheidet, **was** dasteht, nicht wie es aussieht. |
| 223 | * Fehlt etwas, sagt sie das – erfunden wird nichts, und ein leerer Absatz |
| 224 | * wird nicht als vorhandener ausgegeben. |
| 225 | */ |
| 226 | /** |
| 227 | * Vergleicht zwei Absatznummern in amtlicher Ordnung: 1 vor 1a vor 2. |
| 228 | * |
| 229 | * Nummern ohne dieses Muster – im Bestand gibt es keine – landen hinten und |
| 230 | * behalten untereinander ihre Reihenfolge. |
| 231 | */ |
| 232 | function absatzVergleich(a: string, b: string): number { |
| 233 | const teile = (wert: string): [number, string] => { |
| 234 | const treffer = /^(\d+)([a-z]*)$/u.exec(wert); |
| 235 | return treffer === null |
| 236 | ? [Number.MAX_SAFE_INTEGER, wert] |
| 237 | : [Number(treffer[1]), treffer[2] ?? '']; |
| 238 | }; |
| 239 | const [zahlA, restA] = teile(a); |
| 240 | const [zahlB, restB] = teile(b); |
| 241 | return zahlA === zahlB ? restA.localeCompare(restB) : zahlA - zahlB; |
| 242 | } |
| 243 | |
| 244 | export function auszugBilden(normtexte: Normtexte, fundstelle: Fundstelle): Normauszug { |
| 245 | const norm = normFinden(normtexte, fundstelle); |
| 246 | if (norm === null) { |
| 247 | return { art: 'fehlt' }; |
| 248 | } |
| 249 | |
| 250 | if (norm.istAnlage) { |
| 251 | const bloecke = norm.bloecke ?? []; |
| 252 | if (bloecke.length === 0) { |
| 253 | return { art: 'fehlt' }; |
| 254 | } |
| 255 | const stelle = fundstelle.stelle ?? ''; |
| 256 | const block = stelle.length > 0 ? blockFinden(bloecke, stelle) : null; |
| 257 | return block === null |
| 258 | ? { |
| 259 | art: 'anlage', |
| 260 | titel: norm.titel, |
| 261 | bloecke, |
| 262 | stelle, |
| 263 | abbildung: zeigtAufAbbildung(stelle), |
| 264 | } |
| 265 | : { art: 'block', titel: norm.titel, block }; |
| 266 | } |
| 267 | |
| 268 | const absaetze = norm.absaetze ?? {}; |
| 269 | const gesucht = fundstelle.absatz; |
| 270 | if (gesucht !== undefined) { |
| 271 | const text = absaetze[gesucht]; |
| 272 | return text === undefined |
| 273 | ? { art: 'fehlt' } |
| 274 | : { art: 'absatz', titel: norm.titel, absatz: gesucht, text }; |
| 275 | } |
| 276 | |
| 277 | /* Ohne Absatzangabe: Normen ohne Gliederung tragen ihren Text direkt, |
| 278 | gegliederte werden der Reihe nach zusammengesetzt. Die Absatznummern |
| 279 | stehen im amtlichen Text ohnehin in Klammern voran. */ |
| 280 | if (norm.text !== undefined && norm.text.length > 0) { |
| 281 | return { art: 'norm', titel: norm.titel, text: norm.text }; |
| 282 | } |
| 283 | /* |
| 284 | Absatznummern in ihrer amtlichen Reihenfolge. |
| 285 | |
| 286 | `Object.entries` gibt zuerst alle ganzzahligen Schlüssel aufsteigend |
| 287 | zurück und danach erst die übrigen – ein Paragraf mit den Absätzen 1, |
| 288 | 2, 2a, 3 erschien deshalb als 1, 2, 3, 2a. Nachgemessen an |
| 289 | `content/normtexte.json`: acht Normen waren betroffen, darunter WaffG |
| 290 | § 42 und § 32. Die Gesetzesansicht sagt zu, der Text werde „nicht |
| 291 | gekürzt, nicht zusammengesetzt und nicht umsortiert“. |
| 292 | */ |
| 293 | const zusammen = Object.entries(absaetze) |
| 294 | .sort(([a], [b]) => absatzVergleich(a, b)) |
| 295 | .map(([nummer, text]) => `(${nummer}) ${text}`) |
| 296 | .join('\n\n'); |
| 297 | return zusammen.length === 0 |
| 298 | ? { art: 'fehlt' } |
| 299 | : { art: 'norm', titel: norm.titel, text: zusammen }; |
| 300 | } |