waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app src main fragendruck-export.ts

7,6 KB Rohdatei
app/src/main/fragendruck-export.ts — 222 Zeilen
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 }