waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Laden und Prüfen der Themengliederung. |
| 3 | * |
| 4 | * Wie Erklärungen, Glossar und Normtexte eine eigene Datei: |
| 5 | * |
| 6 | * - Entwicklung: `<repo>/content/themen.json` |
| 7 | * - Gepackt: `<app>/resources/themen.json` |
| 8 | * |
| 9 | * Fehlt sie oder ist sie unbrauchbar, läuft die Anwendung ohne. Dann steht in |
| 10 | * den Kapiteln II bis IV nur der Knopf für das ganze Kapitel – also genau der |
| 11 | * Zustand vor 0.22.0. |
| 12 | * |
| 13 | * Die Prüfung hier betrifft nur die Form. Dass jede Frage der Kapitel II bis |
| 14 | * IV in genau einer Gruppe liegt, sichert `data-pipeline/themen_bauen.py` vor |
| 15 | * der Auslieferung – dort steht der Katalog zur Verfügung, hier nicht. |
| 16 | */ |
| 17 | |
| 18 | import { existsSync, readFileSync } from 'node:fs'; |
| 19 | import { join } from 'node:path'; |
| 20 | |
| 21 | import { app } from 'electron'; |
| 22 | |
| 23 | import { THEMEN_LEER, type Themen, type Themengruppe } from '../shared/themen'; |
| 24 | import { sucheAufwaerts } from './katalog'; |
| 25 | |
| 26 | const DATEI = 'themen.json'; |
| 27 | |
| 28 | function ungueltig(nachricht: string): never { |
| 29 | throw new Error(`Themen ungültig: ${nachricht}`); |
| 30 | } |
| 31 | |
| 32 | function objekt(wert: unknown, pfad: string): Record<string, unknown> { |
| 33 | if (typeof wert !== 'object' || wert === null || Array.isArray(wert)) { |
| 34 | ungueltig(`${pfad} ist kein Objekt.`); |
| 35 | } |
| 36 | return wert as Record<string, unknown>; |
| 37 | } |
| 38 | |
| 39 | function pflichttext(wert: unknown, pfad: string): string { |
| 40 | if (typeof wert !== 'string' || wert.trim().length === 0) { |
| 41 | ungueltig(`${pfad} fehlt oder ist leer.`); |
| 42 | } |
| 43 | return wert; |
| 44 | } |
| 45 | |
| 46 | function gruppeLesen(roh: unknown, pfad: string): Themengruppe { |
| 47 | const o = objekt(roh, pfad); |
| 48 | |
| 49 | const fragenRoh = o['fragen']; |
| 50 | if (!Array.isArray(fragenRoh)) { |
| 51 | ungueltig(`${pfad}.fragen fehlt oder ist keine Liste.`); |
| 52 | } |
| 53 | const fragen = (fragenRoh as readonly unknown[]).map((eintrag, i) => |
| 54 | pflichttext(eintrag, `${pfad}.fragen[${String(i)}]`), |
| 55 | ); |
| 56 | if (fragen.length === 0) { |
| 57 | /* Eine leere Gruppe erschiene als Knopf, der eine Sitzung ohne Fragen |
| 58 | startet – schlimmer als eine fehlende Gruppe. */ |
| 59 | ungueltig(`${pfad}.fragen ist leer.`); |
| 60 | } |
| 61 | |
| 62 | return { |
| 63 | id: pflichttext(o['id'], `${pfad}.id`), |
| 64 | kapitel: pflichttext(o['kapitel'], `${pfad}.kapitel`), |
| 65 | titel: pflichttext(o['titel'], `${pfad}.titel`), |
| 66 | fragen, |
| 67 | }; |
| 68 | } |
| 69 | |
| 70 | function themenValidieren(roh: unknown): Themen { |
| 71 | const o = objekt(roh, 'Die Datei'); |
| 72 | const meta = objekt(o['meta'], 'meta'); |
| 73 | |
| 74 | const version = meta['version']; |
| 75 | if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) { |
| 76 | ungueltig('meta.version fehlt oder ist keine ganze Zahl ab 1.'); |
| 77 | } |
| 78 | |
| 79 | const gruppenRoh = o['gruppen']; |
| 80 | if (!Array.isArray(gruppenRoh)) { |
| 81 | ungueltig('gruppen fehlt oder ist keine Liste.'); |
| 82 | } |
| 83 | const gruppen = (gruppenRoh as readonly unknown[]).map((gruppe, i) => |
| 84 | gruppeLesen(gruppe, `gruppen[${String(i)}]`), |
| 85 | ); |
| 86 | |
| 87 | /* Doppelte Kennungen wären in der Anzeige mehrdeutig, doppelte Fragen |
| 88 | hießen, dass ein Thema Fragen eines anderen mitzieht. */ |
| 89 | const kennungen = new Set<string>(); |
| 90 | const fragen = new Set<string>(); |
| 91 | for (const gruppe of gruppen) { |
| 92 | if (kennungen.has(gruppe.id)) { |
| 93 | ungueltig(`Die Gruppenkennung „${gruppe.id}“ kommt mehrfach vor.`); |
| 94 | } |
| 95 | kennungen.add(gruppe.id); |
| 96 | for (const frageId of gruppe.fragen) { |
| 97 | if (fragen.has(frageId)) { |
| 98 | ungueltig(`${frageId} steht in mehr als einer Gruppe.`); |
| 99 | } |
| 100 | fragen.add(frageId); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | return { |
| 105 | meta: { |
| 106 | version, |
| 107 | stand: pflichttext(meta['stand'], 'meta.stand'), |
| 108 | hinweis: pflichttext(meta['hinweis'], 'meta.hinweis'), |
| 109 | }, |
| 110 | gruppen, |
| 111 | }; |
| 112 | } |
| 113 | |
| 114 | /** Pfad der Themendatei – gepackt wie ungepackt. */ |
| 115 | export function themenPfad(): string { |
| 116 | if (app.isPackaged) { |
| 117 | return join(process.resourcesPath, DATEI); |
| 118 | } |
| 119 | |
| 120 | const relativ = join('content', DATEI); |
| 121 | for (const start of [app.getAppPath(), __dirname, process.cwd()]) { |
| 122 | const treffer = sucheAufwaerts(start, relativ); |
| 123 | if (treffer) { |
| 124 | return treffer; |
| 125 | } |
| 126 | } |
| 127 | return join(app.getAppPath(), '..', relativ); |
| 128 | } |
| 129 | |
| 130 | let speicher: Themen | null = null; |
| 131 | |
| 132 | export function themenLaden(): Themen { |
| 133 | if (speicher !== null) { |
| 134 | return speicher; |
| 135 | } |
| 136 | |
| 137 | const pfad = themenPfad(); |
| 138 | if (!existsSync(pfad)) { |
| 139 | speicher = THEMEN_LEER; |
| 140 | return speicher; |
| 141 | } |
| 142 | |
| 143 | try { |
| 144 | speicher = themenValidieren(JSON.parse(readFileSync(pfad, 'utf8'))); |
| 145 | } catch (fehler: unknown) { |
| 146 | console.error('Themen konnten nicht gelesen werden:', fehler); |
| 147 | speicher = THEMEN_LEER; |
| 148 | } |
| 149 | |
| 150 | return speicher; |
| 151 | } |
| 152 | |
| 153 | /** Gegenstück für Tests. */ |
| 154 | export function themenZuruecksetzen(): void { |
| 155 | speicher = null; |
| 156 | } |