waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app src main fragendruck-export.ts
| 1 | /** |
| 2 | * Der Weg vom Katalog zu Fehlerprotokoll und Fragenliste. |
| 3 | * |
| 4 | * Beide Dokumente holen ihre Fragen aus dem Anwendungskern und nicht aus der |
| 5 | * Oberfläche – aus demselben Grund wie der Lernbericht: Ein Dokument nennt |
| 6 | * eine Quelle und steht damit für seinen Inhalt gerade. Reichte die Oberfläche |
| 7 | * die Fragen mit, könnte ein Anzeigefehler in ein Papier gelangen, das jemand |
| 8 | * für bare Münze nimmt. |
| 9 | * |
| 10 | * **Die Fehlermenge kommt aus `lernstand.sitzung`**, nicht aus einer eigenen |
| 11 | * Abfrage. Damit ist zugesichert, was der Auftrag verlangt: Was auf dem |
| 12 | * Startbildschirm hinter „Nur Fehler“ steht, steht hier auf dem Papier. Eine |
| 13 | * zweite Zählweise daneben wäre die Art Widerspruch, die in dieser Anwendung |
| 14 | * schon zweimal eine Kennzahl gekostet hat. |
| 15 | */ |
| 16 | |
| 17 | import type { BrowserWindow } from 'electron'; |
| 18 | |
| 19 | import { fundstelleText } from '../shared/erklaerungen'; |
| 20 | import { |
| 21 | fehlerprotokollBauen, |
| 22 | fehlerprotokollDateiname, |
| 23 | type Erklaerungstiefe, |
| 24 | } from '../shared/druck/fehlerprotokoll'; |
| 25 | import { fragenlisteBauen, fragenlisteDateiname } from '../shared/druck/fragenliste'; |
| 26 | import type { Papierfrage } from '../shared/druck/frageblock'; |
| 27 | import type { Druckergebnis } from '../shared/ipc'; |
| 28 | import type { Frage, Katalog } from '../shared/katalog'; |
| 29 | import { |
| 30 | papierfragen, |
| 31 | quelleAusKatalog, |
| 32 | schriftgroessePruefen, |
| 33 | speichern, |
| 34 | type Zeitgeber, |
| 35 | } from './dokument-export'; |
| 36 | import { erklaerungenLaden } from './erklaerungen'; |
| 37 | import type { Lernstand } from './lernstand'; |
| 38 | |
| 39 | export interface Fragendruckumgebung { |
| 40 | readonly lernstand: Lernstand; |
| 41 | readonly katalog: Katalog; |
| 42 | readonly elternfenster: BrowserWindow | null; |
| 43 | readonly jetzt: Zeitgeber; |
| 44 | } |
| 45 | |
| 46 | /** Alternativtexte je Bild-ID; sie liegen bereits im Katalog. */ |
| 47 | function alttexteAus(katalog: Katalog): ReadonlyMap<string, string> { |
| 48 | return new Map(katalog.bilder.map((b) => [b.id, b.alt ?? ''])); |
| 49 | } |
| 50 | |
| 51 | /** Erklärungen in der Form, die der Papierbaustein erwartet. */ |
| 52 | function erklaerungenFuer( |
| 53 | ids: ReadonlySet<string>, |
| 54 | ): ReadonlyMap<string, Papierfrage['erklaerung']> { |
| 55 | const bestand = erklaerungenLaden().zuFrage; |
| 56 | const karte = new Map<string, Papierfrage['erklaerung']>(); |
| 57 | for (const id of ids) { |
| 58 | const e = bestand[id]; |
| 59 | if (e === undefined) { |
| 60 | continue; |
| 61 | } |
| 62 | karte.set(id, { |
| 63 | kurz: e.kurz, |
| 64 | text: e.text, |
| 65 | ...(e.merksatz === undefined ? {} : { merksatz: e.merksatz }), |
| 66 | fundstellen: e.fundstellen.map(fundstelleText), |
| 67 | }); |
| 68 | } |
| 69 | return karte; |
| 70 | } |
| 71 | |
| 72 | function tiefePruefen(wert: unknown): Erklaerungstiefe { |
| 73 | return wert === 'voll' ? 'voll' : 'kurz'; |
| 74 | } |
| 75 | |
| 76 | // ── Fehlerprotokoll ───────────────────────────────────────────────────── |
| 77 | |
| 78 | export async function fehlerprotokollExportieren( |
| 79 | umgebung: Fragendruckumgebung, |
| 80 | profilIdRoh: unknown, |
| 81 | schriftgroesseRoh: unknown, |
| 82 | tiefeRoh: unknown, |
| 83 | ): Promise<Druckergebnis> { |
| 84 | const { lernstand, katalog, elternfenster, jetzt } = umgebung; |
| 85 | |
| 86 | /* Derselbe Weg wie der Startbildschirm, nur ohne Deckel und ohne Mischen: |
| 87 | Das Dokument muss ALLE betroffenen Fragen enthalten, und zwar in |
| 88 | Katalogreihenfolge. `sitzung` prüft die Profil-ID mit denselben Regeln |
| 89 | wie überall sonst. */ |
| 90 | const auswahl = lernstand.sitzung(profilIdRoh, { |
| 91 | nurFehler: true, |
| 92 | mischen: false, |
| 93 | anzahl: katalog.fragen.length, |
| 94 | }); |
| 95 | |
| 96 | const nachId = new Map(katalog.fragen.map((f) => [f.id, f])); |
| 97 | const fragen = auswahl |
| 98 | .map((s) => nachId.get(s.frageId)) |
| 99 | .filter((f): f is Frage => f !== undefined); |
| 100 | |
| 101 | const profilId = typeof profilIdRoh === 'number' ? profilIdRoh : -1; |
| 102 | const profilName = lernstand.profile().find((p) => p.id === profilId)?.name ?? 'Ohne Profil'; |
| 103 | const erstelltAm = jetzt().toISOString(); |
| 104 | const tiefe = tiefePruefen(tiefeRoh); |
| 105 | |
| 106 | const html = fehlerprotokollBauen({ |
| 107 | fragen: papierfragen( |
| 108 | fragen, |
| 109 | katalog, |
| 110 | alttexteAus(katalog), |
| 111 | erklaerungenFuer(new Set(fragen.map((f) => f.id))), |
| 112 | ), |
| 113 | profilName, |
| 114 | erstelltAm, |
| 115 | tiefe, |
| 116 | schriftgroesse: schriftgroessePruefen(schriftgroesseRoh), |
| 117 | quelle: quelleAusKatalog(katalog, { |
| 118 | art: 'wiedergabe', |
| 119 | fragen: fragen.length, |
| 120 | fragenGesamt: katalog.fragen.length, |
| 121 | loesungen: 'alle', |
| 122 | optionen: 'alle', |
| 123 | }), |
| 124 | }); |
| 125 | |
| 126 | return speichern({ |
| 127 | html, |
| 128 | quelle: quelleAusKatalog(katalog, { |
| 129 | art: 'wiedergabe', |
| 130 | fragen: fragen.length, |
| 131 | fragenGesamt: katalog.fragen.length, |
| 132 | loesungen: 'alle', |
| 133 | optionen: 'alle', |
| 134 | }), |
| 135 | dialogtitel: 'Fehlerprotokoll speichern', |
| 136 | dateiname: fehlerprotokollDateiname(profilName, erstelltAm), |
| 137 | elternfenster, |
| 138 | }); |
| 139 | } |
| 140 | |
| 141 | // ── Fragenliste ───────────────────────────────────────────────────────── |
| 142 | |
| 143 | /** |
| 144 | * Grenzt den Katalog auf die gewählten Bereiche ein. |
| 145 | * |
| 146 | * Eine leere Auswahl bedeutet **alles**. Das ist die einzige sinnvolle |
| 147 | * Lesart: Wer nichts einschränkt, will den ganzen Bogen – und der |
| 148 | * Quellenblock sagt dann „alle 575 Fragen“ statt „einen Teil“. |
| 149 | */ |
| 150 | function eingegrenzt(katalog: Katalog, bereiche: readonly string[]): Frage[] { |
| 151 | if (bereiche.length === 0) { |
| 152 | return [...katalog.fragen]; |
| 153 | } |
| 154 | const gewaehlt = new Set(bereiche); |
| 155 | return katalog.fragen.filter( |
| 156 | (f) => gewaehlt.has(f.kapitel) || (f.abschnitt !== null && gewaehlt.has(f.abschnitt)), |
| 157 | ); |
| 158 | } |
| 159 | |
| 160 | /** Überschrift der Auswahl – nennt Bereiche beim Titel, nicht bei der Kennung. */ |
| 161 | function auswahlTitel(katalog: Katalog, bereiche: readonly string[]): string { |
| 162 | if (bereiche.length === 0) { |
| 163 | return 'gesamter Fragenkatalog'; |
| 164 | } |
| 165 | const titel = new Map<string, string>(); |
| 166 | for (const kapitel of katalog.kapitel) { |
| 167 | titel.set(kapitel.id, `${kapitel.id} – ${kapitel.titel}`); |
| 168 | for (const abschnitt of kapitel.abschnitte) { |
| 169 | titel.set(abschnitt.id, `${abschnitt.id} – ${abschnitt.titel}`); |
| 170 | } |
| 171 | } |
| 172 | return bereiche.map((b) => titel.get(b) ?? b).join(', '); |
| 173 | } |
| 174 | |
| 175 | export async function fragenlisteExportieren( |
| 176 | umgebung: Fragendruckumgebung, |
| 177 | bereicheRoh: unknown, |
| 178 | schriftgroesseRoh: unknown, |
| 179 | mitLoesungenRoh: unknown, |
| 180 | ): Promise<Druckergebnis> { |
| 181 | const { katalog, elternfenster, jetzt } = umgebung; |
| 182 | |
| 183 | const bereiche = Array.isArray(bereicheRoh) |
| 184 | ? bereicheRoh.filter((b): b is string => typeof b === 'string') |
| 185 | : []; |
| 186 | const fragen = eingegrenzt(katalog, bereiche); |
| 187 | |
| 188 | /* Ohne ausdrückliche Angabe MIT Lösungsanhang: Der Fall „ganzer Katalog |
| 189 | ohne Lösungen“ ist der einzige, in dem gar keine Auszugserwägung mehr |
| 190 | trägt – 575 von 575 Fragen, 0 von 629 Markierungen. Solange die Anfrage |
| 191 | beim Bundesverwaltungsamt aussteht, ist die vorsichtige Vorgabe die |
| 192 | richtige. Siehe LICENSES/LicenseRef-Amtliches-Werk-BVA.txt Nummer 2a. */ |
| 193 | const mitLoesungen = mitLoesungenRoh !== false; |
| 194 | const erstelltAm = jetzt().toISOString(); |
| 195 | const auswahl = auswahlTitel(katalog, bereiche); |
| 196 | |
| 197 | const umfang = { |
| 198 | art: 'wiedergabe', |
| 199 | fragen: fragen.length, |
| 200 | fragenGesamt: katalog.fragen.length, |
| 201 | loesungen: mitLoesungen ? ('alle' as const) : ('keine' as const), |
| 202 | optionen: 'alle' as const, |
| 203 | } as const; |
| 204 | |
| 205 | const html = fragenlisteBauen({ |
| 206 | fragen: papierfragen(fragen, katalog, alttexteAus(katalog), new Map()), |
| 207 | auswahl, |
| 208 | erstelltAm, |
| 209 | mitLoesungen, |
| 210 | kapitel: katalog.kapitel, |
| 211 | schriftgroesse: schriftgroessePruefen(schriftgroesseRoh), |
| 212 | quelle: quelleAusKatalog(katalog, umfang), |
| 213 | }); |
| 214 | |
| 215 | return speichern({ |
| 216 | html, |
| 217 | quelle: quelleAusKatalog(katalog, umfang), |
| 218 | dialogtitel: 'Fragenliste speichern', |
| 219 | dateiname: fragenlisteDateiname(auswahl, erstelltAm), |
| 220 | elternfenster, |
| 221 | }); |
| 222 | } |