waffensachkunde

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

/ app src main sicherung.ts

22,1 KB Rohdatei
app/src/main/sicherung.ts — 594 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 /* Gebundener Parameter statt eingesetztem Pfad – nachgemessen, dass SQLite
125 das bei VACUUM INTO annimmt. Ein Pfad mit Anführungszeichen im Namen
126 hätte sonst die Anweisung zerlegt. */
127 db.prepare('VACUUM INTO ?').run(teil);
128
129 /* Die Kennung wird auf einer SCHREIBENDEN Verbindung gesetzt; auf einer
130 nur lesenden wirft jedes schreibende Pragma. Gegengeprüft wird danach in
131 einer zweiten, ausdrücklich lesenden Verbindung – sonst prüfte dieselbe
132 Verbindung ihr eigenes Werk. */
133 const schreibend = new Datenbank(teil);
134 try {
135 schreibend.pragma(`application_id = ${String(ANWENDUNGSKENNUNG)}`);
136 schreibend.pragma(`user_version = ${String(SCHEMA_VERSION)}`);
137
138 /* Die mitreisenden Einstellungen in dieselbe Datei, auf derselben
139 schreibenden Verbindung. Eine eigene Tabelle statt einer Spalte am
140 Profil: Sie gelten für die Anwendung, nicht für ein Profil, und eine
141 Sicherung enthält mehrere Profile.
142
143 `IF NOT EXISTS` und ein Löschen davor: `VACUUM INTO` kopiert die
144 laufende Datenbank – die Tabelle kann aus einer früheren Sicherung
145 schon dastehen, wenn jemand eine Sicherung eingespielt hat. */
146 if (einstellungen !== undefined) {
147 schreibend.exec('CREATE TABLE IF NOT EXISTS einstellungen_kopie (inhalt TEXT NOT NULL)');
148 schreibend.prepare('DELETE FROM einstellungen_kopie').run();
149 schreibend
150 .prepare<[string]>('INSERT INTO einstellungen_kopie (inhalt) VALUES (?)')
151 .run(JSON.stringify(einstellungen));
152 }
153 } finally {
154 schreibend.close();
155 }
156
157 const befund = dateiPruefen(teil, Datenbank);
158 if (befund.art === 'abgelehnt') {
159 aufraeumen(teil);
160 throw new Error(
161 `Die Sicherung wurde geschrieben, hielt der Gegenprobe aber nicht stand: ${befund.grund}`,
162 );
163 }
164
165 const bytes = statSync(teil).size;
166 renameSync(teil, ziel);
167 return bytes;
168 }
169
170 /**
171 * Die Prüfkette, von billig nach teuer.
172 *
173 * Jede Stufe hat ihren Grund, und die wichtigste ist die fünfte: Eine Datei
174 * von null Byte besteht `integrity_check` mit „ok“ und hat null Tabellen.
175 * Sie durchliefe anschliessend die vollständige Migrationskette und stünde
176 * als tadelloser, **leerer** Lernstand da. Das Einspielen meldete Erfolg, und
177 * die Arbeit von Wochen wäre fort.
178 *
179 * Jeder Ablehnungsgrund endet auf denselben Satz: „Es wurde nichts
180 * verändert.“ Wer eine Fehlermeldung liest, will zuerst das wissen.
181 */
182 export function dateiPruefen(pfad: string, Datenbank: DatenbankKonstruktor): Pruefbefund {
183 const schluss = ' Es wurde nichts verändert.';
184
185 // ── Stufe 1: überhaupt eine Datei dieser Größenordnung? ──────────────
186 let groesse: number;
187 try {
188 const stand = statSync(pfad);
189 if (!stand.isFile()) {
190 return { art: 'abgelehnt', grund: `Das ist keine Datei.${schluss}` };
191 }
192 groesse = stand.size;
193 } catch {
194 return { art: 'abgelehnt', grund: `Diese Datei lässt sich nicht lesen.${schluss}` };
195 }
196
197 if (groesse < MINDESTGROESSE) {
198 return {
199 art: 'abgelehnt',
200 grund: `Diese Datei ist leer oder viel zu klein. Sie enthält keinen Lernstand.${schluss}`,
201 };
202 }
203 if (groesse > HOECHSTGROESSE) {
204 return {
205 art: 'abgelehnt',
206 grund:
207 `Diese Datei ist ${megabyte(groesse)} groß und kann kein Lernstand sein – ` +
208 `ein Lernstand ist wenige Megabyte groß.${schluss}`,
209 };
210 }
211
212 // ── Stufe 2: überhaupt SQLite? ───────────────────────────────────────
213 /* Nötig, weil eine Textdatei sich readonly ÖFFNEN lässt – nachgemessen;
214 erst die erste Abfrage wirft dann SQLITE_NOTADB. Ohne diese Stufe bekäme
215 ein umbenanntes Foto eine englische Datenbankmeldung. */
216 if (!hatSqliteKopf(pfad)) {
217 return {
218 art: 'abgelehnt',
219 grund:
220 'Diese Datei ist keine Datenbank, sondern etwas anderes – vielleicht ein Bild oder ' +
221 `ein Dokument. Sicherungen dieser Anwendung enden auf .wsklernstand.${schluss}`,
222 };
223 }
224
225 // ── Stufe 3 bis 8: auf einer nur lesenden Verbindung ─────────────────
226 let db: BetterSqlite3.Database;
227 try {
228 db = new Datenbank(pfad, { readonly: true, fileMustExist: true });
229 } catch {
230 return {
231 art: 'abgelehnt',
232 grund: `Diese Datei lässt sich nicht lesen. Bitte prüfen Sie die Zugriffsrechte.${schluss}`,
233 };
234 }
235
236 try {
237 // Stufe 4: heil? `integrity_check` WIRFT bei Beschädigung, statt einen
238 // Wert zu liefern – nachgemessen. Beide Wege müssen behandelt werden.
239 let heil = false;
240 try {
241 const zeilen = db.pragma('integrity_check') as { integrity_check: string }[];
242 heil = zeilen.length === 1 && zeilen[0]?.integrity_check === 'ok';
243 } catch {
244 heil = false;
245 }
246 if (!heil) {
247 return {
248 art: 'abgelehnt',
249 grund:
250 'Diese Datei ist unvollständig oder beschädigt. Möglicherweise ist das Herunterladen ' +
251 `oder das Kopieren abgebrochen.${schluss}`,
252 };
253 }
254
255 // Stufe 5: ein Lernstand DIESER Anwendung?
256 const tabellen = new Set(
257 db
258 .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
259 .all()
260 .map((zeile) => zeile.name),
261 );
262 const fehlend = PFLICHTTABELLEN.filter((name) => !tabellen.has(name));
263 if (fehlend.length > 0) {
264 return {
265 art: 'abgelehnt',
266 grund:
267 'Diese Datei ist zwar eine SQLite-Datenbank, aber kein Lernstand dieser Anwendung – ' +
268 `es fehlen die Tabellen ${fehlend.join(' und ')}.${schluss}`,
269 };
270 }
271
272 const profile = db
273 .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil')
274 .get();
275 if ((profile?.anzahl ?? 0) < 1) {
276 return {
277 art: 'abgelehnt',
278 grund:
279 'Diese Datei enthält kein einziges Lernprofil und kann deshalb kein Lernstand ' +
280 `dieser Anwendung sein.${schluss}`,
281 };
282 }
283
284 // Stufe 6: Schemafassung.
285 const fassung =
286 db
287 .prepare<[], { version: number | null }>(
288 'SELECT MAX(version) AS version FROM schema_version',
289 )
290 .get()?.version ?? 0;
291 if (fassung < 1) {
292 return {
293 art: 'abgelehnt',
294 grund:
295 'Diese Datei nennt keine Schemafassung und ist damit kein vollständiger ' +
296 `Lernstand.${schluss}`,
297 };
298 }
299 if (fassung > SCHEMA_VERSION) {
300 return {
301 art: 'abgelehnt',
302 grund:
303 `Diese Sicherung stammt aus einer neueren Fassung des Programms (Schema ${String(fassung)}, ` +
304 `dieses Programm kennt ${String(SCHEMA_VERSION)}). Bitte zuerst das Programm ` +
305 `aktualisieren.${schluss}`,
306 };
307 }
308
309 // Stufe 7: hängt es zusammen?
310 /* `integrity_check` prüft die Baumstruktur, nicht die Beziehungen. Die
311 laufende Datenbank arbeitet mit `foreign_keys = ON`; eine Datei mit
312 verwaisten Zeilen fiele später an beliebiger Stelle auf. */
313 const verwaist = db.pragma('foreign_key_check') as unknown[];
314 if (verwaist.length > 0) {
315 return {
316 art: 'abgelehnt',
317 grund:
318 'Diese Datei ist beschädigt: Sie enthält Einträge, die auf ein Profil verweisen, ' +
319 `das es darin nicht gibt.${schluss}`,
320 };
321 }
322
323 // Stufe 8: Zahlen für die Rückfrage – kein Ablehnungsgrund mehr.
324 return {
325 art: 'brauchbar',
326 kennzahlen: kennzahlenLesen(db, fassung, tabellen),
327 profilIds: db
328 .prepare<[], { id: number }>('SELECT id FROM profil ORDER BY id')
329 .all()
330 .map((z) => z.id),
331 };
332 } finally {
333 // Stufe 9: sonst bleibt die Datei unter Windows gesperrt und die
334 // Arbeitskopie liesse sich weder löschen noch umbenennen.
335 db.close();
336 }
337 }
338
339 /**
340 * Die Zahlen, die in der Rückfrage stehen.
341 *
342 * Profilnamen sind Fremdeingabe und laufen deshalb durch `entschaerft()`:
343 * Zeichen zur Schreibrichtung könnten in einer Rückfrage sonst das Gegenteil
344 * dessen anzeigen, was dort steht.
345 */
346 /** Spaltennamen einer Tabelle – ältere Sicherungen haben nicht alle. */
347 function spalten(db: BetterSqlite3.Database, tabelle: string): ReadonlySet<string> {
348 const zeilen = db.prepare<[], { name: string }>(`PRAGMA table_info(${tabelle})`).all();
349 return new Set(zeilen.map((z) => z.name));
350 }
351
352 export function kennzahlenLesen(
353 db: BetterSqlite3.Database,
354 schemafassung: number,
355 tabellen: ReadonlySet<string>,
356 ): Kennzahlen {
357 const namen = db
358 .prepare<[], { name: string }>('SELECT name FROM profil ORDER BY id')
359 .all()
360 .map((zeile) => entschaerft(zeile.name));
361
362 /*
363 Zeilen im Antwortprotokoll – und ausdrücklich nur die, die jemand wirklich
364 beantwortet hat.
365
366 `antwort_log` enthält seit Schemafassung 8 auch Zeilen mit
367 `nur_historie = 1`: Fragen eines abgelaufenen Prüfungsbogens, die nie
368 aufgeschlagen wurden. Sie gehören in die Historie – sie standen im Bogen –,
369 aber nicht in eine Zahl, die „beantwortete Fragen“ heisst und über die
370 jemand eine nicht rücknehmbare Entscheidung trifft. Derselbe Befund wie
371 docs/stand.md 7.3, nur an einer zweiten Stelle.
372
373 Ältere Sicherungen haben die Spalte nicht; dort zählt alles, und das ist
374 richtig so – rückwirkend liesse sich nicht ermitteln, welche Zeile nie
375 gestellt wurde.
376 */
377 const hatNurHistorie = spalten(db, 'antwort_log').has('nur_historie');
378 const antworten =
379 db
380 .prepare<[], { anzahl: number }>(
381 hatNurHistorie
382 ? 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE nur_historie = 0'
383 : 'SELECT COUNT(*) AS anzahl FROM antwort_log',
384 )
385 .get()?.anzahl ?? 0;
386 const letzte =
387 db
388 .prepare<[], { zeitpunkt: string | null }>(
389 'SELECT MAX(zeitpunkt) AS zeitpunkt FROM antwort_log',
390 )
391 .get()?.zeitpunkt ?? null;
392 const gemerkt =
393 db
394 .prepare<[], { anzahl: number }>(
395 'SELECT COUNT(*) AS anzahl FROM frage_stand WHERE gemerkt = 1',
396 )
397 .get()?.anzahl ?? 0;
398
399 /* Nur ab Schemafassung 2 beziehungsweise 5 – ältere Sicherungen haben die
400 Tabellen nicht, und ihr Fehlen ist kein Fehler. */
401 const pruefungslaeufe = tabellen.has('pruefung_lauf')
402 ? (db.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM pruefung_lauf').get()
403 ?.anzahl ?? 0)
404 : 0;
405 const offenerBogen = tabellen.has('pruefung_offen')
406 ? (db.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM pruefung_offen').get()
407 ?.anzahl ?? 0) > 0
408 : false;
409
410 /* Je Profil, damit sich vergleichen lässt statt nur zu summieren. Die
411 Namen sind der einzige Anker: Die Nummern werden auf jedem Gerät
412 unabhängig vergeben und sagen über die Zugehörigkeit nichts. */
413 const jeProfil = db
414 .prepare<[], { id: number; name: string }>('SELECT id, name FROM profil ORDER BY id')
415 .all()
416 .map((profil) => {
417 const zaehle = (sql: string): number =>
418 db.prepare<[number], { anzahl: number }>(sql).get(profil.id)?.anzahl ?? 0;
419 return {
420 name: entschaerft(profil.name),
421 antworten: zaehle(
422 hatNurHistorie
423 ? 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE profil_id = ? AND nur_historie = 0'
424 : 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE profil_id = ?',
425 ),
426 gemerkt: zaehle(
427 'SELECT COUNT(*) AS anzahl FROM frage_stand WHERE profil_id = ? AND gemerkt = 1',
428 ),
429 pruefungslaeufe: tabellen.has('pruefung_lauf')
430 ? zaehle('SELECT COUNT(*) AS anzahl FROM pruefung_lauf WHERE profil_id = ?')
431 : 0,
432 letzteAntwort:
433 db
434 .prepare<[number], { zeitpunkt: string | null }>(
435 'SELECT MAX(zeitpunkt) AS zeitpunkt FROM antwort_log WHERE profil_id = ?',
436 )
437 .get(profil.id)?.zeitpunkt ?? null,
438 };
439 });
440
441 return {
442 profilnamen: namen,
443 jeProfil,
444 antworten,
445 letzteAntwort: letzte,
446 gemerkt,
447 pruefungslaeufe,
448 offenerBogen,
449 schemafassung,
450 };
451 }
452
453 /** Dateiname einer Sicherung, mit Datum und Uhrzeit auf die Sekunde genau. */
454 export function sicherungsDateiname(jetzt: Date, vorsatz = 'Waffensachkunde-Lernstand'): string {
455 const z = (wert: number, stellen = 2): string => String(wert).padStart(stellen, '0');
456 const stempel =
457 `${z(jetzt.getFullYear(), 4)}-${z(jetzt.getMonth() + 1)}-${z(jetzt.getDate())}` +
458 `-${z(jetzt.getHours())}${z(jetzt.getMinutes())}${z(jetzt.getSeconds())}`;
459 return `${vorsatz}-${stempel}.wsklernstand`;
460 }
461
462 /**
463 * Löscht eine Datenbankdatei samt ihrer Nebendateien.
464 *
465 * `-wal` und `-shm` kamen bis Fassung 0.19.0 nicht mit. Nachgemessen ist das
466 * im heutigen Ablauf **harmlos**: Die Arbeitskopie wird nur lesend geöffnet,
467 * das zurückbleibende `-wal` ist 0 Byte gross, und der nächste Durchgang
468 * überliest es folgenlos – gemessen liest er die richtige Datei mit den
469 * richtigen Zahlen.
470 *
471 * Harmlos, aber nicht ungefährlich. Läge dort je ein **gefülltes** `-wal`,
472 * bekäme SQLite den Inhalt der vorigen Datenbank untergeschoben, und
473 * `integrity_check` meldete dazu „ok“ – nachgestellt und bestätigt: erwartet
474 * wurden 900 Zeilen, gelesen wurden 5000 aus der anderen Datei. Im Durchgang
475 * danach war die Datei unbrauchbar („database disk image is malformed“).
476 *
477 * Ein gefülltes `-wal` entsteht, sobald die Arbeitskopie **schreibend**
478 * geöffnet wird – genau das braucht das Übernehmen eines Profils aus einer
479 * älteren Sicherung. Diese Zeilen stehen deshalb hier, bevor der erste
480 * schreibende Zugriff dazukommt, und nicht danach.
481 */
482 export function aufraeumen(pfad: string): void {
483 loeschen(pfad);
484 nebendateienAufraeumen(pfad);
485 }
486
487 /**
488 * Räumt **nur** `-wal` und `-shm` weg, nicht die Datei selbst.
489 *
490 * Für den einen Fall, in dem das Ziel stehen bleiben muss, bis sein Ersatz
491 * vollständig geschrieben ist: beim Anlegen einer Sicherung über eine
492 * vorhandene. Dort erledigt `renameSync` das Ersetzen unteilbar, und ein
493 * vorheriges Löschen hätte im Fehlerfall beide Fassungen gekostet – siehe
494 * `main/sicherung-dialoge.ts`.
495 */
496 export function nebendateienAufraeumen(pfad: string): void {
497 loeschen(`${pfad}-wal`);
498 loeschen(`${pfad}-shm`);
499 }
500
501 function loeschen(datei: string): void {
502 try {
503 unlinkSync(datei);
504 } catch {
505 /* Nicht da, oder gesperrt. Beides ist hier kein Grund abzubrechen –
506 der Aufrufer prüft anschliessend ohnehin, was er braucht. */
507 }
508 }
509
510 function hatSqliteKopf(pfad: string): boolean {
511 let griff: number;
512 try {
513 griff = openSync(pfad, 'r');
514 } catch {
515 return false;
516 }
517 try {
518 const puffer = Buffer.alloc(SQLITE_KOPF.length);
519 const gelesen = readSync(griff, puffer, 0, puffer.length, 0);
520 return gelesen === puffer.length && puffer.toString('latin1') === SQLITE_KOPF;
521 } finally {
522 closeSync(griff);
523 }
524 }
525
526 /** „1,5 MB“ – dieselbe Schreibweise in jeder Ablehnung. */
527 export function megabyte(bytes: number): string {
528 return `${(bytes / (1024 * 1024)).toLocaleString('de-DE', { maximumFractionDigits: 1 })} MB`;
529 }
530
531 /**
532 * Liest die mitgereisten Einstellungen aus einer geöffneten Sicherung.
533 *
534 * Gibt `null` zurück, wenn die Datei keine enthält – Sicherungen aus Fassung
535 * 0.26.7 und davor tun das, und eine Sicherung ohne Einstellungen ist kein
536 * Fehler, sondern der Normalfall der Vergangenheit. Auch beschädigter Inhalt
537 * führt zu `null`: Am Einspielen des Lernstands – der eigentlichen Sache –
538 * darf eine unlesbare Nebensache nichts ändern.
539 *
540 * Geprüft wird hier nur die Form: JSON, Objekt, bekannter Schlüssel. Die
541 * **Werte** prüft `einstellungenBereinigen` – auf dem Weg über
542 * `einstellungenSchreiben` und `anzeigegroesseSetzen`. Es gibt genau einen
543 * Reinigungsweg, und der bleibt dort.
544 *
545 * Der Rückgabetyp sagt deshalb weniger, als er aussieht: Er benennt die
546 * erlaubten **Schlüssel**, nicht die erlaubten Werte. TypeScript lässt ein
547 * `Record<string, unknown>` an dieser Stelle durch (nachgemessen), weil alle
548 * Felder wahlfrei sind. Wer den Rückgabewert irgendwo hinreicht, wo nicht
549 * gereinigt wird, hat einen Fehler eingebaut – nicht der Typ hält ihn auf.
550 */
551 export function einstellungenAusSicherung(
552 db: BetterSqlite3.Database,
553 ): ReisendeEinstellungen | null {
554 let roh: string;
555 try {
556 const zeile = db
557 .prepare<[], { inhalt: string }>('SELECT inhalt FROM einstellungen_kopie LIMIT 1')
558 .get();
559 if (zeile === undefined) return null;
560 roh = zeile.inhalt;
561 } catch {
562 /* Keine solche Tabelle: eine Sicherung von vor 0.27.0. */
563 return null;
564 }
565
566 let gelesen: unknown;
567 try {
568 gelesen = JSON.parse(roh);
569 } catch {
570 return null;
571 }
572 if (typeof gelesen !== 'object' || gelesen === null || Array.isArray(gelesen)) return null;
573
574 const gefiltert: Record<string, unknown> = {};
575 for (const schluessel of EINSTELLUNGEN_REISEN) {
576 if (schluessel in gelesen) {
577 gefiltert[schluessel] = (gelesen as Record<string, unknown>)[schluessel];
578 }
579 }
580 /* Auch beim Lesen gefiltert, nicht nur beim Schreiben. Sonst brächte eine
581 von Hand veränderte Sicherungsdatei `profilId` oder `fenster` mit – und
582 genau die dürfen nicht mitreisen (siehe EINSTELLUNGEN_REISEN). */
583 return Object.keys(gefiltert).length === 0 ? null : gefiltert;
584 }
585
586 /** Die mitreisenden Einstellungen des laufenden Betriebs zusammenstellen. */
587 export function reisendeEinstellungen(alle: Einstellungen): ReisendeEinstellungen {
588 const stueck: Record<string, unknown> = {};
589 for (const schluessel of EINSTELLUNGEN_REISEN) {
590 const wert = alle[schluessel];
591 if (wert !== undefined) stueck[schluessel] = wert;
592 }
593 return stueck;
594 }