waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app src main lernbericht-export.ts
| 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 | } |