waffensachkunde

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

/ app src main sicherung-dialoge.ts

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