waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app src shared fragensuche.ts
| 1 | /** |
| 2 | * Die Volltextsuche über den amtlichen Fragenkatalog. |
| 3 | * |
| 4 | * ## Wo der Index liegt und warum hier |
| 5 | * |
| 6 | * Im Arbeitsspeicher, aufgebaut aus dem bereits geladenen Katalog. Nicht in |
| 7 | * SQLite, nicht im Hauptprozess, nicht in der Lernstand-Datei. Die Gründe |
| 8 | * stehen ausführlich in `docs/entscheidung-volltextsuche.md`; die drei |
| 9 | * wichtigsten in einem Satz: FTS5 kann deutsche Umlaute **oder** deutsche |
| 10 | * Zusammensetzungen, nie beides; wer die Faltung deshalb ohnehin selbst |
| 11 | * schreibt, bekommt von FTS5 nichts mehr geschenkt; und der Katalog liegt |
| 12 | * bereits vollständig im Renderer. |
| 13 | * |
| 14 | * Gemessen: Aufbau rund 3 ms für 151.000 Zeichen, eine Abfrage 0,15 ms. Ein |
| 15 | * Bild bei 60 Hz dauert 16,7 ms. |
| 16 | * |
| 17 | * ## Was durchsucht wird |
| 18 | * |
| 19 | * Fragetext, **alle** Antwortmöglichkeiten und die Musterantwort. Die |
| 20 | * Antwortmöglichkeiten sind mit 57 Prozent der größte Textblock des Katalogs |
| 21 | * – eine Suche nur über Fragetexte verfehlte mehr als die Hälfte des |
| 22 | * Materials. |
| 23 | * |
| 24 | * Die Musterantwort wird durchsucht, aber **nie angezeigt**: Bei den 103 |
| 25 | * offenen Fragen ist sie die Lösung. Ein Treffer dort landet in der letzten |
| 26 | * Rangklasse und sagt von sich aus, dass er nicht gezeigt wird. |
| 27 | * |
| 28 | * Nicht durchsucht werden die Alternativtexte der Prüfzeichen. Sie sind bei |
| 29 | * Bildfragen redaktionell so geschrieben, dass sie die Lösung nicht verraten; |
| 30 | * ein Treffer darin machte genau diese Arbeit rückgängig. |
| 31 | */ |
| 32 | |
| 33 | import { BEGRIFFSBRUECKE } from './begriffsbruecke'; |
| 34 | import type { Katalog } from './katalog'; |
| 35 | import { falten, faltenMitZuordnung, fundstellen, istWortanfang } from './suchtext'; |
| 36 | |
| 37 | /** |
| 38 | * Wie gut ein Treffer sitzt – als Klasse, nicht als Punktwert. |
| 39 | * |
| 40 | * Eine Zahl, die niemand nachrechnen kann, ist in einem Lernmittel schlechter |
| 41 | * als eine Reihenfolge, die dasteht und sich erklärt. Das gilt doppelt für |
| 42 | * jemanden, der die Liste hört, statt sie zu überfliegen: Er kann eine |
| 43 | * Sortierung nicht überblicken, aber er kann einen Satz lesen hören. |
| 44 | */ |
| 45 | export type Rangklasse = 'frage-anfang' | 'frage-innen' | 'antwort' | 'muster'; |
| 46 | |
| 47 | const RANGFOLGE: readonly Rangklasse[] = ['frage-anfang', 'frage-innen', 'antwort', 'muster']; |
| 48 | |
| 49 | export interface Treffer { |
| 50 | readonly frageId: string; |
| 51 | readonly klasse: Rangklasse; |
| 52 | /** Labels der Antwortmöglichkeiten mit Fundstelle, in Katalogreihenfolge. */ |
| 53 | readonly optionen: readonly string[]; |
| 54 | /** |
| 55 | * Steht mindestens ein Suchwort auch im Fragetext? |
| 56 | * |
| 57 | * Nur für die Klassen `antwort` und `muster` von Belang, und dort für die |
| 58 | * Wahrheit der Fundstellenzeile: Sagt die Karte „Treffer nur in der |
| 59 | * Musterantwort“, markiert aber gleichzeitig ein Wort in der sichtbaren |
| 60 | * Frage, widersprechen sich Marke und Satz. Der Satz ist dann der falsche – |
| 61 | * die Marke steht ja zu Recht dort. |
| 62 | */ |
| 63 | readonly auchImFragetext: boolean; |
| 64 | } |
| 65 | |
| 66 | interface Feld { |
| 67 | readonly label: string; |
| 68 | readonly gefaltet: string; |
| 69 | } |
| 70 | |
| 71 | interface Eintrag { |
| 72 | readonly frageId: string; |
| 73 | readonly kapitel: string; |
| 74 | readonly abschnitt: string | null; |
| 75 | readonly fragetext: string; |
| 76 | readonly optionen: readonly Feld[]; |
| 77 | readonly musterantwort: string; |
| 78 | /** Alle Felder zusammen – die schnelle Vorprüfung je Suchwort. */ |
| 79 | readonly alles: string; |
| 80 | } |
| 81 | |
| 82 | export interface Suchindex { |
| 83 | readonly eintraege: readonly Eintrag[]; |
| 84 | } |
| 85 | |
| 86 | /** Ein wählbarer Bereich der Trefferliste: Kapitel oder Abschnitt. */ |
| 87 | export interface Bereich { |
| 88 | /** `null` steht für „alle Bereiche“. */ |
| 89 | readonly id: string | null; |
| 90 | readonly titel: string; |
| 91 | readonly anzahl: number; |
| 92 | } |
| 93 | |
| 94 | export interface Suchergebnis { |
| 95 | readonly treffer: readonly Treffer[]; |
| 96 | /** Die gefalteten Suchwörter – die Oberfläche hebt danach hervor. */ |
| 97 | readonly woerter: readonly string[]; |
| 98 | /** Die Eingabe ist zu kurz, um überhaupt zu suchen. */ |
| 99 | readonly zuKurz: boolean; |
| 100 | /** Kein Suchwort eingegeben: die Liste zeigt den Bereich vollständig. */ |
| 101 | readonly stoebern: boolean; |
| 102 | } |
| 103 | |
| 104 | /** Kürzeste Eingabe, mit der gesucht wird. */ |
| 105 | export const MINDESTLAENGE = 2; |
| 106 | |
| 107 | // ─── Aufbau ────────────────────────────────────────────────────────────── |
| 108 | |
| 109 | export function suchindexBauen(katalog: Katalog): Suchindex { |
| 110 | const eintraege: Eintrag[] = katalog.fragen.map((frage) => { |
| 111 | const fragetext = falten(frage.frage.text); |
| 112 | const optionen = (frage.optionen ?? []).map((option) => ({ |
| 113 | label: option.label, |
| 114 | gefaltet: falten(option.inhalt.text), |
| 115 | })); |
| 116 | const musterantwort = frage.musterantwort ? falten(frage.musterantwort.text) : ''; |
| 117 | return { |
| 118 | frageId: frage.id, |
| 119 | kapitel: frage.kapitel, |
| 120 | abschnitt: frage.abschnitt, |
| 121 | fragetext, |
| 122 | optionen, |
| 123 | musterantwort, |
| 124 | alles: [fragetext, ...optionen.map((o) => o.gefaltet), musterantwort].join(' '), |
| 125 | }; |
| 126 | }); |
| 127 | return { eintraege }; |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * Die wählbaren Bereiche samt Fragenzahl. |
| 132 | * |
| 133 | * Die Zahlen stehen in der Beschriftung, damit niemand einen Bereich wählt |
| 134 | * und erst danach merkt, dass darin drei Fragen liegen. Sie kommen aus dem |
| 135 | * Katalog und nicht aus einer zweiten Pflege – eine Zahl, die von Hand |
| 136 | * nachgeführt wird, ist eine Zahl, die irgendwann falsch ist. |
| 137 | */ |
| 138 | export function bereiche(katalog: Katalog): readonly Bereich[] { |
| 139 | const aus: Bereich[] = [{ id: null, titel: 'Alle Bereiche', anzahl: katalog.fragen.length }]; |
| 140 | for (const kapitel of katalog.kapitel) { |
| 141 | aus.push({ |
| 142 | id: kapitel.id, |
| 143 | titel: `Kapitel ${kapitel.id} – ${kapitel.titel}`, |
| 144 | anzahl: katalog.fragen.filter((f) => f.kapitel === kapitel.id).length, |
| 145 | }); |
| 146 | for (const abschnitt of kapitel.abschnitte) { |
| 147 | aus.push({ |
| 148 | id: abschnitt.id, |
| 149 | titel: `${abschnitt.id} – ${abschnitt.titel}`, |
| 150 | anzahl: katalog.fragen.filter((f) => f.abschnitt === abschnitt.id).length, |
| 151 | }); |
| 152 | } |
| 153 | } |
| 154 | return aus; |
| 155 | } |
| 156 | |
| 157 | function imBereich(eintrag: Eintrag, bereich: string | null): boolean { |
| 158 | if (bereich === null) { |
| 159 | return true; |
| 160 | } |
| 161 | return eintrag.kapitel === bereich || eintrag.abschnitt === bereich; |
| 162 | } |
| 163 | |
| 164 | // ─── Suchen ────────────────────────────────────────────────────────────── |
| 165 | |
| 166 | /** |
| 167 | * Zerlegt die Eingabe in gefaltete Suchwörter. |
| 168 | * |
| 169 | * Getrennt wird an Leerzeichen, sonst nichts: keine Anführungszeichen, keine |
| 170 | * Platzhalter, kein UND/ODER/NICHT. Die Fachnotation dieses Korpus besteht |
| 171 | * genau aus den Zeichen, an denen eine Abfragesprache zerbricht – „7,65“, |
| 172 | * „Abs. 1“, „§ 3“, „II-45“ lösen bei FTS5 harte Fehler aus. Hier gibt es |
| 173 | * keine Eingabe, die eine Ausnahme wirft. |
| 174 | */ |
| 175 | export function suchwoerter(eingabe: string): string[] { |
| 176 | return falten(eingabe) |
| 177 | .split(' ') |
| 178 | .map((wort) => randzeichenAbschneiden(wort)) |
| 179 | .filter((w) => w.length > 0); |
| 180 | } |
| 181 | |
| 182 | /** |
| 183 | * Schneidet Satzzeichen an den Rändern eines Suchworts ab. |
| 184 | * |
| 185 | * Ohne das sucht die Anwendung das Satzzeichen mit, und zwar still: |
| 186 | * „Notwehr!“ fand null Fragen, „Notwehr“ neunundzwanzig. Dasselbe bei |
| 187 | * „Schusswaffe;“, bei „(Erwerb“ und – besonders ärgerlich – bei |
| 188 | * „„Sicherheitsbehältnis““ mit den typografischen Anführungszeichen, die |
| 189 | * diese Anwendung in ihren eigenen Hinweistexten setzt. Wer den Begriff aus |
| 190 | * dem eigenen Angebot der Suche kopierte, bekam nichts. |
| 191 | * |
| 192 | * Abgeschnitten wird **nur außen**. Innen bleibt jedes Zeichen stehen, denn |
| 193 | * dort trägt es Bedeutung: „7,5“, „12/70“, „I.2-150“, „II-45“. |
| 194 | */ |
| 195 | function randzeichenAbschneiden(wort: string): string { |
| 196 | const istWortzeichen = (zeichen: string): boolean => /[\p{L}\p{N}]/u.test(zeichen); |
| 197 | let anfang = 0; |
| 198 | let ende = wort.length; |
| 199 | while (anfang < ende && !istWortzeichen(wort[anfang] ?? '')) { |
| 200 | anfang++; |
| 201 | } |
| 202 | while (ende > anfang && !istWortzeichen(wort[ende - 1] ?? '')) { |
| 203 | ende--; |
| 204 | } |
| 205 | return wort.slice(anfang, ende); |
| 206 | } |
| 207 | |
| 208 | /** Enthält der Eintrag alle Suchwörter – gleich in welchem Feld? */ |
| 209 | function alleWoerterVorhanden(eintrag: Eintrag, woerter: readonly string[]): boolean { |
| 210 | return woerter.every((wort) => eintrag.alles.includes(wort)); |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * In welche Rangklasse fällt der Treffer, und welche Antworten sind betroffen? |
| 215 | * |
| 216 | * Die Klasse richtet sich danach, mit welchem Feld die Anfrage **vollständig** |
| 217 | * erfüllt ist: Erst wenn der Fragetext allein nicht reicht, zählen die |
| 218 | * Antwortmöglichkeiten, und erst wenn auch die nicht reichen, die |
| 219 | * Musterantwort. So steht nie ein Antworttreffer über einem Fragetexttreffer. |
| 220 | */ |
| 221 | function einordnen(eintrag: Eintrag, woerter: readonly string[]): Treffer | null { |
| 222 | const imFragetext = woerter.every((wort) => eintrag.fragetext.includes(wort)); |
| 223 | |
| 224 | if (imFragetext) { |
| 225 | const amWortanfang = woerter.every((wort) => |
| 226 | fundstellen(eintrag.fragetext, wort).some((stelle) => |
| 227 | istWortanfang(eintrag.fragetext, stelle), |
| 228 | ), |
| 229 | ); |
| 230 | return { |
| 231 | frageId: eintrag.frageId, |
| 232 | klasse: amWortanfang ? 'frage-anfang' : 'frage-innen', |
| 233 | optionen: [], |
| 234 | auchImFragetext: true, |
| 235 | }; |
| 236 | } |
| 237 | |
| 238 | const auchImFragetext = woerter.some((wort) => eintrag.fragetext.includes(wort)); |
| 239 | |
| 240 | const mitFragetext = (feld: string): string => `${eintrag.fragetext} ${feld}`; |
| 241 | const inOptionen = eintrag.optionen |
| 242 | .filter((option) => woerter.some((wort) => option.gefaltet.includes(wort))) |
| 243 | .map((option) => option.label); |
| 244 | |
| 245 | const optionenReichen = woerter.every((wort) => |
| 246 | eintrag.optionen.some((option) => mitFragetext(option.gefaltet).includes(wort)), |
| 247 | ); |
| 248 | if (optionenReichen && inOptionen.length > 0) { |
| 249 | return { frageId: eintrag.frageId, klasse: 'antwort', optionen: inOptionen, auchImFragetext }; |
| 250 | } |
| 251 | |
| 252 | if (alleWoerterVorhanden(eintrag, woerter)) { |
| 253 | return { frageId: eintrag.frageId, klasse: 'muster', optionen: inOptionen, auchImFragetext }; |
| 254 | } |
| 255 | return null; |
| 256 | } |
| 257 | |
| 258 | /** |
| 259 | * Sucht im Katalog. |
| 260 | * |
| 261 | * Linear über 575 Einträge – eine invertierte Wortliste könnte die |
| 262 | * Teilwortsuche gar nicht leisten, auf die es hier ankommt: „besitzkarte“ |
| 263 | * findet als Wortanfangssuche **null** Fragen und als Teilwort **52**. |
| 264 | * Deutsche Rechtssprache verschluckt das gesuchte Wort im Kompositum. |
| 265 | */ |
| 266 | export function suchen( |
| 267 | index: Suchindex, |
| 268 | eingabe: string, |
| 269 | bereich: string | null = null, |
| 270 | ): Suchergebnis { |
| 271 | const woerter = suchwoerter(eingabe); |
| 272 | const gesamt = woerter.join(''); |
| 273 | |
| 274 | if (gesamt.length === 0) { |
| 275 | /* Leeres Feld ist kein Fehlerfall, sondern der Fragen-Browser: Der |
| 276 | gewählte Bereich wird vollständig gezeigt, in Katalogreihenfolge. */ |
| 277 | return { |
| 278 | treffer: index.eintraege |
| 279 | .filter((e) => imBereich(e, bereich)) |
| 280 | .map((e) => ({ |
| 281 | frageId: e.frageId, |
| 282 | klasse: 'frage-anfang' as const, |
| 283 | optionen: [], |
| 284 | auchImFragetext: true, |
| 285 | })), |
| 286 | woerter: [], |
| 287 | zuKurz: false, |
| 288 | stoebern: true, |
| 289 | }; |
| 290 | } |
| 291 | |
| 292 | if (gesamt.length < MINDESTLAENGE) { |
| 293 | return { treffer: [], woerter, zuKurz: true, stoebern: false }; |
| 294 | } |
| 295 | |
| 296 | const treffer: Treffer[] = []; |
| 297 | for (const eintrag of index.eintraege) { |
| 298 | if (!imBereich(eintrag, bereich) || !alleWoerterVorhanden(eintrag, woerter)) { |
| 299 | continue; |
| 300 | } |
| 301 | const gefunden = einordnen(eintrag, woerter); |
| 302 | if (gefunden !== null) { |
| 303 | treffer.push(gefunden); |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | /* Stabil nach Rangklasse, innerhalb einer Klasse in Katalogreihenfolge – |
| 308 | der Reihenfolge, die der Prüfling aus der amtlichen Vorlage kennt. */ |
| 309 | treffer.sort((a, b) => RANGFOLGE.indexOf(a.klasse) - RANGFOLGE.indexOf(b.klasse)); |
| 310 | return { treffer, woerter, zuKurz: false, stoebern: false }; |
| 311 | } |
| 312 | |
| 313 | // ─── Hilfen für den Leerzustand ────────────────────────────────────────── |
| 314 | |
| 315 | /** |
| 316 | * Welches Wort einer mehrteiligen Anfrage ist schuld am leeren Ergebnis? |
| 317 | * |
| 318 | * „Keine Treffer“ ist eine Sackgasse; „‚verloren‘ kommt im Katalog nicht vor, |
| 319 | * ohne dieses Wort: 305 Treffer“ ist ein Weg. Der Katalog sagt statt |
| 320 | * „verloren“ nämlich „abhanden gekommen“. |
| 321 | */ |
| 322 | export function schuldigeWoerter(index: Suchindex, woerter: readonly string[]): string[] { |
| 323 | return woerter.filter((wort) => !index.eintraege.some((e) => e.alles.includes(wort))); |
| 324 | } |
| 325 | |
| 326 | /** Ein Wort des Katalogs samt der Zahl, die eine Suche danach liefert. */ |
| 327 | export interface Brueckenvorschlag { |
| 328 | readonly wort: string; |
| 329 | readonly anzahl: number; |
| 330 | } |
| 331 | |
| 332 | export interface Brueckenangebot { |
| 333 | /** |
| 334 | * Je Katalogwort ein eigener Vorschlag – **nicht** eine gemeinsame Zahl. |
| 335 | * |
| 336 | * Das war zuerst anders und falsch: Das Angebot nannte die |
| 337 | * Vereinigungsmenge über alle Katalogwörter („27 Fragen“) und setzte beim |
| 338 | * Klick nur das erste Wort ein („Sicherheitsbehältnis“, 3 Fragen). Der |
| 339 | * Suchende bekam ein Neuntel dessen, was ihm zugesagt war – und hielt das |
| 340 | * Thema danach für erschöpfend behandelt. Genau der Schaden, den diese |
| 341 | * Brücke verhindern soll. |
| 342 | * |
| 343 | * Die Vereinigung ist über das Suchfeld auch gar nicht herstellbar: Mehrere |
| 344 | * Wörter sind UND-verknüpft, „Sicherheitsbehältnis Widerstandsgrad |
| 345 | * Aufbewahrung“ liefert null. Also verspricht jeder Vorschlag nur noch das, |
| 346 | * was sein eigener Klick einlöst. |
| 347 | */ |
| 348 | readonly vorschlaege: readonly Brueckenvorschlag[]; |
| 349 | } |
| 350 | |
| 351 | /** |
| 352 | * Gibt es zu dieser Eingabe ein Wort, unter dem der Katalog dieselbe Sache |
| 353 | * führt? |
| 354 | * |
| 355 | * Das Angebot wird **immer** geprüft, nicht nur bei null Treffern. „Tresor“ |
| 356 | * findet drei Fragen – wer diese drei sieht, hält das Thema für erledigt und |
| 357 | * übersieht die zwanzig zur Aufbewahrung. Eine Suche, die zu wenig findet, |
| 358 | * ist gefährlicher als eine, die nichts findet: Die eine schweigt, die andere |
| 359 | * behauptet Vollständigkeit. |
| 360 | */ |
| 361 | export function brueckeFinden( |
| 362 | index: Suchindex, |
| 363 | eingabe: string, |
| 364 | bereich: string | null = null, |
| 365 | ): Brueckenangebot | null { |
| 366 | const woerter = new Set(suchwoerter(eingabe)); |
| 367 | if (woerter.size === 0) { |
| 368 | return null; |
| 369 | } |
| 370 | |
| 371 | /* |
| 372 | Verglichen wird Wort für Wort, nicht als Teilzeichenkette – anders als bei |
| 373 | der Suche selbst. Nachgemessen der Grund: „ölen“ faltet zu „olen“, und das |
| 374 | steckt in „Pistolen“, „wollen“, „sollen“. Wer nach Pistolen sucht, bekäme |
| 375 | sonst ein Angebot zur Instandhaltung. Beim Durchsuchen des Katalogs ist die |
| 376 | Teilzeichenkette richtig, beim Erkennen eines Stichworts ist sie falsch. |
| 377 | */ |
| 378 | const eintrag = BEGRIFFSBRUECKE.find((e) => e.gesucht.some((wort) => woerter.has(falten(wort)))); |
| 379 | if (eintrag === undefined) { |
| 380 | return null; |
| 381 | } |
| 382 | |
| 383 | /* Gezählt wird im gewählten Bereich, nicht katalogweit. Sonst verspräche |
| 384 | das Angebot 27 Fragen und führte in Kapitel II auf „Keine Frage |
| 385 | gefunden.“ – eine Zusage, die die Einschränkung nicht kennt. */ |
| 386 | const vorschlaege = eintrag.katalog |
| 387 | .filter((wort) => !woerter.has(falten(wort))) |
| 388 | .map((wort) => ({ wort, anzahl: suchen(index, wort, bereich).treffer.length })) |
| 389 | .filter((vorschlag) => vorschlag.anzahl > 0); |
| 390 | |
| 391 | return vorschlaege.length === 0 ? null : { vorschlaege }; |
| 392 | } |
| 393 | |
| 394 | export interface Zerlegung { |
| 395 | /** Der linke Teil – in der Schreibweise des Suchenden, nicht gefaltet. */ |
| 396 | readonly links: string; |
| 397 | readonly rechts: string; |
| 398 | readonly anzahl: number; |
| 399 | } |
| 400 | |
| 401 | /** |
| 402 | * Ein Zerlegungsangebot für ein zusammengesetztes Wort ohne Treffer. |
| 403 | * |
| 404 | * Das echte Loch, das keine Normalisierung schließt: „Reizstoffwaffe“ – der |
| 405 | * Gesetzesbegriff aus § 42a WaffG – steht im Katalog **kein einziges Mal**, |
| 406 | * weil dort „Schreckschuss-, Reizstoff- und Signalwaffen“ geschrieben ist. |
| 407 | * |
| 408 | * Zerlegt wird die **Anfrage**, nicht der Index. Beim Indexbau aufzulösen |
| 409 | * wäre der naheliegende Weg und der schlechtere: Die Regel „Kopf plus letztes |
| 410 | * Glied“ macht aus „Reizstoff- und Signalwaffen“ nicht „Reizstoffwaffen“, |
| 411 | * sondern „Reizstoffsignalwaffen“, und aus kleingeschriebenen Fragmenten wie |
| 412 | * „ge- und entladen“ erzeugt sie Unwörter. Auf der Anfrageseite kostet es nur |
| 413 | * im Nullfall Rechenzeit, erzeugt nichts Falsches im Index und geschieht |
| 414 | * sichtbar auf Knopfdruck. |
| 415 | */ |
| 416 | export function zerlegung( |
| 417 | index: Suchindex, |
| 418 | rohesWort: string, |
| 419 | bereich: string | null = null, |
| 420 | ): Zerlegung | null { |
| 421 | const MINDEST_TEIL = 3; |
| 422 | const faltung = faltenMitZuordnung(rohesWort); |
| 423 | const gefaltet = faltung.gefaltet; |
| 424 | let beste: Zerlegung | null = null; |
| 425 | |
| 426 | for (let schnitt = MINDEST_TEIL; schnitt <= gefaltet.length - MINDEST_TEIL; schnitt++) { |
| 427 | const links = gefaltet.slice(0, schnitt); |
| 428 | const rechts = gefaltet.slice(schnitt); |
| 429 | const anzahl = index.eintraege.filter( |
| 430 | (e) => imBereich(e, bereich) && e.alles.includes(links) && e.alles.includes(rechts), |
| 431 | ).length; |
| 432 | if (anzahl > 0 && (beste === null || anzahl > beste.anzahl)) { |
| 433 | /* Angeboten wird die Schreibweise des Suchenden, nicht die Kanonform: |
| 434 | „Reizstoff“ und „waffe“, nicht „reizstof“ und „wafe“. Die Faltung ist |
| 435 | ein inneres Werkzeug und hat auf dem Bildschirm nichts verloren. */ |
| 436 | const grenze = faltung.von[schnitt] ?? rohesWort.length; |
| 437 | /* Die Ränder abschneiden: Bei „Kurzwaffen-Munition“ fiele der |
| 438 | Bindestrich sonst an das Ende des linken Teils, und die neue Anfrage |
| 439 | „Kurzwaffen- Munition“ fände null – das Angebot verspräche drei. */ |
| 440 | beste = { |
| 441 | links: randzeichenAbschneiden(rohesWort.slice(0, grenze)), |
| 442 | rechts: randzeichenAbschneiden(rohesWort.slice(grenze)), |
| 443 | anzahl, |
| 444 | }; |
| 445 | } |
| 446 | } |
| 447 | |
| 448 | return beste; |
| 449 | } |