waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 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 | } |