waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app src shared gesetzesuche.ts
| 1 | /** |
| 2 | * In den mitgelieferten Gesetzen blättern und suchen. |
| 3 | * |
| 4 | * ## Was diese Datei beantwortet |
| 5 | * |
| 6 | * „Was steht eigentlich in § 13 WaffG?“ Bis Fassung 0.26.7 war der amtliche |
| 7 | * Wortlaut nur über eine Erklärung erreichbar: Wer eine Frage bearbeitete, |
| 8 | * konnte die Fundstelle darunter aufklappen. Wer eine Vorschrift **suchte**, |
| 9 | * ohne die passende Frage zu kennen, kam nicht an sie heran – obwohl 148 |
| 10 | * Normen aus sieben Gesetzen im Paket liegen. |
| 11 | * |
| 12 | * ## Warum die Suche hier steht und nicht in der Ansicht |
| 13 | * |
| 14 | * Damit sie prüfbar ist. Was gesucht wird und in welcher Reihenfolge die |
| 15 | * Treffer stehen, ist eine Aussage über den Inhalt – kein Aussehen. Die |
| 16 | * Ansicht bekommt fertige Listen und entscheidet nur noch über die Darstellung. |
| 17 | * |
| 18 | * ## Was nicht gesucht wird |
| 19 | * |
| 20 | * Der Wortlaut wird nicht verändert, nicht gekürzt und nicht umsortiert |
| 21 | * (§ 5 Abs. 1 UrhG, und der Grundsatz dieses Projekts dazu). Gesucht wird |
| 22 | * über Bezeichnung, Überschrift und Text; ausgegeben wird der Fundort, nicht |
| 23 | * ein zurechtgeschnittener Ausschnitt. |
| 24 | */ |
| 25 | |
| 26 | import { GESETZ_BEZEICHNUNG, type Gesetzeskuerzel } from './erklaerungen'; |
| 27 | import type { Normtext, Normtexte } from './normtexte'; |
| 28 | |
| 29 | /** Ein Gesetz, wie die Ansicht es in der Auswahl braucht. */ |
| 30 | export interface Gesetzeseintrag { |
| 31 | readonly kuerzel: Gesetzeskuerzel; |
| 32 | readonly bezeichnung: string; |
| 33 | /** Änderungsstand, wörtlich aus dem amtlichen XML. */ |
| 34 | readonly stand: string; |
| 35 | readonly quelle: string; |
| 36 | readonly normenGesamt: number; |
| 37 | } |
| 38 | |
| 39 | /** Eine Norm in der Liste. */ |
| 40 | export interface Normeintrag { |
| 41 | readonly gesetz: Gesetzeskuerzel; |
| 42 | /** „§ 12“ oder „Anlage 1“. */ |
| 43 | readonly norm: string; |
| 44 | readonly titel: string; |
| 45 | readonly istAnlage: boolean; |
| 46 | /** |
| 47 | * Warum diese Norm in der Trefferliste steht. |
| 48 | * |
| 49 | * Die Ansicht sagt es dazu: Ein Treffer, der nur im Fließtext steckt, ist |
| 50 | * für den Suchenden etwas anderes als einer in der Überschrift – und wer |
| 51 | * nicht sieht, warum etwas gefunden wurde, sucht weiter. |
| 52 | */ |
| 53 | readonly treffer: 'bezeichnung' | 'ueberschrift' | 'text' | null; |
| 54 | } |
| 55 | |
| 56 | /** Die Gesetze in der Reihenfolge, in der sie angeboten werden. */ |
| 57 | export function gesetzeAuflisten(normtexte: Normtexte): Gesetzeseintrag[] { |
| 58 | return ( |
| 59 | Object.entries(normtexte.gesetze) |
| 60 | .map(([kuerzel, gesetz]) => ({ |
| 61 | kuerzel: kuerzel as Gesetzeskuerzel, |
| 62 | bezeichnung: gesetz.bezeichnung, |
| 63 | stand: gesetz.stand, |
| 64 | quelle: gesetz.quelle, |
| 65 | normenGesamt: Object.keys(gesetz.normen).length, |
| 66 | })) |
| 67 | /* Nach der ausgeschriebenen Bezeichnung, nicht nach dem Kürzel: In der |
| 68 | Auswahl steht die Bezeichnung, und eine Liste, die anders sortiert ist |
| 69 | als sie aussieht, ist keine Liste, sondern ein Rätsel. */ |
| 70 | .sort((a, b) => a.bezeichnung.localeCompare(b.bezeichnung, 'de')) |
| 71 | ); |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * Die Sortiernummer einer Norm. |
| 76 | * |
| 77 | * „§ 2“ muss vor „§ 10“ stehen – alphabetisch stünde es dahinter. Gelesen |
| 78 | * wird die erste Zahlengruppe; „§ 13a“ reiht sich hinter „§ 13“ ein, weil der |
| 79 | * Rest als Zeichenkette nachentscheidet. Anlagen kommen nach den Paragrafen, |
| 80 | * so wie sie auch im Gesetz stehen. |
| 81 | */ |
| 82 | function sortierschluessel(eintrag: Normeintrag): [number, number, string] { |
| 83 | const zahl = /\d+/u.exec(eintrag.norm); |
| 84 | return [eintrag.istAnlage ? 1 : 0, zahl === null ? 0 : Number(zahl[0]), eintrag.norm]; |
| 85 | } |
| 86 | |
| 87 | function nachOrdnung(a: Normeintrag, b: Normeintrag): number { |
| 88 | const [aA, aZ, aT] = sortierschluessel(a); |
| 89 | const [bA, bZ, bT] = sortierschluessel(b); |
| 90 | return aA - bA || aZ - bZ || aT.localeCompare(bT, 'de'); |
| 91 | } |
| 92 | |
| 93 | /** Der ganze Text einer Norm, für die Suche zusammengelegt. */ |
| 94 | function volltext(norm: Normtext): string { |
| 95 | const teile: string[] = [norm.titel]; |
| 96 | if (norm.text !== undefined) { |
| 97 | teile.push(norm.text); |
| 98 | } |
| 99 | for (const absatz of Object.values(norm.absaetze ?? {})) { |
| 100 | teile.push(absatz); |
| 101 | } |
| 102 | for (const block of norm.bloecke ?? []) { |
| 103 | teile.push(block.text); |
| 104 | } |
| 105 | return teile.join('\n'); |
| 106 | } |
| 107 | |
| 108 | /** |
| 109 | * Vereinheitlicht einen Suchbegriff. |
| 110 | * |
| 111 | * Kleinschreibung und das Paragrafenzeichen weg: Wer „§13“ eingibt, meint |
| 112 | * „§ 13“, und wer „13“ eingibt, meint es auch. Ein Suchfeld, das an einem |
| 113 | * Leerzeichen scheitert, wird nicht wieder benutzt. |
| 114 | */ |
| 115 | function falten(text: string): string { |
| 116 | return text.toLocaleLowerCase('de').replace(/§/gu, ' ').replace(/\s+/gu, ' ').trim(); |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * Die Normen eines Gesetzes – gefiltert, wenn gesucht wird. |
| 121 | * |
| 122 | * Ohne Suchbegriff steht die vollständige Liste in der Ordnung des Gesetzes. |
| 123 | * Mit Suchbegriff stehen die Treffer in derselben Ordnung: Wer in einem |
| 124 | * Gesetz sucht, will wissen, **wo** etwas steht – eine nach Trefferqualität |
| 125 | * umsortierte Liste zerrisse den Zusammenhang, den die Nummerierung stiftet. |
| 126 | */ |
| 127 | export function normenAuflisten( |
| 128 | normtexte: Normtexte, |
| 129 | gesetz: Gesetzeskuerzel, |
| 130 | suche = '', |
| 131 | ): Normeintrag[] { |
| 132 | const eintrag = normtexte.gesetze[gesetz]; |
| 133 | if (eintrag === undefined) { |
| 134 | return []; |
| 135 | } |
| 136 | const gesucht = falten(suche); |
| 137 | |
| 138 | const alle = Object.entries(eintrag.normen).map(([norm, text]) => { |
| 139 | const treffer: Normeintrag['treffer'] = |
| 140 | gesucht.length === 0 |
| 141 | ? null |
| 142 | : falten(norm).includes(gesucht) |
| 143 | ? 'bezeichnung' |
| 144 | : falten(text.titel).includes(gesucht) |
| 145 | ? 'ueberschrift' |
| 146 | : falten(volltext(text)).includes(gesucht) |
| 147 | ? 'text' |
| 148 | : null; |
| 149 | return { |
| 150 | gesetz, |
| 151 | norm, |
| 152 | titel: text.titel, |
| 153 | istAnlage: text.istAnlage, |
| 154 | treffer, |
| 155 | }; |
| 156 | }); |
| 157 | |
| 158 | const gefiltert = gesucht.length === 0 ? alle : alle.filter((n) => n.treffer !== null); |
| 159 | return gefiltert.sort(nachOrdnung); |
| 160 | } |
| 161 | |
| 162 | /** |
| 163 | * Wie viele Normen jedes Gesetz zu einem Suchbegriff beisteuert. |
| 164 | * |
| 165 | * Damit die Gesetzesauswahl sagen kann, wo etwas zu finden ist, statt den |
| 166 | * Suchenden sieben Gesetze der Reihe nach durchklicken zu lassen. |
| 167 | */ |
| 168 | export function trefferJeGesetz( |
| 169 | normtexte: Normtexte, |
| 170 | suche: string, |
| 171 | ): ReadonlyMap<Gesetzeskuerzel, number> { |
| 172 | const zahlen = new Map<Gesetzeskuerzel, number>(); |
| 173 | for (const kuerzel of Object.keys(normtexte.gesetze) as Gesetzeskuerzel[]) { |
| 174 | zahlen.set(kuerzel, normenAuflisten(normtexte, kuerzel, suche).length); |
| 175 | } |
| 176 | return zahlen; |
| 177 | } |
| 178 | |
| 179 | /** Die ausgeschriebene Bezeichnung – auch für ein Kürzel ohne Normtext. */ |
| 180 | export function bezeichnungVon(kuerzel: Gesetzeskuerzel): string { |
| 181 | return GESETZ_BEZEICHNUNG[kuerzel]; |
| 182 | } |