waffensachkunde

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

/ app src main lernbericht-export.ts

5,9 KB Rohdatei
app/src/main/lernbericht-export.ts — 152 Zeilen
1 /**
2 * Der Weg vom Lernstand zur gespeicherten PDF-Datei.
3 *
4 * Getrennt von `druck.ts` (das nur aus HTML ein PDF macht) und von
5 * `shared/druck/` (reine Textbausteine ohne Electron). Hier kommt zusammen,
6 * was ohne Electron nicht geht: Daten holen, Datei anbieten, schreiben.
7 *
8 * **Warum der Kern die Zahlen selbst holt.** Die Oberfläche schickt nur, für
9 * welches Profil und in welcher Schriftgröße. Reichte sie die Zahlen mit,
10 * könnte ein Fehler in der Anzeige in ein Dokument gelangen, das jemand für
11 * bare Münze nimmt – und das Dokument nennt eine Quelle, steht also für
12 * seinen Inhalt gerade.
13 */
14
15 import { join } from 'node:path';
16
17 import { app, dialog, type BrowserWindow } from 'electron';
18
19 import { type Quellenangabe } from '../shared/druck/dokument';
20 import { dateiname, lernberichtBauen } from '../shared/druck/lernbericht';
21 import type { Schriftgroesse } from '../shared/druck/stil';
22 import type { Druckergebnis } from '../shared/ipc';
23 import type { Katalog } from '../shared/katalog';
24 import { geschriebenMerken } from './dateizugriff';
25 import { pdfSchreiben } from './dokument-export';
26 import { pdfErzeugen } from './druck';
27 import type { Lernstand } from './lernstand';
28 import type { Pruefung } from './pruefung';
29
30 /**
31 * Baut die Quellenangabe aus den Katalogmetadaten.
32 *
33 * Ohne Katalog gibt es keinen Export: Die Angabe ließe sich dann nicht
34 * belegen, und ein Dokument mit erfundener oder fehlender Herkunft ist
35 * schlechter als gar keines.
36 */
37 export function quelleAusKatalog(katalog: Katalog, umfang: Quellenangabe['umfang']): Quellenangabe {
38 const { quellenangabe, herausgeber, stand, quelle_url } = katalog.meta;
39 if (quellenangabe.trim().length === 0) {
40 throw new Error('Der Fragenkatalog nennt keine Quellenangabe – es wird nichts exportiert.');
41 }
42 return { amtlich: quellenangabe, herausgeber, stand, quelleUrl: quelle_url, umfang };
43 }
44
45 function schriftgroessePruefen(wert: unknown): Schriftgroesse {
46 return wert === 'gross' ? 'gross' : 'normal';
47 }
48
49 /**
50 * Ein Zeitstempel, den Aufrufer setzen können.
51 *
52 * Ein fest verdrahtetes `new Date()` machte den Export untestbar: Das
53 * Dokument trüge bei jedem Lauf ein anderes Datum, und ein Vergleich gegen
54 * einen Sollwert wäre unmöglich.
55 */
56 export type Zeitgeber = () => Date;
57
58 export interface Exportumgebung {
59 readonly lernstand: Lernstand;
60 readonly pruefung: Pruefung;
61 readonly katalog: Katalog;
62 /** Fenster, an dem der Speicherdialog hängt – modal ist für Screenreader klarer. */
63 readonly elternfenster: BrowserWindow | null;
64 readonly jetzt: Zeitgeber;
65 }
66
67 /**
68 * Erzeugt den Lernbericht und speichert ihn dorthin, wohin der Nutzer zeigt.
69 *
70 * Ein Abbruch im Dialog ist kein Fehler: `gespeichert: false`. Alles andere –
71 * fehlendes Schreibrecht, voller Datenträger – wirft, damit die Oberfläche es
72 * sagen kann statt so zu tun, als sei alles gut gegangen.
73 */
74 export async function lernberichtExportieren(
75 umgebung: Exportumgebung,
76 profilIdRoh: unknown,
77 schriftgroesseRoh: unknown,
78 ): Promise<Druckergebnis> {
79 const { lernstand, pruefung, katalog, elternfenster, jetzt } = umgebung;
80
81 /* Die Prüfung der Profil-ID übernimmt der Lernstand – dieselben Regeln wie
82 überall sonst, an einer Stelle. */
83 const uebersicht = lernstand.uebersicht(profilIdRoh);
84 const verlauf = pruefung.verlauf(profilIdRoh);
85
86 /* Der Lernplan rechnet über den gesamten Katalog. Scheitert er, soll der
87 Bericht trotzdem entstehen – die übrigen Zahlen sind davon unberührt,
88 und ein Bericht ohne Planabschnitt ist besser als keiner. */
89 let plan = null;
90 try {
91 plan = lernstand.lernplan(profilIdRoh);
92 } catch (fehler: unknown) {
93 console.warn('[druck] Lernplan nicht ermittelbar, Bericht entsteht ohne ihn:', fehler);
94 }
95
96 const profilId = typeof profilIdRoh === 'number' ? profilIdRoh : -1;
97 const profil = lernstand.profile().find((p) => p.id === profilId);
98 const profilName = profil?.name ?? 'Ohne Profil';
99 const erstelltAm = jetzt().toISOString();
100
101 const quelle = quelleAusKatalog(katalog, { art: 'kein amtlicher wortlaut' });
102 const html = lernberichtBauen({
103 uebersicht,
104 plan,
105 verlauf,
106 profilName,
107 quelle,
108 erstelltAm,
109 schriftgroesse: schriftgroessePruefen(schriftgroesseRoh),
110 });
111
112 const vorschlag = join(app.getPath('documents'), dateiname(profilName, erstelltAm));
113 const auswahl = await (elternfenster === null
114 ? dialog.showSaveDialog(dialogOptionen(vorschlag))
115 : dialog.showSaveDialog(elternfenster, dialogOptionen(vorschlag)));
116
117 if (auswahl.canceled || auswahl.filePath === '') {
118 return { gespeichert: false, pfad: null, bytes: 0 };
119 }
120
121 /* Erst nach der Zusage des Nutzers wird gerechnet: Ein PDF zu erzeugen,
122 das niemand haben will, kostet nur Zeit. */
123 const pdf = await pdfErzeugen({ html, quelle });
124 /* Unteilbar geschrieben – siehe `pdfSchreiben`. Ein Abbruch mittendrin
125 hinterließ sonst eine halbe Datei unter dem endgültigen Namen. */
126 pdfSchreiben(auswahl.filePath, pdf);
127
128 return {
129 gespeichert: true,
130 pfad: auswahl.filePath,
131 bytes: pdf.byteLength,
132 /* Erst nach dem Schreiben gemerkt: Was nicht existiert, soll sich auch
133 nicht öffnen lassen. Ohne diese Kennung blieben „Datei öffnen“ und „Im
134 Ordner anzeigen“ unter dem Lernbericht unsichtbar – `Dateiknoepfe`
135 stand dort seit 0.22.0, bekam aber nie eine. Als einzige der vier
136 Ausgaben endete der Bericht damit bei einem Pfad zum Abschreiben. */
137 dateiKennung: geschriebenMerken(auswahl.filePath),
138 };
139 }
140
141 function dialogOptionen(vorschlag: string): Electron.SaveDialogOptions {
142 return {
143 title: 'Lernbericht speichern',
144 defaultPath: vorschlag,
145 buttonLabel: 'Speichern',
146 filters: [{ name: 'PDF-Dokument', extensions: ['pdf'] }],
147 /* Überschreiben nur nach Rückfrage, und ein neuer Ordner soll sich
148 anlegen lassen – sonst muss der Nutzer den Dialog verlassen, um Platz
149 für seine Datei zu schaffen. */
150 properties: ['showOverwriteConfirmation', 'createDirectory'],
151 };
152 }