waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Glossar der Fachbegriffe des Waffenrechts. |
| 3 | * |
| 4 | * Das Waffenrecht arbeitet mit Wörtern, die im Alltag etwas anderes bedeuten. |
| 5 | * „Führen" heißt nicht lenken, sondern die tatsächliche Gewalt außerhalb der |
| 6 | * eigenen Wohnung ausüben. „Erwerben" hat mit Kaufen nichts zu tun. Wer das |
| 7 | * nicht weiß, versteht die Frage nicht – unabhängig davon, wie gut er den |
| 8 | * Stoff kann. |
| 9 | * |
| 10 | * Genau darauf zielt WCAG 3.1.3 (Ungewöhnliche Wörter, Stufe AAA): Es muss |
| 11 | * einen Weg geben, die Bedeutung nachzuschlagen. 3.1.4 verlangt dasselbe für |
| 12 | * Abkürzungen; beide sind im Prüfplan dieses Projekts als Zusage aufgeführt. |
| 13 | * |
| 14 | * ## Herkunft der Definitionen |
| 15 | * |
| 16 | * Wo das Gesetz einen Begriff selbst bestimmt, wird diese Bestimmung |
| 17 | * wiedergegeben – nicht eine eigene Umschreibung davon. Die |
| 18 | * waffenrechtlichen Begriffe stehen in Anlage 1 Abschnitt 2 WaffG, die |
| 19 | * technischen in Abschnitt 1. Gesetzestexte sind nach § 5 Abs. 1 UrhG |
| 20 | * gemeinfrei. |
| 21 | * |
| 22 | * Jede Fundstelle wird wie bei den Erklärungen maschinell gegen den amtlichen |
| 23 | * Gesetzestext geprüft (`data-pipeline/pruefe_glossar.py`). |
| 24 | */ |
| 25 | |
| 26 | import type { Fundstelle } from './erklaerungen'; |
| 27 | |
| 28 | /** |
| 29 | * Art des Eintrags. |
| 30 | * |
| 31 | * Abkürzungen werden getrennt geführt: Sie brauchen keine Erläuterung, |
| 32 | * sondern die aufgelöste Form – und WCAG 3.1.4 verlangt genau das. |
| 33 | */ |
| 34 | export type Begriffsart = 'begriff' | 'abkuerzung'; |
| 35 | |
| 36 | export interface Glossareintrag { |
| 37 | /** Anzeigeform, etwa „Führen" oder „WaffG". */ |
| 38 | readonly begriff: string; |
| 39 | readonly art: Begriffsart; |
| 40 | /** |
| 41 | * Die Bedeutung in einem Satz. Bei Abkürzungen die aufgelöste Form. |
| 42 | * |
| 43 | * Steht für sich allein: Wer nur diesen Satz liest, muss die Frage |
| 44 | * verstehen können. |
| 45 | */ |
| 46 | readonly kurz: string; |
| 47 | /** Ausführlichere Erläuterung, wo ein Satz nicht reicht. */ |
| 48 | readonly text?: string; |
| 49 | /** |
| 50 | * Wortformen, unter denen der Begriff im Katalog vorkommt. |
| 51 | * |
| 52 | * Nötig, weil Deutsch flektiert: „Führen" erscheint als „führt", |
| 53 | * „geführt", „des Führens". Ohne die Formen bliebe der Begriff in genau |
| 54 | * den Fragen unerkannt, in denen er gebraucht wird. |
| 55 | */ |
| 56 | readonly varianten: readonly string[]; |
| 57 | readonly fundstellen: readonly Fundstelle[]; |
| 58 | /** Warum es zu diesem Begriff keine gesetzliche Bestimmung gibt. */ |
| 59 | readonly ohneFundstelleGrund?: string; |
| 60 | /** Verwandte Begriffe, die beim Verständnis helfen. */ |
| 61 | readonly siehe?: readonly string[]; |
| 62 | } |
| 63 | |
| 64 | export interface GlossarMeta { |
| 65 | readonly version: number; |
| 66 | readonly stand: string; |
| 67 | readonly gesetzesstand: Readonly<Record<string, string>>; |
| 68 | readonly hinweis: string; |
| 69 | } |
| 70 | |
| 71 | export interface Glossar { |
| 72 | readonly meta: GlossarMeta; |
| 73 | readonly eintraege: readonly Glossareintrag[]; |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * Zeichen, die in einem deutschen Wort vorkommen. |
| 78 | * |
| 79 | * `\b` aus der regulären Ausdruckssprache taugt hier nicht: Es kennt nur |
| 80 | * ASCII-Wortzeichen. In „Schießstätte" gälte damit jedes ß und ä als |
| 81 | * Wortgrenze, und „Stätte" wäre plötzlich ein eigenes Wort. |
| 82 | */ |
| 83 | const WORTZEICHEN = 'A-Za-zÄÖÜäöüßẞ0-9'; |
| 84 | |
| 85 | /** Escaped, was in einem regulären Ausdruck Bedeutung hätte. */ |
| 86 | function maskiert(wert: string): string { |
| 87 | return wert.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); |
| 88 | } |
| 89 | |
| 90 | function ausdruckFuer(eintrag: Glossareintrag): RegExp { |
| 91 | // Längere Formen zuerst, damit „Kurzwaffe" nicht von „Waffe" verdeckt wird, |
| 92 | // falls beide als Variante desselben Eintrags stehen. |
| 93 | const alternativen = [...eintrag.varianten] |
| 94 | .sort((a, b) => b.length - a.length) |
| 95 | .map(maskiert) |
| 96 | .join('|'); |
| 97 | |
| 98 | return new RegExp(`(?<![${WORTZEICHEN}])(?:${alternativen})(?![${WORTZEICHEN}])`, 'giu'); |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * Löst auf, wenn zwei Einträge dieselbe Fundstelle beanspruchen. |
| 103 | * |
| 104 | * ## Der Fall, um den es geht |
| 105 | * |
| 106 | * Deutsch schreibt Substantive groß, und daran hängt hier eine Bedeutung: |
| 107 | * „Geschossen" ist der Dativ Plural von „Geschoss", „geschossen" das Partizip |
| 108 | * von „schießen". Beide Wortformen stehen als Variante in **verschiedenen** |
| 109 | * Einträgen, und die Suche arbeitet ohne Rücksicht auf Groß- und |
| 110 | * Kleinschreibung — sie muss das, weil ein klein notiertes Verb am Satzanfang |
| 111 | * groß steht und ein nominalisiertes Verb („das Führen") ohnehin. |
| 112 | * |
| 113 | * Bis Fassung 0.24.1 bekam der Lernende deshalb beide Einträge angeboten. Am |
| 114 | * Katalog nachgezählt: zwölf Fragen mit dem Verb („Darf unter |
| 115 | * Alkoholeinfluss geschossen werden?") zeigten den Substantiveintrag |
| 116 | * „Geschoss", sieben mit dem Substantiv („Gefährdungsbereich von Geschossen") |
| 117 | * den Verbeintrag „Schießen". |
| 118 | * |
| 119 | * ## Die Regel |
| 120 | * |
| 121 | * Beanspruchen mehrere Einträge dieselbe Schreibweise im Text, gewinnt der, |
| 122 | * der sie **zeichengenau** als Variante führt. Führt keiner sie genau, bleibt |
| 123 | * es bei allen — dann ist die Groß-/Kleinschreibung kein Unterscheidungs- |
| 124 | * merkmal, und weniger zu zeigen wäre geraten. |
| 125 | * |
| 126 | * Ein Eintrag bleibt, sobald er **eine** seiner Fundstellen behält. „Das |
| 127 | * Führen ist erlaubnispflichtig" trifft weiterhin, weil dort niemand sonst |
| 128 | * Anspruch erhebt. |
| 129 | */ |
| 130 | function eindeutigMachen( |
| 131 | treffer: readonly { readonly eintrag: Glossareintrag; readonly formen: readonly string[] }[], |
| 132 | ): Glossareintrag[] { |
| 133 | const genau = new Map<string, Set<string>>(); |
| 134 | for (const { eintrag, formen } of treffer) { |
| 135 | for (const form of formen) { |
| 136 | if (eintrag.varianten.includes(form)) { |
| 137 | const bisher = genau.get(form) ?? new Set<string>(); |
| 138 | bisher.add(eintrag.begriff); |
| 139 | genau.set(form, bisher); |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | return treffer |
| 145 | .filter(({ eintrag, formen }) => |
| 146 | formen.some((form) => { |
| 147 | const beansprucht = genau.get(form); |
| 148 | return beansprucht === undefined || beansprucht.has(eintrag.begriff); |
| 149 | }), |
| 150 | ) |
| 151 | .map(({ eintrag }) => eintrag); |
| 152 | } |
| 153 | |
| 154 | /** |
| 155 | * Vorbereitetes Glossar – die Ausdrücke werden einmal gebaut, nicht je Frage. |
| 156 | */ |
| 157 | export interface Begriffssuche { |
| 158 | readonly eintraege: readonly Glossareintrag[]; |
| 159 | /** Findet die Begriffe, die in einem Text tatsächlich vorkommen. */ |
| 160 | readonly imText: (text: string) => Glossareintrag[]; |
| 161 | } |
| 162 | |
| 163 | /** |
| 164 | * Bereitet die Suche vor. |
| 165 | * |
| 166 | * Einträge ohne Varianten werden übergangen: Ein Ausdruck aus einer leeren |
| 167 | * Alternativenliste würde auf jede Stelle passen und das ganze Glossar an |
| 168 | * jede Frage hängen. |
| 169 | */ |
| 170 | export function begriffssucheBauen(glossar: Glossar): Begriffssuche { |
| 171 | const vorbereitet = glossar.eintraege |
| 172 | .filter((eintrag) => eintrag.varianten.length > 0) |
| 173 | .map((eintrag) => ({ eintrag, ausdruck: ausdruckFuer(eintrag) })); |
| 174 | |
| 175 | return { |
| 176 | eintraege: glossar.eintraege, |
| 177 | imText: (text: string): Glossareintrag[] => { |
| 178 | const treffer = vorbereitet |
| 179 | .map(({ eintrag, ausdruck }) => { |
| 180 | /* `lastIndex` zurücksetzen: Der Ausdruck ist global und wird über |
| 181 | alle Aufrufe hinweg wiederverwendet. */ |
| 182 | ausdruck.lastIndex = 0; |
| 183 | return { eintrag, formen: [...text.matchAll(ausdruck)].map((m) => m[0]) }; |
| 184 | }) |
| 185 | .filter(({ formen }) => formen.length > 0); |
| 186 | |
| 187 | return eindeutigMachen(treffer).sort((a, b) => a.begriff.localeCompare(b.begriff, 'de')); |
| 188 | }, |
| 189 | }; |
| 190 | } |
| 191 | |
| 192 | /** Leeres Glossar – die Anwendung läuft auch ohne. */ |
| 193 | export const GLOSSAR_LEER: Glossar = Object.freeze({ |
| 194 | meta: Object.freeze({ |
| 195 | version: 0, |
| 196 | stand: '', |
| 197 | gesetzesstand: Object.freeze({}), |
| 198 | hinweis: '', |
| 199 | }), |
| 200 | eintraege: Object.freeze([]), |
| 201 | }); |