waffensachkunde

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

/ app src main sicherung-dialoge.ts

24,6 KB Rohdatei
app/src/main/sicherung-dialoge.ts — 671 Zeilen
1 /**
2 * Sicherung anlegen und einspielen – der Ablauf mit Dialogen.
3 *
4 * Die reine Datei- und Datenbankarbeit steht in `sicherung.ts` und ist dort
5 * ohne Electron prüfbar. Hier steht, in welcher **Reihenfolge** sie geschieht,
6 * und die Reihenfolge ist beim Einspielen der ganze Schutz.
7 *
8 * ## Warum das Einspielen zwei Kanäle braucht
9 *
10 * Zwischen „diese Datei ist brauchbar, sie enthält Folgendes“ und „ja, ersetze
11 * meinen Lernstand damit“ liegt eine Entscheidung des Nutzers. Ein einziger
12 * Kanal müsste sie im Hauptprozess abwarten – und träfe sie damit im selben
13 * Aufruf, in dem er schon schreibt. Getrennt ist der Prüfschritt folgenlos:
14 * Bis der zweite Kanal gerufen wird, ist nichts angefasst.
15 *
16 * ## Die gefährlichste Einzelheit
17 *
18 * Ein verwaistes `lernstand.db-wal` neben einer frisch eingesetzten Datenbank
19 * ist der einzige gemessene Weg, in diesem Vorgang wirklich Daten zu
20 * vernichten: SQLite spielt das alte Schreibprotokoll auf die neue Datei, der
21 * eingespielte Stand ist danach **vollständig fort**, und `integrity_check`
22 * meldet „ok“. Kein Dateikopf und keine Prüfsumme schützen davor – nur die
23 * Reihenfolge in {@link einspielen}: erst schliessen, dann sicherstellen, dass
24 * die Nebendateien wirklich weg sind, und nur dann ersetzen.
25 */
26
27 import { existsSync, copyFileSync, renameSync, readdirSync, statSync, unlinkSync } from 'node:fs';
28 import { basename, join, resolve } from 'node:path';
29
30 import { app, dialog, type BrowserWindow } from 'electron';
31
32 import { geschriebenMerken } from './dateizugriff';
33 import type BetterSqlite3 from 'better-sqlite3';
34
35 import { anzeigegroesseSetzen } from './anzeige';
36 import { einstellungenLesen, einstellungenSchreiben } from './einstellungen';
37 import { laeuftPruefung } from './laufwaechter';
38 import { lernstandInstanz, lernstandSchliessen, type Lernstand } from './lernstand';
39 import { profilUebernehmen } from './profil-uebernehmen';
40 import { pruefungZuruecksetzen } from './pruefung';
41 import {
42 aufraeumen,
43 dateiPruefen,
44 einstellungenAusSicherung,
45 HOECHSTGROESSE,
46 kennzahlenLesen,
47 megabyte,
48 nebendateienAufraeumen,
49 reisendeEinstellungen,
50 sicherungsDateiname,
51 sicherungSchreiben,
52 type DatenbankKonstruktor,
53 } from './sicherung';
54 import type {
55 Einspielergebnis,
56 Kennzahlen,
57 Pruefergebnis,
58 Sicherungsergebnis,
59 Uebernahmeergebnis,
60 } from '../shared/sicherung';
61 import type { Katalog } from '../shared/katalog';
62
63 /** Arbeitskopie der gewählten Datei, während sie geprüft wird. */
64 const PRUEFKOPIE = 'einspielen-pruefkopie.db';
65
66 /** Vorsatz der selbsttätigen Sicherheitskopie vor dem Einspielen. */
67 const VOR_DEM_EINSPIELEN = 'Lernstand-vor-dem-Einspielen';
68
69 /** So viele Sicherheitskopien bleiben liegen. */
70 const KOPIEN_BEHALTEN = 3;
71
72 /** Nach dieser Zeit verfällt eine geprüfte Datei und muss neu gewählt werden. */
73 const KENNUNG_GUELTIG_MS = 10 * 60 * 1000;
74
75 interface OffenerVorgang {
76 readonly kennung: string;
77 readonly arbeitskopie: string;
78 readonly groesse: number;
79 readonly mtimeMs: number;
80 readonly bis: number;
81 /**
82 * Die Profilnummern in der Datei, in derselben Reihenfolge wie
83 * `Kennzahlen.jeProfil`.
84 *
85 * Der Renderer wählt über den **Index** in diese Liste, nie über eine
86 * Nummer. Dieselbe Überlegung wie beim Pfad: Wer eine Nummer mitbringen
87 * darf, darf auch eine andere mitbringen – und in einer fremden Datei
88 * führte sie geradewegs in das Profil einer anderen Person.
89 */
90 readonly profilIds: readonly number[];
91 }
92
93 /*
94 Genau ein Vorgang zur Zeit. Der Renderer bekommt nur die Kennung und den
95 blossen Dateinamen, nie einen Pfad – dieselbe Überlegung wie beim
96 unterbrochenen Prüfungsbogen: Wer einen Pfad mitbringen darf, darf auch
97 einen anderen mitbringen.
98 */
99 let offen: OffenerVorgang | null = null;
100
101 export interface Umgebung {
102 readonly lernstand: () => Lernstand;
103 readonly katalog: () => Katalog;
104 readonly Datenbank: DatenbankKonstruktor;
105 readonly jetzt?: () => Date;
106 }
107
108 function userData(): string {
109 return app.getPath('userData');
110 }
111
112 function lernstandPfad(): string {
113 return join(userData(), 'lernstand.db');
114 }
115
116 /** Gleicher Pfad? Unter Windows entscheidet die Schreibweise nicht mit. */
117 function selbeDatei(a: string, b: string): boolean {
118 const norm = (pfad: string): string =>
119 process.platform === 'win32' ? resolve(pfad).toLowerCase() : resolve(pfad);
120 return norm(a) === norm(b);
121 }
122
123 // ── Sicherung anlegen ──────────────────────────────────────────────────
124
125 export async function sicherungAnlegen(
126 elternfenster: BrowserWindow | null,
127 umgebung: Umgebung,
128 ): Promise<Sicherungsergebnis> {
129 const jetzt = umgebung.jetzt?.() ?? new Date();
130 const vorschlag = join(app.getPath('documents'), sicherungsDateiname(jetzt));
131
132 const optionen: Electron.SaveDialogOptions = {
133 title: 'Lernstand sichern',
134 defaultPath: vorschlag,
135 buttonLabel: 'Sichern',
136 filters: [{ name: 'Lernstand-Sicherung', extensions: ['wsklernstand'] }],
137 properties: ['showOverwriteConfirmation', 'createDirectory'],
138 };
139 const auswahl = await (elternfenster === null
140 ? dialog.showSaveDialog(optionen)
141 : dialog.showSaveDialog(elternfenster, optionen));
142
143 if (auswahl.canceled || auswahl.filePath === '') {
144 return { gespeichert: false, pfad: null, bytes: 0 };
145 }
146
147 /* Auf die laufende Datenbank zu sichern hiesse, sie mit sich selbst zu
148 überschreiben. Der Systemdialog kennt diesen Fall nicht. */
149 if (selbeDatei(auswahl.filePath, lernstandPfad())) {
150 throw new Error(
151 'Dorthin lässt sich nicht sichern: Das ist der laufende Lernstand selbst. ' +
152 'Bitte wählen Sie einen anderen Ort.',
153 );
154 }
155
156 /*
157 Die alte Sicherung bleibt liegen, bis die neue vollständig dasteht.
158
159 Bis 0.24.1 stand hier `aufraeumen(auswahl.filePath)` mit der Begründung,
160 `VACUUM INTO` weigere sich, ein vorhandenes Ziel zu überschreiben. Die
161 Begründung trägt nicht: `sicherungSchreiben` schreibt nach `<ziel>.teil`,
162 prüft die fertige Datei und schiebt sie erst dann mit `renameSync` an
163 ihren Platz – und `renameSync` ersetzt ein vorhandenes Ziel unteilbar
164 (nachgemessen). Das Löschen davor war also nicht nötig, aber zerstörend:
165 Scheiterte danach irgendetwas – voller Datenträger, abgezogener
166 USB-Stick, eine Gegenprobe, die die frische Datei ablehnt –, stand der
167 Nutzer ohne die alte UND ohne die neue Sicherung da. Die Zustimmung im
168 Systemdialog gilt dem Überschreiben, nicht dem Verlust von beidem.
169
170 Weggeräumt werden nur die Nebendateien: Ein verwaistes Schreibprotokoll
171 neben der neuen Datei ist der einzige gemessene Weg, in diesem Vorgang
172 wirklich Daten zu vernichten (siehe Modulkopf von `main/sicherung.ts`).
173 Sie gehören nicht zur Sicherung und tragen nichts, was verloren gehen
174 könnte.
175 */
176 nebendateienAufraeumen(auswahl.filePath);
177
178 /* Die Einstellungen reisen mit. Wer 400 Prozent Anzeigegröße oder hohen
179 Kontrast braucht, musste bis 0.26.7 auf dem zweiten Rechner ohne sie
180 anfangen – um sie einzustellen. Welche mitreisen und warum nicht alle,
181 steht bei EINSTELLUNGEN_REISEN. */
182 const bytes = sicherungSchreiben(
183 umgebung.lernstand().datenbank,
184 auswahl.filePath,
185 umgebung.Datenbank,
186 reisendeEinstellungen(einstellungenLesen()),
187 );
188
189 /* Vermerken, wann zuletzt gesichert wurde – sonst kann die Karte nicht
190 sagen, ob es überhaupt je geschah, und eine Erinnerung wäre geraten
191 statt gewusst. Das Vermerken darf den Vorgang nicht zu Fall bringen:
192 Die Sicherung liegt zu diesem Zeitpunkt bereits vollständig da. */
193 try {
194 einstellungenSchreiben({ letzteSicherung: jetzt.toISOString() });
195 } catch (fehler: unknown) {
196 console.warn('[sicherung] Zeitpunkt konnte nicht vermerkt werden:', fehler);
197 }
198
199 return {
200 gespeichert: true,
201 pfad: auswahl.filePath,
202 bytes,
203 dateiKennung: geschriebenMerken(auswahl.filePath),
204 };
205 }
206
207 // ── Einspielen, erste Hälfte: prüfen ───────────────────────────────────
208
209 export async function sicherungPruefen(
210 elternfenster: BrowserWindow | null,
211 umgebung: Umgebung,
212 ): Promise<Pruefergebnis> {
213 restePruefen();
214
215 /* Vor jedem Dialog: Läuft ein Prüfungsbogen, gibt es eine Antwort und keinen
216 gesperrten Knopf. */
217 if (elternfenster !== null && laeuftPruefung(elternfenster)) {
218 return {
219 art: 'abgelehnt',
220 grund:
221 'Es läuft gerade eine Prüfungssimulation. Bitte beenden Sie sie zuerst – ' +
222 'ein Wechsel des Lernstands mittendrin verlöre den angefangenen Bogen. ' +
223 'Es wurde nichts verändert.',
224 };
225 }
226
227 const optionen: Electron.OpenDialogOptions = {
228 title: 'Sicherung auswählen',
229 buttonLabel: 'Ansehen',
230 properties: ['openFile'],
231 filters: [
232 { name: 'Lernstand-Sicherung', extensions: ['wsklernstand'] },
233 /* „Alle Dateien“ steht bewusst daneben: Mailprogramme und
234 Downloadordner benennen Anhänge um. Über die Brauchbarkeit
235 entscheidet der Inhalt, nie die Endung. */
236 { name: 'Alle Dateien', extensions: ['*'] },
237 ],
238 };
239 const auswahl = await (elternfenster === null
240 ? dialog.showOpenDialog(optionen)
241 : dialog.showOpenDialog(elternfenster, optionen));
242
243 const gewaehlt = auswahl.filePaths[0];
244 if (auswahl.canceled || gewaehlt === undefined) {
245 /* Ein Abbruch ist eine Entscheidung, kein Ereignis – wie beim
246 Lernbericht gibt es dazu keine Meldung. */
247 return { art: 'abgebrochen' };
248 }
249
250 if (selbeDatei(gewaehlt, lernstandPfad())) {
251 return {
252 art: 'abgelehnt',
253 grund: 'Das ist der laufende Lernstand selbst, keine Sicherung. Es wurde nichts verändert.',
254 };
255 }
256
257 /*
258 Ab hier wird ausschliesslich auf einer Arbeitskopie im eigenen Verzeichnis
259 gearbeitet. Zwei Gründe: Jedes Öffnen einer WAL-Datenbank legt `-wal` und
260 `-shm` daneben und lässt sie liegen – auf dem USB-Stick des Nutzers. Und
261 die geprüften Bytes sind dann genau die eingesetzten; die gewählte Datei
262 wird nie geöffnet, nur gelesen.
263 */
264 /*
265 Erst die Größe ansehen, dann kopieren.
266
267 Die Prüfkette in `dateiPruefen` lehnt alles über einem halben Gigabyte ab –
268 aber sie sieht die **Arbeitskopie**, also erst, nachdem die Datei
269 vollständig ins Programmverzeichnis geschrieben wurde. Wer aus Versehen
270 einen Film auswählt, wartete bis 0.24.1 auf eine Kopie von mehreren
271 Gigabyte, um danach zu lesen, dass die Datei zu groß ist – und die Kopie
272 lag anschließend im Benutzerverzeichnis, bis der nächste Prüflauf sie
273 überschrieb. Dieselbe Grenze, nur an der richtigen Stelle.
274 */
275 let quellgroesse: number;
276 try {
277 quellgroesse = statSync(gewaehlt).size;
278 } catch {
279 return {
280 art: 'abgelehnt',
281 grund: 'Diese Datei lässt sich nicht lesen. Es wurde nichts verändert.',
282 };
283 }
284 if (quellgroesse > HOECHSTGROESSE) {
285 return {
286 art: 'abgelehnt',
287 grund:
288 `Diese Datei ist ${megabyte(quellgroesse)} groß und kann kein Lernstand sein – ` +
289 'ein Lernstand ist wenige Megabyte groß. Es wurde nichts verändert.',
290 };
291 }
292
293 const arbeitskopie = join(userData(), PRUEFKOPIE);
294 aufraeumen(arbeitskopie);
295 try {
296 copyFileSync(gewaehlt, arbeitskopie);
297 } catch {
298 return {
299 art: 'abgelehnt',
300 grund: 'Diese Datei lässt sich nicht lesen. Es wurde nichts verändert.',
301 };
302 }
303
304 const befund = dateiPruefen(arbeitskopie, umgebung.Datenbank);
305 if (befund.art === 'abgelehnt') {
306 aufraeumen(arbeitskopie);
307 return { art: 'abgelehnt', grund: befund.grund };
308 }
309
310 const stand = statSync(arbeitskopie);
311 const vorgang: OffenerVorgang = {
312 kennung: `${String(Date.now())}-${String(Math.floor(Math.random() * 1e9))}`,
313 arbeitskopie,
314 groesse: stand.size,
315 mtimeMs: stand.mtimeMs,
316 bis: Date.now() + KENNUNG_GUELTIG_MS,
317 profilIds: befund.profilIds,
318 };
319 offen = vorgang;
320
321 return {
322 art: 'brauchbar',
323 vorgang: vorgang.kennung,
324 dateiname: basename(gewaehlt),
325 ausDatei: befund.kennzahlen,
326 jetzt: kennzahlenDesLaufenden(umgebung),
327 };
328 }
329
330 // ── Einspielen, zweite Hälfte: vollziehen ──────────────────────────────
331
332 /**
333 * Ersetzt den Lernstand.
334 *
335 * **Ab der Sicherheitskopie läuft alles ohne ein einziges `await`.** Der
336 * Hauptprozess ist einfädig; solange nichts abgewartet wird, kann sich kein
337 * anderer Kanal dazwischenschieben und über `lernstand()` die Datei in genau
338 * dem Augenblick wieder öffnen, in dem sie ersetzt wird.
339 */
340 /**
341 * Nimmt ein Profil aus der geprüften Datei dazu – ohne zu ersetzen.
342 *
343 * Dieselben Wachen wie beim Ersetzen: Token einlösen (Wegwerfstück), Grösse
344 * und mtime der Arbeitskopie gegenprüfen, laufende Prüfungssimulation
345 * abweisen. Der eine Unterschied ist der wichtigste: Es wird **in die lebende
346 * Datenbank geschrieben**, nicht getauscht. Ein Rückweg über das
347 * Zurücktauschen einer Datei gibt es hier nicht – nur die Sicherheitskopie,
348 * die `profilUebernehmen` unter eigenem Vorsatz anlegt.
349 *
350 * Ohne `await` wie das Ersetzen: Der Hauptprozess ist einfädig, und solange
351 * nichts abgewartet wird, kann sich kein zweiter IPC-Kanal dazwischenschieben
352 * und in derselben Datenbank schreiben.
353 */
354 export function sicherungUebernehmen(
355 vorgangRoh: unknown,
356 profilIndexRoh: unknown,
357 elternfenster: BrowserWindow | null,
358 umgebung: Umgebung,
359 ): Uebernahmeergebnis {
360 const merkposten = offen;
361 offen = null;
362
363 if (
364 merkposten === null ||
365 typeof vorgangRoh !== 'string' ||
366 vorgangRoh !== merkposten.kennung ||
367 Date.now() > merkposten.bis ||
368 !existsSync(merkposten.arbeitskopie)
369 ) {
370 return { art: 'abgelaufen' };
371 }
372
373 const stand = statSync(merkposten.arbeitskopie);
374 if (stand.size !== merkposten.groesse || stand.mtimeMs !== merkposten.mtimeMs) {
375 aufraeumen(merkposten.arbeitskopie);
376 return { art: 'abgelaufen' };
377 }
378
379 if (elternfenster !== null && laeuftPruefung(elternfenster)) {
380 aufraeumen(merkposten.arbeitskopie);
381 return {
382 art: 'gescheitert',
383 grund: 'Es läuft inzwischen eine Prüfungssimulation. Es wurde nichts verändert.',
384 };
385 }
386
387 /* Der Renderer nennt einen Index in die Liste, die der Hauptprozess selbst
388 festgehalten hat – nie eine Profilnummer. */
389 if (
390 typeof profilIndexRoh !== 'number' ||
391 !Number.isInteger(profilIndexRoh) ||
392 profilIndexRoh < 0 ||
393 profilIndexRoh >= merkposten.profilIds.length
394 ) {
395 aufraeumen(merkposten.arbeitskopie);
396 return { art: 'gescheitert', grund: 'Dieses Profil steht nicht in der Datei.' };
397 }
398
399 const quellProfilId = merkposten.profilIds[profilIndexRoh] ?? -1;
400
401 try {
402 return profilUebernehmen({
403 ziel: umgebung.lernstand().datenbank,
404 quelle: merkposten.arbeitskopie,
405 quellProfilId,
406 katalogstand: umgebung.katalog().meta.stand,
407 ordner: userData(),
408 jetzt: umgebung.jetzt?.() ?? new Date(),
409 Datenbank: umgebung.Datenbank,
410 });
411 } finally {
412 aufraeumen(merkposten.arbeitskopie);
413 }
414 }
415
416 export function sicherungEinspielen(
417 vorgang: unknown,
418 elternfenster: BrowserWindow | null,
419 umgebung: Umgebung,
420 ): Einspielergebnis {
421 const merkposten = offen;
422 offen = null;
423
424 if (
425 merkposten === null ||
426 typeof vorgang !== 'string' ||
427 vorgang !== merkposten.kennung ||
428 Date.now() > merkposten.bis ||
429 !existsSync(merkposten.arbeitskopie)
430 ) {
431 return { art: 'abgelaufen' };
432 }
433
434 /* Die Arbeitskopie muss noch dieselbe sein, die geprüft wurde. */
435 const stand = statSync(merkposten.arbeitskopie);
436 if (stand.size !== merkposten.groesse || stand.mtimeMs !== merkposten.mtimeMs) {
437 aufraeumen(merkposten.arbeitskopie);
438 return { art: 'abgelaufen' };
439 }
440
441 /* Ein zweites Mal: Zwischen Rückfrage und Zustimmung liegt Zeit. */
442 if (elternfenster !== null && laeuftPruefung(elternfenster)) {
443 aufraeumen(merkposten.arbeitskopie);
444 return {
445 art: 'gescheitert',
446 grund: 'Es läuft inzwischen eine Prüfungssimulation. Es wurde nichts verändert.',
447 };
448 }
449
450 const ziel = lernstandPfad();
451 const jetzt = umgebung.jetzt?.() ?? new Date();
452
453 // ── Sicherheitskopie. Ohne sie wird nicht gesprungen. ────────────────
454 let sicherheitskopie: string;
455 try {
456 sicherheitskopie = freierName(userData(), jetzt);
457 sicherungSchreiben(
458 umgebung.lernstand().datenbank,
459 sicherheitskopie,
460 umgebung.Datenbank,
461 /* Auch die Sicherheitskopie trägt sie. Sonst wäre der Rückweg nach
462 einem missglückten Einspielen der eine Weg, auf dem Einstellungen
463 doch noch verloren gingen. */
464 reisendeEinstellungen(einstellungenLesen()),
465 );
466 } catch (fehler) {
467 aufraeumen(merkposten.arbeitskopie);
468 return {
469 art: 'gescheitert',
470 grund:
471 'Es ließ sich keine Sicherung Ihres jetzigen Lernstands anlegen, deshalb wurde ' +
472 `nichts eingelesen. Möglicherweise ist der Speicherplatz erschöpft. (${fehlertext(fehler)}) ` +
473 'Es wurde nichts verändert.',
474 };
475 }
476
477 // ── Schliessen, Nebendateien beseitigen, ersetzen ────────────────────
478 lernstandSchliessen();
479 pruefungZuruecksetzen();
480
481 /*
482 Nach einem sauberen `close()` sind `-wal` und `-shm` weg. Liegen sie doch
483 noch da, hält ein fremdes Handle die Datei. Löschen gelingt dann unter
484 Windows nicht – und weitermachen wäre der eine Weg, auf dem wirklich
485 Daten verschwinden: SQLite spielte das alte Schreibprotokoll auf die neue
486 Datenbank, der eingespielte Stand wäre fort, und `integrity_check` meldete
487 „ok“.
488 */
489 for (const anhang of ['-wal', '-shm']) {
490 const nebendatei = `${ziel}${anhang}`;
491 if (!existsSync(nebendatei)) {
492 continue;
493 }
494 try {
495 unlinkSync(nebendatei);
496 } catch {
497 lernstandInstanz(ziel, umgebung.katalog());
498 return {
499 art: 'gescheitert',
500 grund:
501 'Der Lernstand wird gerade von einem anderen Programm benutzt und ließ sich nicht ' +
502 'ersetzen. Bitte schließen Sie andere Fenster dieser Anwendung und versuchen Sie es ' +
503 `erneut. Ihr Lernstand ist unverändert; eine Sicherung liegt unter ${basename(sicherheitskopie)}.`,
504 };
505 }
506 }
507
508 try {
509 renameSync(merkposten.arbeitskopie, ziel);
510 } catch (fehler) {
511 lernstandInstanz(ziel, umgebung.katalog());
512 return {
513 art: 'gescheitert',
514 grund:
515 `Die Datei ließ sich nicht an die Stelle des Lernstands setzen (${fehlertext(fehler)}). ` +
516 'Ihr Lernstand ist unverändert.',
517 };
518 }
519
520 // ── Öffnen, migrieren, Probe ─────────────────────────────────────────
521 try {
522 const neuer = lernstandInstanz(ziel, umgebung.katalog());
523 const profile = neuer.profile();
524 const erstes = profile[0];
525 if (erstes === undefined) {
526 throw new Error('Die eingespielte Datei enthält kein Profil.');
527 }
528 /* Eine Datei, die sich öffnen lässt, aber bei der ersten echten Abfrage
529 umfällt, ist kein Erfolg. Gefragt wird **jedes** Profil: Die Probe soll
530 die ganze Datei treffen, nicht ihren ersten Eintrag.
531
532 Und die Zahl gehört über alle Profile gebildet. Bis Fassung 0.24.1 nahm
533 sie nur das erste; nach dem Einspielen einer Datei mit drei Profilen
534 stand da „3 Profile, 24 verschiedene Fragen bearbeitet“, obwohl es die
535 24 des ersten waren. Wer die Zahl mit der Gegenüberstellung von vorhin
536 verglich, fand einen Widerspruch, den es nicht gab. */
537 const bearbeiteteFragen = profile.reduce(
538 (summe, profil) => summe + neuer.uebersicht(profil.id).beantwortet,
539 0,
540 );
541 kopienAufraeumen(userData());
542
543 return {
544 art: 'eingespielt',
545 einstellungenUebernommen: einstellungenUebernehmen(neuer.datenbank),
546 profilzahl: profile.length,
547 ersteProfilId: erstes.id,
548 bearbeiteteFragen,
549 sicherheitskopie: basename(sicherheitskopie),
550 };
551 } catch (fehler) {
552 /* Rückweg – und zwar unteilbar wie der Hinweg: erst nach `.teil`, dann
553 umbenennen. Ein Rückweg, der auf halbem Weg abbricht, wäre die
554 schlimmste aller Lagen. */
555 lernstandSchliessen();
556 for (const anhang of ['', '-wal', '-shm']) {
557 aufraeumen(`${ziel}${anhang}`);
558 }
559 try {
560 copyFileSync(sicherheitskopie, `${ziel}.teil`);
561 renameSync(`${ziel}.teil`, ziel);
562 lernstandInstanz(ziel, umgebung.katalog());
563 } catch {
564 /* Auch der Rückweg misslang. Die Sicherheitskopie liegt und wird
565 benannt – von Hand ist sie einspielbar. */
566 }
567 return {
568 art: 'gescheitert',
569 grund:
570 `Die Datei ließ sich nicht öffnen (${fehlertext(fehler)}). Ihr bisheriger Lernstand ` +
571 `wurde wiederhergestellt. Eine Sicherung davon liegt unter ${basename(sicherheitskopie)}.`,
572 };
573 }
574 }
575
576 // ── Hilfen ─────────────────────────────────────────────────────────────
577 /**
578 * Übernimmt die mitgereisten Einstellungen aus einer eingespielten Datei.
579 *
580 * @returns `true`, wenn welche übernommen wurden.
581 *
582 * **Warum das Scheitern hier nichts umwirft.** Der Lernstand ist an dieser
583 * Stelle bereits eingespielt und geprüft. Eine Einstellung, die sich nicht
584 * setzen lässt, darf diesen Erfolg nicht in einen Fehlschlag verwandeln – der
585 * Nutzer stünde sonst vor „gescheitert“, obwohl seine Daten da sind.
586 *
587 * **Warum die Anzeigegröße einen eigenen Weg geht.** Sie hängt nicht nur in
588 * der Einstellungsdatei, sondern als Zoomfaktor an den `webContents` jedes
589 * Fensters und in einem Merkposten in `anzeige.ts`. Bloßes Schreiben änderte
590 * die Datei und nicht das Bild. `anzeigegroesseSetzen` macht beides und meldet
591 * es den Fenstern.
592 */
593 function einstellungenUebernehmen(db: BetterSqlite3.Database): boolean {
594 try {
595 const mitgereist = einstellungenAusSicherung(db);
596 if (mitgereist === null) return false;
597
598 const { anzeigegroesse, ...uebrige } = mitgereist;
599 if (Object.keys(uebrige).length > 0) einstellungenSchreiben(uebrige);
600 if (anzeigegroesse !== undefined) anzeigegroesseSetzen(anzeigegroesse);
601 return true;
602 } catch (fehler: unknown) {
603 console.warn('[sicherung] Einstellungen konnten nicht übernommen werden:', fehler);
604 return false;
605 }
606 }
607
608 function kennzahlenDesLaufenden(umgebung: Umgebung): Kennzahlen {
609 const db = umgebung.lernstand().datenbank;
610 const tabellen = new Set(
611 db
612 .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
613 .all()
614 .map((zeile) => zeile.name),
615 );
616 const fassung =
617 db
618 .prepare<[], { version: number | null }>('SELECT MAX(version) AS version FROM schema_version')
619 .get()?.version ?? 0;
620 /* Dieselbe Funktion wie für die Datei – zwei Zählweisen nebeneinander
621 wären zwei Wahrheiten in derselben Rückfrage. */
622 return kennzahlenLesen(db, fassung, tabellen);
623 }
624
625 /**
626 * Ein freier Name für die Sicherheitskopie.
627 *
628 * Sekundengenau, und trotzdem mit Zähler: `VACUUM INTO` weigert sich, ein
629 * vorhandenes Ziel zu überschreiben, und diese Kopie entsteht ohne Rückfrage.
630 * Zweimal in derselben Sekunde ist unwahrscheinlich – aber „unwahrscheinlich“
631 * ist bei einer Sicherheitskopie das falsche Wort.
632 */
633 function freierName(ordner: string, jetzt: Date): string {
634 const grund = join(ordner, sicherungsDateiname(jetzt, VOR_DEM_EINSPIELEN));
635 if (!existsSync(grund)) {
636 return grund;
637 }
638 for (let zaehler = 2; zaehler < 100; zaehler++) {
639 const kandidat = grund.replace(/\.wsklernstand$/u, `-${String(zaehler)}.wsklernstand`);
640 if (!existsSync(kandidat)) {
641 return kandidat;
642 }
643 }
644 throw new Error('Es ließ sich kein freier Name für die Sicherheitskopie finden.');
645 }
646
647 /** Nur die jüngsten Sicherheitskopien bleiben; ausschliesslich dieses Muster. */
648 function kopienAufraeumen(ordner: string): void {
649 try {
650 const kopien = readdirSync(ordner)
651 .filter((name) => name.startsWith(`${VOR_DEM_EINSPIELEN}-`) && name.endsWith('.wsklernstand'))
652 .map((name) => ({ name, zeit: statSync(join(ordner, name)).mtimeMs }))
653 .sort((a, b) => b.zeit - a.zeit);
654 for (const alt of kopien.slice(KOPIEN_BEHALTEN)) {
655 aufraeumen(join(ordner, alt.name));
656 }
657 } catch {
658 /* Aufräumen ist Komfort. Misslingt es, bleiben ein paar Dateien mehr
659 liegen – das ist kein Grund, einen erfolgreichen Vorgang zu melden. */
660 }
661 }
662
663 /** Reste eines abgebrochenen Vorgangs, bevor ein neuer beginnt. */
664 function restePruefen(): void {
665 offen = null;
666 aufraeumen(join(userData(), PRUEFKOPIE));
667 }
668
669 function fehlertext(fehler: unknown): string {
670 return fehler instanceof Error ? fehler.message : String(fehler);
671 }