waffensachkunde

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

/ app src main sicherung.ts

23,7 KB Rohdatei
app/src/main/sicherung.ts — 628 Zeilen
1 /**
2 * Sicherung des Lernstands: schreiben, prüfen, beziffern.
3 *
4 * Alles hier ist reine Datei- und Datenbankarbeit ohne Electron – damit es
5 * gegen echte SQLite-Dateien prüfbar bleibt. Die Dialoge und der Ablauf des
6 * Einspielens stehen in `sicherung-dialoge.ts`.
7 *
8 * ## Warum `VACUUM INTO` und nicht `db.backup()`
9 *
10 * Beides erzeugt eine stimmige Kopie einer laufenden Datenbank. Nachgemessen
11 * an better-sqlite3 13.0.3 und SQLite 3.53.4 dieses Projekts unterscheiden
12 * sie sich an drei Stellen, und alle drei sprechen für `VACUUM INTO`:
13 *
14 * - `db.backup()` liefert eine Datei im **WAL-Modus**. Wer sie später auch
15 * nur ansieht, erzeugt daneben `-wal` und `-shm` – und die bleiben nach dem
16 * Schließen liegen. Eine Sicherung, von der man zwei Dateien vergisst, ist
17 * genau der Fehler, den sie verhüten soll. `VACUUM INTO` schreibt eine
18 * einzelne Datei im `delete`-Modus; sie bleibt eine einzelne Datei.
19 * - `VACUUM INTO` bricht ab, wenn das Ziel schon existiert. Ein
20 * versehentliches Überschreiben ist damit technisch ausgeschlossen und
21 * nicht bloß durch Sorgfalt.
22 * - `VACUUM INTO` ist **synchron**. Der Hauptprozess ist einfädig; solange
23 * nichts abgewartet wird, kann sich kein anderer Kanal dazwischenschieben
24 * und die Datei in genau dem Augenblick öffnen, in dem sie ersetzt wird.
25 * `db.backup()` arbeitet über eine `setImmediate`-Schleife und risse dieses
26 * Fenster wieder auf.
27 *
28 * ## Warum eine blosse Dateikopie nicht genügt
29 *
30 * Der Lernstand läuft im WAL-Modus. Nachgemessen: Bei offener Verbindung und
31 * 5 000 geschriebenen Zeilen war `lernstand.db` **4 096 Byte** gross und das
32 * Schreibprotokoll `lernstand.db-wal` **1,1 MB**. Eine Kopie nur der
33 * Hauptdatei liess sich anstandslos öffnen, bestand `integrity_check` mit
34 * „ok“ – und enthielt **keine einzige Tabelle**. Nicht ein Teil fehlte,
35 * sondern alles. Wer so sichert, merkt es an dem Tag, an dem er die Sicherung
36 * braucht.
37 */
38
39 import { closeSync, openSync, readSync, renameSync, statSync, unlinkSync } from 'node:fs';
40
41 import type BetterSqlite3 from 'better-sqlite3';
42
43 import { entschaerft } from './eingaben';
44 import { SCHEMA_VERSION } from './schema';
45 import {
46 EINSTELLUNGEN_REISEN,
47 type Einstellungen,
48 type ReisendeEinstellungen,
49 } from '../shared/ipc';
50 import type { Kennzahlen } from '../shared/sicherung';
51
52 /**
53 * Kennung im Dateikopf, an der eine Sicherung dieser Anwendung zu erkennen
54 * ist – „WSKL“ als 32-Bit-Zahl.
55 *
56 * Ausschliesslich als Auskunft, **nie** als Ablehnungsgrund: Eine von Hand
57 * kopierte `lernstand.db` trägt hier 0 und muss trotzdem einspielbar bleiben.
58 */
59 export const ANWENDUNGSKENNUNG = 0x57534b4c;
60
61 /** Die ersten Bytes jeder SQLite-Datei. */
62 const SQLITE_KOPF = 'SQLite format 3\0';
63
64 /** Kleiner als das kann keine Datenbank sein: eine SQLite-Seite. */
65 const MINDESTGROESSE = 512;
66
67 /**
68 * Grösser als das ist kein Lernstand, sondern ein Versehen.
69 *
70 * Exportiert, weil `sicherung-dialoge.ts` die Grenze schon an der **Quelle**
71 * anlegt: Sonst wanderte ein versehentlich gewählter Film erst vollständig
72 * ins Programmverzeichnis und würde danach abgelehnt.
73 */
74 export const HOECHSTGROESSE = 512 * 1024 * 1024;
75
76 /**
77 * Tabellen, die es seit Schemafassung 1 gibt und die jeder Lernstand hat.
78 *
79 * `pruefung_lauf` und `pruefung_offen` fehlen hier mit Absicht – sie kamen
80 * erst mit Fassung 2 und 5. Eine echte alte Sicherung soll nicht daran
81 * scheitern, dass sie alt ist.
82 */
83 const PFLICHTTABELLEN = ['schema_version', 'profil', 'frage_stand', 'antwort_log'] as const;
84
85 export type Pruefbefund =
86 | {
87 readonly art: 'brauchbar';
88 readonly kennzahlen: Kennzahlen;
89 /**
90 * Die Profilnummern der Datei, in derselben Reihenfolge wie
91 * `kennzahlen.jeProfil`. Sie verlassen den Hauptprozess nie – der
92 * Renderer wählt über den Index.
93 */
94 readonly profilIds: readonly number[];
95 }
96 | { readonly art: 'abgelehnt'; readonly grund: string };
97
98 /** Konstruktor von better-sqlite3, so weit hier gebraucht. */
99 export type DatenbankKonstruktor = new (
100 pfad: string,
101 optionen?: BetterSqlite3.Options,
102 ) => BetterSqlite3.Database;
103
104 /**
105 * Schreibt eine Sicherung der laufenden Datenbank.
106 *
107 * Erst nach `.teil`, dann umbenennen: Ein Abbruch mittendrin hinterlässt
108 * damit nie eine halbe Datei unter dem richtigen Namen. Das Umbenennen im
109 * selben Verzeichnis ist der einzige Schritt, den das Betriebssystem
110 * unteilbar ausführt.
111 *
112 * @returns Grösse der geschriebenen Datei in Byte.
113 */
114 export function sicherungSchreiben(
115 db: BetterSqlite3.Database,
116 ziel: string,
117 Datenbank: DatenbankKonstruktor,
118 /** Die mitreisenden Einstellungen; ohne sie enthält die Datei keine. */
119 einstellungen?: ReisendeEinstellungen,
120 ): number {
121 const teil = `${ziel}.teil`;
122 aufraeumen(teil);
123
124 try {
125 return teilSchreiben(db, ziel, teil, Datenbank, einstellungen);
126 } catch (fehler) {
127 /*
128 Der Rest fällt in jedem Fehlerfall, nicht nur bei abgelehnter
129 Gegenprobe. Bis 0.27.2 stand `aufraeumen` allein im Ablehnungszweig:
130 Warf `VACUUM INTO` – voller Datenträger, abgezogener Stick – oder der
131 schreibende Zugriff darauf, blieb `⟨ziel⟩.teil` liegen. Weggeräumt
132 wurde er nie, weil der Zielname die Sekunde trägt und die drei
133 Aufräumwege auf `.wsklernstand` filtern; auf einem vollen Datenträger
134 belegte er genau den Platz, der beim nächsten Versuch fehlte.
135 */
136 aufraeumen(teil);
137 throw fehler;
138 }
139 }
140
141 /** Der eigentliche Schreibvorgang; der Aufrufer räumt den Rest weg. */
142 function teilSchreiben(
143 db: BetterSqlite3.Database,
144 ziel: string,
145 teil: string,
146 Datenbank: DatenbankKonstruktor,
147 einstellungen?: ReisendeEinstellungen,
148 ): number {
149 /* Gebundener Parameter statt eingesetztem Pfad – nachgemessen, dass SQLite
150 das bei VACUUM INTO annimmt. Ein Pfad mit Anführungszeichen im Namen
151 hätte sonst die Anweisung zerlegt. */
152 db.prepare('VACUUM INTO ?').run(teil);
153
154 /* Die Kennung wird auf einer SCHREIBENDEN Verbindung gesetzt; auf einer
155 nur lesenden wirft jedes schreibende Pragma. Gegengeprüft wird danach in
156 einer zweiten, ausdrücklich lesenden Verbindung – sonst prüfte dieselbe
157 Verbindung ihr eigenes Werk. */
158 const schreibend = new Datenbank(teil);
159 try {
160 schreibend.pragma(`application_id = ${String(ANWENDUNGSKENNUNG)}`);
161 schreibend.pragma(`user_version = ${String(SCHEMA_VERSION)}`);
162
163 /* Die mitreisenden Einstellungen in dieselbe Datei, auf derselben
164 schreibenden Verbindung. Eine eigene Tabelle statt einer Spalte am
165 Profil: Sie gelten für die Anwendung, nicht für ein Profil, und eine
166 Sicherung enthält mehrere Profile.
167
168 `IF NOT EXISTS` und ein Löschen davor: `VACUUM INTO` kopiert die
169 laufende Datenbank – die Tabelle kann aus einer früheren Sicherung
170 schon dastehen, wenn jemand eine Sicherung eingespielt hat. */
171 if (einstellungen !== undefined) {
172 schreibend.exec('CREATE TABLE IF NOT EXISTS einstellungen_kopie (inhalt TEXT NOT NULL)');
173 schreibend.prepare('DELETE FROM einstellungen_kopie').run();
174 schreibend
175 .prepare<[string]>('INSERT INTO einstellungen_kopie (inhalt) VALUES (?)')
176 .run(JSON.stringify(einstellungen));
177 }
178 } finally {
179 schreibend.close();
180 }
181
182 const befund = dateiPruefen(teil, Datenbank);
183 if (befund.art === 'abgelehnt') {
184 aufraeumen(teil);
185 throw new Error(
186 `Die Sicherung wurde geschrieben, hielt der Gegenprobe aber nicht stand: ${befund.grund}`,
187 );
188 }
189
190 const bytes = statSync(teil).size;
191 renameSync(teil, ziel);
192 return bytes;
193 }
194
195 /**
196 * Die Prüfkette, von billig nach teuer.
197 *
198 * Jede Stufe hat ihren Grund, und die wichtigste ist die fünfte: Eine Datei
199 * von null Byte besteht `integrity_check` mit „ok“ und hat null Tabellen.
200 * Sie durchliefe anschliessend die vollständige Migrationskette und stünde
201 * als tadelloser, **leerer** Lernstand da. Das Einspielen meldete Erfolg, und
202 * die Arbeit von Wochen wäre fort.
203 *
204 * Jeder Ablehnungsgrund endet auf denselben Satz: „Es wurde nichts
205 * verändert.“ Wer eine Fehlermeldung liest, will zuerst das wissen.
206 */
207 export function dateiPruefen(pfad: string, Datenbank: DatenbankKonstruktor): Pruefbefund {
208 const schluss = ' Es wurde nichts verändert.';
209
210 // ── Stufe 1: überhaupt eine Datei dieser Größenordnung? ──────────────
211 let groesse: number;
212 try {
213 const stand = statSync(pfad);
214 if (!stand.isFile()) {
215 return { art: 'abgelehnt', grund: `Das ist keine Datei.${schluss}` };
216 }
217 groesse = stand.size;
218 } catch {
219 return { art: 'abgelehnt', grund: `Diese Datei lässt sich nicht lesen.${schluss}` };
220 }
221
222 if (groesse < MINDESTGROESSE) {
223 return {
224 art: 'abgelehnt',
225 grund: `Diese Datei ist leer oder viel zu klein. Sie enthält keinen Lernstand.${schluss}`,
226 };
227 }
228 if (groesse > HOECHSTGROESSE) {
229 return {
230 art: 'abgelehnt',
231 grund:
232 `Diese Datei ist ${megabyte(groesse)} groß und kann kein Lernstand sein – ` +
233 `ein Lernstand ist wenige Megabyte groß.${schluss}`,
234 };
235 }
236
237 // ── Stufe 2: überhaupt SQLite? ───────────────────────────────────────
238 /* Nötig, weil eine Textdatei sich readonly ÖFFNEN lässt – nachgemessen;
239 erst die erste Abfrage wirft dann SQLITE_NOTADB. Ohne diese Stufe bekäme
240 ein umbenanntes Foto eine englische Datenbankmeldung. */
241 if (!hatSqliteKopf(pfad)) {
242 return {
243 art: 'abgelehnt',
244 grund:
245 'Diese Datei ist keine Datenbank, sondern etwas anderes – vielleicht ein Bild oder ' +
246 `ein Dokument. Sicherungen dieser Anwendung enden auf .wsklernstand.${schluss}`,
247 };
248 }
249
250 // ── Stufe 3 bis 8: auf einer nur lesenden Verbindung ─────────────────
251 let db: BetterSqlite3.Database;
252 try {
253 db = new Datenbank(pfad, { readonly: true, fileMustExist: true });
254 } catch {
255 return {
256 art: 'abgelehnt',
257 grund: `Diese Datei lässt sich nicht lesen. Bitte prüfen Sie die Zugriffsrechte.${schluss}`,
258 };
259 }
260
261 try {
262 // Stufe 4: heil? `integrity_check` WIRFT bei Beschädigung, statt einen
263 // Wert zu liefern – nachgemessen. Beide Wege müssen behandelt werden.
264 let heil = false;
265 try {
266 const zeilen = db.pragma('integrity_check') as { integrity_check: string }[];
267 heil = zeilen.length === 1 && zeilen[0]?.integrity_check === 'ok';
268 } catch {
269 heil = false;
270 }
271 if (!heil) {
272 return {
273 art: 'abgelehnt',
274 grund:
275 'Diese Datei ist unvollständig oder beschädigt. Möglicherweise ist das Herunterladen ' +
276 `oder das Kopieren abgebrochen.${schluss}`,
277 };
278 }
279
280 // Stufe 5: ein Lernstand DIESER Anwendung?
281 const tabellen = new Set(
282 db
283 .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
284 .all()
285 .map((zeile) => zeile.name),
286 );
287 const fehlend = PFLICHTTABELLEN.filter((name) => !tabellen.has(name));
288 if (fehlend.length > 0) {
289 return {
290 art: 'abgelehnt',
291 grund:
292 'Diese Datei ist zwar eine SQLite-Datenbank, aber kein Lernstand dieser Anwendung – ' +
293 `es fehlen die Tabellen ${fehlend.join(' und ')}.${schluss}`,
294 };
295 }
296
297 const profile = db
298 .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil')
299 .get();
300 if ((profile?.anzahl ?? 0) < 1) {
301 return {
302 art: 'abgelehnt',
303 grund:
304 'Diese Datei enthält kein einziges Lernprofil und kann deshalb kein Lernstand ' +
305 `dieser Anwendung sein.${schluss}`,
306 };
307 }
308
309 // Stufe 6: Schemafassung.
310 const fassung =
311 db
312 .prepare<[], { version: number | null }>(
313 'SELECT MAX(version) AS version FROM schema_version',
314 )
315 .get()?.version ?? 0;
316 if (fassung < 1) {
317 return {
318 art: 'abgelehnt',
319 grund:
320 'Diese Datei nennt keine Schemafassung und ist damit kein vollständiger ' +
321 `Lernstand.${schluss}`,
322 };
323 }
324 if (fassung > SCHEMA_VERSION) {
325 return {
326 art: 'abgelehnt',
327 grund:
328 `Diese Sicherung stammt aus einer neueren Fassung des Programms (Schema ${String(fassung)}, ` +
329 `dieses Programm kennt ${String(SCHEMA_VERSION)}). Bitte zuerst das Programm ` +
330 `aktualisieren.${schluss}`,
331 };
332 }
333
334 // Stufe 7: hängt es zusammen?
335 /* `integrity_check` prüft die Baumstruktur, nicht die Beziehungen. Die
336 laufende Datenbank arbeitet mit `foreign_keys = ON`; eine Datei mit
337 verwaisten Zeilen fiele später an beliebiger Stelle auf. */
338 const verwaist = db.pragma('foreign_key_check') as unknown[];
339 if (verwaist.length > 0) {
340 return {
341 art: 'abgelehnt',
342 grund:
343 'Diese Datei ist beschädigt: Sie enthält Einträge, die auf ein Profil verweisen, ' +
344 `das es darin nicht gibt.${schluss}`,
345 };
346 }
347
348 // Stufe 8: Zahlen für die Rückfrage – kein Ablehnungsgrund mehr.
349 return {
350 art: 'brauchbar',
351 kennzahlen: kennzahlenLesen(db, fassung, tabellen),
352 profilIds: db
353 .prepare<[], { id: number }>('SELECT id FROM profil ORDER BY id')
354 .all()
355 .map((z) => z.id),
356 };
357 } finally {
358 // Stufe 9: sonst bleibt die Datei unter Windows gesperrt und die
359 // Arbeitskopie liesse sich weder löschen noch umbenennen.
360 db.close();
361 }
362 }
363
364 /**
365 * Die Zahlen, die in der Rückfrage stehen.
366 *
367 * Profilnamen sind Fremdeingabe und laufen deshalb durch `entschaerft()`:
368 * Zeichen zur Schreibrichtung könnten in einer Rückfrage sonst das Gegenteil
369 * dessen anzeigen, was dort steht.
370 */
371 /** Spaltennamen einer Tabelle – ältere Sicherungen haben nicht alle. */
372 function spalten(db: BetterSqlite3.Database, tabelle: string): ReadonlySet<string> {
373 const zeilen = db.prepare<[], { name: string }>(`PRAGMA table_info(${tabelle})`).all();
374 return new Set(zeilen.map((z) => z.name));
375 }
376
377 export function kennzahlenLesen(
378 db: BetterSqlite3.Database,
379 schemafassung: number,
380 tabellen: ReadonlySet<string>,
381 ): Kennzahlen {
382 const namen = db
383 .prepare<[], { name: string }>('SELECT name FROM profil ORDER BY id')
384 .all()
385 .map((zeile) => entschaerft(zeile.name));
386
387 /*
388 Zeilen im Antwortprotokoll – und ausdrücklich nur die, die jemand wirklich
389 beantwortet hat.
390
391 `antwort_log` enthält seit Schemafassung 8 auch Zeilen mit
392 `nur_historie = 1`: Fragen eines abgelaufenen Prüfungsbogens, die nie
393 aufgeschlagen wurden. Sie gehören in die Historie – sie standen im Bogen –,
394 aber nicht in eine Zahl, die „beantwortete Fragen“ heisst und über die
395 jemand eine nicht rücknehmbare Entscheidung trifft. Derselbe Befund wie
396 docs/stand.md 7.3, nur an einer zweiten Stelle.
397
398 Ältere Sicherungen haben die Spalte nicht; dort zählt alles, und das ist
399 richtig so – rückwirkend liesse sich nicht ermitteln, welche Zeile nie
400 gestellt wurde.
401 */
402 const hatNurHistorie = spalten(db, 'antwort_log').has('nur_historie');
403 const antworten =
404 db
405 .prepare<[], { anzahl: number }>(
406 hatNurHistorie
407 ? 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE nur_historie = 0'
408 : 'SELECT COUNT(*) AS anzahl FROM antwort_log',
409 )
410 .get()?.anzahl ?? 0;
411 /* Dieselbe Bedingung wie eine Zeile darüber. Bis 0.27.2 filterte die Zahl
412 die reinen Historienzeilen aus und das Datum nicht: Ein Profil, das nur
413 eine abgelaufene Simulation hinter sich hat, zeigte „0 Antworten …
414 zuletzt gelernt am ⟨Prüfungstag⟩“ – zwei Zahlen, die einander
415 widersprechen, direkt vor einem nicht rücknehmbaren Schritt. */
416 const letzte =
417 db
418 .prepare<[], { zeitpunkt: string | null }>(
419 hatNurHistorie
420 ? 'SELECT MAX(zeitpunkt) AS zeitpunkt FROM antwort_log WHERE nur_historie = 0'
421 : 'SELECT MAX(zeitpunkt) AS zeitpunkt FROM antwort_log',
422 )
423 .get()?.zeitpunkt ?? null;
424 const gemerkt =
425 db
426 .prepare<[], { anzahl: number }>(
427 'SELECT COUNT(*) AS anzahl FROM frage_stand WHERE gemerkt = 1',
428 )
429 .get()?.anzahl ?? 0;
430
431 /* Nur ab Schemafassung 2 beziehungsweise 5 – ältere Sicherungen haben die
432 Tabellen nicht, und ihr Fehlen ist kein Fehler. */
433 const pruefungslaeufe = tabellen.has('pruefung_lauf')
434 ? (db.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM pruefung_lauf').get()
435 ?.anzahl ?? 0)
436 : 0;
437 const offenerBogen = tabellen.has('pruefung_offen')
438 ? (db.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM pruefung_offen').get()
439 ?.anzahl ?? 0) > 0
440 : false;
441
442 /* Je Profil, damit sich vergleichen lässt statt nur zu summieren. Die
443 Namen sind der einzige Anker: Die Nummern werden auf jedem Gerät
444 unabhängig vergeben und sagen über die Zugehörigkeit nichts. */
445 const jeProfil = db
446 .prepare<[], { id: number; name: string }>('SELECT id, name FROM profil ORDER BY id')
447 .all()
448 .map((profil) => {
449 const zaehle = (sql: string): number =>
450 db.prepare<[number], { anzahl: number }>(sql).get(profil.id)?.anzahl ?? 0;
451 return {
452 name: entschaerft(profil.name),
453 antworten: zaehle(
454 hatNurHistorie
455 ? 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE profil_id = ? AND nur_historie = 0'
456 : 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE profil_id = ?',
457 ),
458 gemerkt: zaehle(
459 'SELECT COUNT(*) AS anzahl FROM frage_stand WHERE profil_id = ? AND gemerkt = 1',
460 ),
461 pruefungslaeufe: tabellen.has('pruefung_lauf')
462 ? zaehle('SELECT COUNT(*) AS anzahl FROM pruefung_lauf WHERE profil_id = ?')
463 : 0,
464 letzteAntwort:
465 db
466 .prepare<[number], { zeitpunkt: string | null }>(
467 hatNurHistorie
468 ? 'SELECT MAX(zeitpunkt) AS zeitpunkt FROM antwort_log WHERE profil_id = ? AND nur_historie = 0'
469 : 'SELECT MAX(zeitpunkt) AS zeitpunkt FROM antwort_log WHERE profil_id = ?',
470 )
471 .get(profil.id)?.zeitpunkt ?? null,
472 };
473 });
474
475 return {
476 profilnamen: namen,
477 jeProfil,
478 antworten,
479 letzteAntwort: letzte,
480 gemerkt,
481 pruefungslaeufe,
482 offenerBogen,
483 schemafassung,
484 };
485 }
486
487 /** Dateiname einer Sicherung, mit Datum und Uhrzeit auf die Sekunde genau. */
488 export function sicherungsDateiname(jetzt: Date, vorsatz = 'Waffensachkunde-Lernstand'): string {
489 const z = (wert: number, stellen = 2): string => String(wert).padStart(stellen, '0');
490 const stempel =
491 `${z(jetzt.getFullYear(), 4)}-${z(jetzt.getMonth() + 1)}-${z(jetzt.getDate())}` +
492 `-${z(jetzt.getHours())}${z(jetzt.getMinutes())}${z(jetzt.getSeconds())}`;
493 return `${vorsatz}-${stempel}.wsklernstand`;
494 }
495
496 /**
497 * Löscht eine Datenbankdatei samt ihrer Nebendateien.
498 *
499 * `-wal` und `-shm` kamen bis Fassung 0.19.0 nicht mit. Nachgemessen ist das
500 * im heutigen Ablauf **harmlos**: Die Arbeitskopie wird nur lesend geöffnet,
501 * das zurückbleibende `-wal` ist 0 Byte gross, und der nächste Durchgang
502 * überliest es folgenlos – gemessen liest er die richtige Datei mit den
503 * richtigen Zahlen.
504 *
505 * Harmlos, aber nicht ungefährlich. Läge dort je ein **gefülltes** `-wal`,
506 * bekäme SQLite den Inhalt der vorigen Datenbank untergeschoben, und
507 * `integrity_check` meldete dazu „ok“ – nachgestellt und bestätigt: erwartet
508 * wurden 900 Zeilen, gelesen wurden 5000 aus der anderen Datei. Im Durchgang
509 * danach war die Datei unbrauchbar („database disk image is malformed“).
510 *
511 * Ein gefülltes `-wal` entsteht, sobald die Arbeitskopie **schreibend**
512 * geöffnet wird – genau das braucht das Übernehmen eines Profils aus einer
513 * älteren Sicherung. Diese Zeilen stehen deshalb hier, bevor der erste
514 * schreibende Zugriff dazukommt, und nicht danach.
515 */
516 export function aufraeumen(pfad: string): void {
517 loeschen(pfad);
518 nebendateienAufraeumen(pfad);
519 }
520
521 /**
522 * Räumt **nur** `-wal` und `-shm` weg, nicht die Datei selbst.
523 *
524 * Für den einen Fall, in dem das Ziel stehen bleiben muss, bis sein Ersatz
525 * vollständig geschrieben ist: beim Anlegen einer Sicherung über eine
526 * vorhandene. Dort erledigt `renameSync` das Ersetzen unteilbar, und ein
527 * vorheriges Löschen hätte im Fehlerfall beide Fassungen gekostet – siehe
528 * `main/sicherung-dialoge.ts`.
529 */
530 export function nebendateienAufraeumen(pfad: string): void {
531 loeschen(`${pfad}-wal`);
532 loeschen(`${pfad}-shm`);
533 }
534
535 function loeschen(datei: string): void {
536 try {
537 unlinkSync(datei);
538 } catch {
539 /* Nicht da, oder gesperrt. Beides ist hier kein Grund abzubrechen –
540 der Aufrufer prüft anschliessend ohnehin, was er braucht. */
541 }
542 }
543
544 function hatSqliteKopf(pfad: string): boolean {
545 let griff: number;
546 try {
547 griff = openSync(pfad, 'r');
548 } catch {
549 return false;
550 }
551 try {
552 const puffer = Buffer.alloc(SQLITE_KOPF.length);
553 const gelesen = readSync(griff, puffer, 0, puffer.length, 0);
554 return gelesen === puffer.length && puffer.toString('latin1') === SQLITE_KOPF;
555 } finally {
556 closeSync(griff);
557 }
558 }
559
560 /** „1,5 MB“ – dieselbe Schreibweise in jeder Ablehnung. */
561 export function megabyte(bytes: number): string {
562 return `${(bytes / (1024 * 1024)).toLocaleString('de-DE', { maximumFractionDigits: 1 })} MB`;
563 }
564
565 /**
566 * Liest die mitgereisten Einstellungen aus einer geöffneten Sicherung.
567 *
568 * Gibt `null` zurück, wenn die Datei keine enthält – Sicherungen aus Fassung
569 * 0.26.7 und davor tun das, und eine Sicherung ohne Einstellungen ist kein
570 * Fehler, sondern der Normalfall der Vergangenheit. Auch beschädigter Inhalt
571 * führt zu `null`: Am Einspielen des Lernstands – der eigentlichen Sache –
572 * darf eine unlesbare Nebensache nichts ändern.
573 *
574 * Geprüft wird hier nur die Form: JSON, Objekt, bekannter Schlüssel. Die
575 * **Werte** prüft `einstellungenBereinigen` – auf dem Weg über
576 * `einstellungenSchreiben` und `anzeigegroesseSetzen`. Es gibt genau einen
577 * Reinigungsweg, und der bleibt dort.
578 *
579 * Der Rückgabetyp sagt deshalb weniger, als er aussieht: Er benennt die
580 * erlaubten **Schlüssel**, nicht die erlaubten Werte. TypeScript lässt ein
581 * `Record<string, unknown>` an dieser Stelle durch (nachgemessen), weil alle
582 * Felder wahlfrei sind. Wer den Rückgabewert irgendwo hinreicht, wo nicht
583 * gereinigt wird, hat einen Fehler eingebaut – nicht der Typ hält ihn auf.
584 */
585 export function einstellungenAusSicherung(
586 db: BetterSqlite3.Database,
587 ): ReisendeEinstellungen | null {
588 let roh: string;
589 try {
590 const zeile = db
591 .prepare<[], { inhalt: string }>('SELECT inhalt FROM einstellungen_kopie LIMIT 1')
592 .get();
593 if (zeile === undefined) return null;
594 roh = zeile.inhalt;
595 } catch {
596 /* Keine solche Tabelle: eine Sicherung von vor 0.27.0. */
597 return null;
598 }
599
600 let gelesen: unknown;
601 try {
602 gelesen = JSON.parse(roh);
603 } catch {
604 return null;
605 }
606 if (typeof gelesen !== 'object' || gelesen === null || Array.isArray(gelesen)) return null;
607
608 const gefiltert: Record<string, unknown> = {};
609 for (const schluessel of EINSTELLUNGEN_REISEN) {
610 if (schluessel in gelesen) {
611 gefiltert[schluessel] = (gelesen as Record<string, unknown>)[schluessel];
612 }
613 }
614 /* Auch beim Lesen gefiltert, nicht nur beim Schreiben. Sonst brächte eine
615 von Hand veränderte Sicherungsdatei `profilId` oder `fenster` mit – und
616 genau die dürfen nicht mitreisen (siehe EINSTELLUNGEN_REISEN). */
617 return Object.keys(gefiltert).length === 0 ? null : gefiltert;
618 }
619
620 /** Die mitreisenden Einstellungen des laufenden Betriebs zusammenstellen. */
621 export function reisendeEinstellungen(alle: Einstellungen): ReisendeEinstellungen {
622 const stueck: Record<string, unknown> = {};
623 for (const schluessel of EINSTELLUNGEN_REISEN) {
624 const wert = alle[schluessel];
625 if (wert !== undefined) stueck[schluessel] = wert;
626 }
627 return stueck;
628 }