/** * Der Weg vom Lernstand zur gespeicherten PDF-Datei. * * Getrennt von `druck.ts` (das nur aus HTML ein PDF macht) und von * `shared/druck/` (reine Textbausteine ohne Electron). Hier kommt zusammen, * was ohne Electron nicht geht: Daten holen, Datei anbieten, schreiben. * * **Warum der Kern die Zahlen selbst holt.** Die Oberfläche schickt nur, für * welches Profil und in welcher Schriftgröße. Reichte sie die Zahlen mit, * könnte ein Fehler in der Anzeige in ein Dokument gelangen, das jemand für * bare Münze nimmt – und das Dokument nennt eine Quelle, steht also für * seinen Inhalt gerade. */ import { join } from 'node:path'; import { app, dialog, type BrowserWindow } from 'electron'; import { type Quellenangabe } from '../shared/druck/dokument'; import { dateiname, lernberichtBauen } from '../shared/druck/lernbericht'; import type { Schriftgroesse } from '../shared/druck/stil'; import type { Druckergebnis } from '../shared/ipc'; import type { Katalog } from '../shared/katalog'; import { geschriebenMerken } from './dateizugriff'; import { pdfSchreiben } from './dokument-export'; import { pdfErzeugen } from './druck'; import type { Lernstand } from './lernstand'; import type { Pruefung } from './pruefung'; /** * Baut die Quellenangabe aus den Katalogmetadaten. * * Ohne Katalog gibt es keinen Export: Die Angabe ließe sich dann nicht * belegen, und ein Dokument mit erfundener oder fehlender Herkunft ist * schlechter als gar keines. */ export function quelleAusKatalog(katalog: Katalog, umfang: Quellenangabe['umfang']): Quellenangabe { const { quellenangabe, herausgeber, stand, quelle_url } = katalog.meta; if (quellenangabe.trim().length === 0) { throw new Error('Der Fragenkatalog nennt keine Quellenangabe – es wird nichts exportiert.'); } return { amtlich: quellenangabe, herausgeber, stand, quelleUrl: quelle_url, umfang }; } function schriftgroessePruefen(wert: unknown): Schriftgroesse { return wert === 'gross' ? 'gross' : 'normal'; } /** * Ein Zeitstempel, den Aufrufer setzen können. * * Ein fest verdrahtetes `new Date()` machte den Export untestbar: Das * Dokument trüge bei jedem Lauf ein anderes Datum, und ein Vergleich gegen * einen Sollwert wäre unmöglich. */ export type Zeitgeber = () => Date; export interface Exportumgebung { readonly lernstand: Lernstand; readonly pruefung: Pruefung; readonly katalog: Katalog; /** Fenster, an dem der Speicherdialog hängt – modal ist für Screenreader klarer. */ readonly elternfenster: BrowserWindow | null; readonly jetzt: Zeitgeber; } /** * Erzeugt den Lernbericht und speichert ihn dorthin, wohin der Nutzer zeigt. * * Ein Abbruch im Dialog ist kein Fehler: `gespeichert: false`. Alles andere – * fehlendes Schreibrecht, voller Datenträger – wirft, damit die Oberfläche es * sagen kann statt so zu tun, als sei alles gut gegangen. */ export async function lernberichtExportieren( umgebung: Exportumgebung, profilIdRoh: unknown, schriftgroesseRoh: unknown, ): Promise { const { lernstand, pruefung, katalog, elternfenster, jetzt } = umgebung; /* Die Prüfung der Profil-ID übernimmt der Lernstand – dieselben Regeln wie überall sonst, an einer Stelle. */ const uebersicht = lernstand.uebersicht(profilIdRoh); const verlauf = pruefung.verlauf(profilIdRoh); /* Der Lernplan rechnet über den gesamten Katalog. Scheitert er, soll der Bericht trotzdem entstehen – die übrigen Zahlen sind davon unberührt, und ein Bericht ohne Planabschnitt ist besser als keiner. */ let plan = null; try { plan = lernstand.lernplan(profilIdRoh); } catch (fehler: unknown) { console.warn('[druck] Lernplan nicht ermittelbar, Bericht entsteht ohne ihn:', fehler); } const profilId = typeof profilIdRoh === 'number' ? profilIdRoh : -1; const profil = lernstand.profile().find((p) => p.id === profilId); const profilName = profil?.name ?? 'Ohne Profil'; const erstelltAm = jetzt().toISOString(); const quelle = quelleAusKatalog(katalog, { art: 'kein amtlicher wortlaut' }); const html = lernberichtBauen({ uebersicht, plan, verlauf, profilName, quelle, erstelltAm, schriftgroesse: schriftgroessePruefen(schriftgroesseRoh), }); const vorschlag = join(app.getPath('documents'), dateiname(profilName, erstelltAm)); const auswahl = await (elternfenster === null ? dialog.showSaveDialog(dialogOptionen(vorschlag)) : dialog.showSaveDialog(elternfenster, dialogOptionen(vorschlag))); if (auswahl.canceled || auswahl.filePath === '') { return { gespeichert: false, pfad: null, bytes: 0 }; } /* Erst nach der Zusage des Nutzers wird gerechnet: Ein PDF zu erzeugen, das niemand haben will, kostet nur Zeit. */ const pdf = await pdfErzeugen({ html, quelle }); /* Unteilbar geschrieben – siehe `pdfSchreiben`. Ein Abbruch mittendrin hinterließ sonst eine halbe Datei unter dem endgültigen Namen. */ pdfSchreiben(auswahl.filePath, pdf); return { gespeichert: true, pfad: auswahl.filePath, bytes: pdf.byteLength, /* Erst nach dem Schreiben gemerkt: Was nicht existiert, soll sich auch nicht öffnen lassen. Ohne diese Kennung blieben „Datei öffnen“ und „Im Ordner anzeigen“ unter dem Lernbericht unsichtbar – `Dateiknoepfe` stand dort seit 0.22.0, bekam aber nie eine. Als einzige der vier Ausgaben endete der Bericht damit bei einem Pfad zum Abschreiben. */ dateiKennung: geschriebenMerken(auswahl.filePath), }; } function dialogOptionen(vorschlag: string): Electron.SaveDialogOptions { return { title: 'Lernbericht speichern', defaultPath: vorschlag, buttonLabel: 'Speichern', filters: [{ name: 'PDF-Dokument', extensions: ['pdf'] }], /* Überschreiben nur nach Rückfrage, und ein neuer Ordner soll sich anlegen lassen – sonst muss der Nutzer den Dialog verlassen, um Platz für seine Datei zu schaffen. */ properties: ['showOverwriteConfirmation', 'createDirectory'], }; }