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 | 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 | } |