waffensachkunde

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

/ app src main sicherung-dialoge.ts

24,5 KB Rohdatei
app/src/main/sicherung-dialoge.ts — 668 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 * Nimmt ein Profil aus der geprüften Datei dazu – ohne zu ersetzen.
334 *
335 * Dieselben Wachen wie beim Ersetzen: Token einlösen (Wegwerfstück), Grösse
336 * und mtime der Arbeitskopie gegenprüfen, laufende Prüfungssimulation
337 * abweisen. Der eine Unterschied ist der wichtigste: Es wird **in die lebende
338 * Datenbank geschrieben**, nicht getauscht. Ein Rückweg über das
339 * Zurücktauschen einer Datei gibt es hier nicht – nur die Sicherheitskopie,
340 * die `profilUebernehmen` unter eigenem Vorsatz anlegt.
341 *
342 * Ohne `await` wie das Ersetzen: Der Hauptprozess ist einfädig, und solange
343 * nichts abgewartet wird, kann sich kein zweiter IPC-Kanal dazwischenschieben
344 * und in derselben Datenbank schreiben.
345 */
346 export function sicherungUebernehmen(
347 vorgangRoh: unknown,
348 profilIndexRoh: unknown,
349 elternfenster: BrowserWindow | null,
350 umgebung: Umgebung,
351 ): Uebernahmeergebnis {
352 const merkposten = offen;
353 offen = null;
354
355 if (
356 merkposten === null ||
357 typeof vorgangRoh !== 'string' ||
358 vorgangRoh !== merkposten.kennung ||
359 Date.now() > merkposten.bis ||
360 !existsSync(merkposten.arbeitskopie)
361 ) {
362 return { art: 'abgelaufen' };
363 }
364
365 const stand = statSync(merkposten.arbeitskopie);
366 if (stand.size !== merkposten.groesse || stand.mtimeMs !== merkposten.mtimeMs) {
367 aufraeumen(merkposten.arbeitskopie);
368 return { art: 'abgelaufen' };
369 }
370
371 if (elternfenster !== null && laeuftPruefung(elternfenster)) {
372 aufraeumen(merkposten.arbeitskopie);
373 return {
374 art: 'gescheitert',
375 grund: 'Es läuft inzwischen eine Prüfungssimulation. Es wurde nichts verändert.',
376 };
377 }
378
379 /* Der Renderer nennt einen Index in die Liste, die der Hauptprozess selbst
380 festgehalten hat – nie eine Profilnummer. */
381 if (
382 typeof profilIndexRoh !== 'number' ||
383 !Number.isInteger(profilIndexRoh) ||
384 profilIndexRoh < 0 ||
385 profilIndexRoh >= merkposten.profilIds.length
386 ) {
387 aufraeumen(merkposten.arbeitskopie);
388 return { art: 'gescheitert', grund: 'Dieses Profil steht nicht in der Datei.' };
389 }
390
391 const quellProfilId = merkposten.profilIds[profilIndexRoh] ?? -1;
392
393 try {
394 return profilUebernehmen({
395 ziel: umgebung.lernstand().datenbank,
396 quelle: merkposten.arbeitskopie,
397 quellProfilId,
398 katalogstand: umgebung.katalog().meta.stand,
399 ordner: userData(),
400 jetzt: umgebung.jetzt?.() ?? new Date(),
401 Datenbank: umgebung.Datenbank,
402 });
403 } finally {
404 aufraeumen(merkposten.arbeitskopie);
405 }
406 }
407
408 export function sicherungEinspielen(
409 vorgang: unknown,
410 elternfenster: BrowserWindow | null,
411 umgebung: Umgebung,
412 ): Einspielergebnis {
413 const merkposten = offen;
414 offen = null;
415
416 if (
417 merkposten === null ||
418 typeof vorgang !== 'string' ||
419 vorgang !== merkposten.kennung ||
420 Date.now() > merkposten.bis ||
421 !existsSync(merkposten.arbeitskopie)
422 ) {
423 return { art: 'abgelaufen' };
424 }
425
426 /* Die Arbeitskopie muss noch dieselbe sein, die geprüft wurde. */
427 const stand = statSync(merkposten.arbeitskopie);
428 if (stand.size !== merkposten.groesse || stand.mtimeMs !== merkposten.mtimeMs) {
429 aufraeumen(merkposten.arbeitskopie);
430 return { art: 'abgelaufen' };
431 }
432
433 /* Ein zweites Mal: Zwischen Rückfrage und Zustimmung liegt Zeit. */
434 if (elternfenster !== null && laeuftPruefung(elternfenster)) {
435 aufraeumen(merkposten.arbeitskopie);
436 return {
437 art: 'gescheitert',
438 grund: 'Es läuft inzwischen eine Prüfungssimulation. Es wurde nichts verändert.',
439 };
440 }
441
442 const ziel = lernstandPfad();
443 const jetzt = umgebung.jetzt?.() ?? new Date();
444
445 // ── Sicherheitskopie. Ohne sie wird nicht gesprungen. ────────────────
446 let sicherheitskopie: string;
447 try {
448 sicherheitskopie = freierName(userData(), jetzt);
449 sicherungSchreiben(
450 umgebung.lernstand().datenbank,
451 sicherheitskopie,
452 umgebung.Datenbank,
453 /* Auch die Sicherheitskopie trägt sie. Sonst wäre der Rückweg nach
454 einem missglückten Einspielen der eine Weg, auf dem Einstellungen
455 doch noch verloren gingen. */
456 reisendeEinstellungen(einstellungenLesen()),
457 );
458 } catch (fehler) {
459 aufraeumen(merkposten.arbeitskopie);
460 return {
461 art: 'gescheitert',
462 grund:
463 'Es ließ sich keine Sicherung Ihres jetzigen Lernstands anlegen, deshalb wurde ' +
464 `nichts eingelesen. Möglicherweise ist der Speicherplatz erschöpft. (${fehlertext(fehler)}) ` +
465 'Es wurde nichts verändert.',
466 };
467 }
468
469 // ── Schliessen, Nebendateien beseitigen, ersetzen ────────────────────
470 lernstandSchliessen();
471 pruefungZuruecksetzen();
472
473 /*
474 Nach einem sauberen `close()` sind `-wal` und `-shm` weg. Liegen sie doch
475 noch da, hält ein fremdes Handle die Datei. Löschen gelingt dann unter
476 Windows nicht – und weitermachen wäre der eine Weg, auf dem wirklich
477 Daten verschwinden: SQLite spielte das alte Schreibprotokoll auf die neue
478 Datenbank, der eingespielte Stand wäre fort, und `integrity_check` meldete
479 „ok“.
480 */
481 for (const anhang of ['-wal', '-shm']) {
482 const nebendatei = `${ziel}${anhang}`;
483 if (!existsSync(nebendatei)) {
484 continue;
485 }
486 try {
487 unlinkSync(nebendatei);
488 } catch {
489 lernstandInstanz(ziel, umgebung.katalog());
490 return {
491 art: 'gescheitert',
492 grund:
493 'Der Lernstand wird gerade von einem anderen Programm benutzt und ließ sich nicht ' +
494 'ersetzen. Bitte schließen Sie andere Fenster dieser Anwendung und versuchen Sie es ' +
495 `erneut. Ihr Lernstand ist unverändert; eine Sicherung liegt unter ${basename(sicherheitskopie)}.`,
496 };
497 }
498 }
499
500 try {
501 renameSync(merkposten.arbeitskopie, ziel);
502 } catch (fehler) {
503 lernstandInstanz(ziel, umgebung.katalog());
504 return {
505 art: 'gescheitert',
506 grund:
507 `Die Datei ließ sich nicht an die Stelle des Lernstands setzen (${fehlertext(fehler)}). ` +
508 'Ihr Lernstand ist unverändert.',
509 };
510 }
511
512 // ── Öffnen, migrieren, Probe ─────────────────────────────────────────
513 try {
514 const neuer = lernstandInstanz(ziel, umgebung.katalog());
515 const profile = neuer.profile();
516 const erstes = profile[0];
517 if (erstes === undefined) {
518 throw new Error('Die eingespielte Datei enthält kein Profil.');
519 }
520 /* Eine Datei, die sich öffnen lässt, aber bei der ersten echten Abfrage
521 umfällt, ist kein Erfolg. Gefragt wird **jedes** Profil: Die Probe soll
522 die ganze Datei treffen, nicht ihren ersten Eintrag.
523
524 Und die Zahl gehört über alle Profile gebildet. Bis Fassung 0.24.1 nahm
525 sie nur das erste; nach dem Einspielen einer Datei mit drei Profilen
526 stand da „3 Profile, 24 verschiedene Fragen bearbeitet“, obwohl es die
527 24 des ersten waren. Wer die Zahl mit der Gegenüberstellung von vorhin
528 verglich, fand einen Widerspruch, den es nicht gab. */
529 const bearbeiteteFragen = profile.reduce(
530 (summe, profil) => summe + neuer.uebersicht(profil.id).beantwortet,
531 0,
532 );
533 kopienAufraeumen(userData());
534
535 return {
536 art: 'eingespielt',
537 einstellungenUebernommen: einstellungenUebernehmen(neuer.datenbank),
538 profilzahl: profile.length,
539 ersteProfilId: erstes.id,
540 bearbeiteteFragen,
541 sicherheitskopie: basename(sicherheitskopie),
542 };
543 } catch (fehler) {
544 /* Rückweg – und zwar unteilbar wie der Hinweg: erst nach `.teil`, dann
545 umbenennen. Ein Rückweg, der auf halbem Weg abbricht, wäre die
546 schlimmste aller Lagen. */
547 lernstandSchliessen();
548 for (const anhang of ['', '-wal', '-shm']) {
549 aufraeumen(`${ziel}${anhang}`);
550 }
551 try {
552 copyFileSync(sicherheitskopie, `${ziel}.teil`);
553 renameSync(`${ziel}.teil`, ziel);
554 lernstandInstanz(ziel, umgebung.katalog());
555 } catch {
556 /* Auch der Rückweg misslang. Die Sicherheitskopie liegt und wird
557 benannt – von Hand ist sie einspielbar. */
558 }
559 return {
560 art: 'gescheitert',
561 grund:
562 `Die Datei ließ sich nicht öffnen (${fehlertext(fehler)}). Ihr bisheriger Lernstand ` +
563 `wurde wiederhergestellt. Eine Sicherung davon liegt unter ${basename(sicherheitskopie)}.`,
564 };
565 }
566 }
567
568 // ── Hilfen ─────────────────────────────────────────────────────────────
569 /**
570 * Übernimmt die mitgereisten Einstellungen aus einer eingespielten Datei.
571 *
572 * @returns `true`, wenn welche übernommen wurden.
573 *
574 * **Warum das Scheitern hier nichts umwirft.** Der Lernstand ist an dieser
575 * Stelle bereits eingespielt und geprüft. Eine Einstellung, die sich nicht
576 * setzen lässt, darf diesen Erfolg nicht in einen Fehlschlag verwandeln – der
577 * Nutzer stünde sonst vor „gescheitert“, obwohl seine Daten da sind.
578 *
579 * **Warum die Anzeigegröße einen eigenen Weg geht.** Sie hängt nicht nur in
580 * der Einstellungsdatei, sondern als Zoomfaktor an den `webContents` jedes
581 * Fensters und in einem Merkposten in `anzeige.ts`. Bloßes Schreiben änderte
582 * die Datei und nicht das Bild. `anzeigegroesseSetzen` macht beides und meldet
583 * es den Fenstern.
584 */
585 function einstellungenUebernehmen(db: BetterSqlite3.Database): boolean {
586 try {
587 const mitgereist = einstellungenAusSicherung(db);
588 if (mitgereist === null) return false;
589
590 const { anzeigegroesse, ...uebrige } = mitgereist;
591 if (Object.keys(uebrige).length > 0) einstellungenSchreiben(uebrige);
592 if (anzeigegroesse !== undefined) anzeigegroesseSetzen(anzeigegroesse);
593 return true;
594 } catch (fehler: unknown) {
595 console.warn('[sicherung] Einstellungen konnten nicht übernommen werden:', fehler);
596 return false;
597 }
598 }
599
600 function kennzahlenDesLaufenden(umgebung: Umgebung): Kennzahlen {
601 const db = umgebung.lernstand().datenbank;
602 const tabellen = new Set(
603 db
604 .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
605 .all()
606 .map((zeile) => zeile.name),
607 );
608 const fassung =
609 db
610 .prepare<[], { version: number | null }>('SELECT MAX(version) AS version FROM schema_version')
611 .get()?.version ?? 0;
612 /* Dieselbe Funktion wie für die Datei – zwei Zählweisen nebeneinander
613 wären zwei Wahrheiten in derselben Rückfrage. */
614 return kennzahlenLesen(db, fassung, tabellen);
615 }
616
617 /**
618 * Ein freier Name für die Sicherheitskopie.
619 *
620 * Sekundengenau, und trotzdem mit Zähler: Diese Kopie entsteht ohne
621 * Rückfrage, und `sicherungSchreiben` schiebt die fertige Datei mit
622 * `renameSync` an ihren Platz – das ersetzt ein vorhandenes Ziel unteilbar
623 * und wortlos. Zweimal in derselben Sekunde ist unwahrscheinlich, aber
624 * „unwahrscheinlich“ ist bei einer Sicherheitskopie das falsche Wort.
625 *
626 * Bis 0.27.2 stand hier als Begründung, `VACUUM INTO` weigere sich, ein
627 * vorhandenes Ziel zu überschreiben. Das trifft auf den Zielnamen nicht mehr
628 * zu: Geschrieben wird nach `⟨ziel⟩.teil`, und der wird vorher weggeräumt.
629 */
630 function freierName(ordner: string, jetzt: Date): string {
631 const grund = join(ordner, sicherungsDateiname(jetzt, VOR_DEM_EINSPIELEN));
632 if (!existsSync(grund)) {
633 return grund;
634 }
635 for (let zaehler = 2; zaehler < 100; zaehler++) {
636 const kandidat = grund.replace(/\.wsklernstand$/u, `-${String(zaehler)}.wsklernstand`);
637 if (!existsSync(kandidat)) {
638 return kandidat;
639 }
640 }
641 throw new Error('Es ließ sich kein freier Name für die Sicherheitskopie finden.');
642 }
643
644 /** Nur die jüngsten Sicherheitskopien bleiben; ausschliesslich dieses Muster. */
645 function kopienAufraeumen(ordner: string): void {
646 try {
647 const kopien = readdirSync(ordner)
648 .filter((name) => name.startsWith(`${VOR_DEM_EINSPIELEN}-`) && name.endsWith('.wsklernstand'))
649 .map((name) => ({ name, zeit: statSync(join(ordner, name)).mtimeMs }))
650 .sort((a, b) => b.zeit - a.zeit);
651 for (const alt of kopien.slice(KOPIEN_BEHALTEN)) {
652 aufraeumen(join(ordner, alt.name));
653 }
654 } catch {
655 /* Aufräumen ist Komfort. Misslingt es, bleiben ein paar Dateien mehr
656 liegen – das ist kein Grund, einen erfolgreichen Vorgang zu melden. */
657 }
658 }
659
660 /** Reste eines abgebrochenen Vorgangs, bevor ein neuer beginnt. */
661 function restePruefen(): void {
662 offen = null;
663 aufraeumen(join(userData(), PRUEFKOPIE));
664 }
665
666 function fehlertext(fehler: unknown): string {
667 return fehler instanceof Error ? fehler.message : String(fehler);
668 }