waffensachkunde

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

/ app src main selbstsicherung.ts

7,7 KB Rohdatei
app/src/main/selbstsicherung.ts — 204 Zeilen
1 /**
2 * Die selbsttätige Sicherheitskopie des Lernstands.
3 *
4 * **Wozu.** Eine Sicherung entstand bis 0.22.0 ausschließlich, wenn jemand
5 * die Karte „Lernstand sichern und übertragen“ aufschlug und bediente. Die
6 * einzige selbsttätige Kopie entstand vor dem **Einspielen** einer fremden
7 * Sicherung – also genau dann, wenn ohnehin jemand mit Sicherungen hantiert.
8 * Wer die Karte nie öffnete, hatte nichts: Ein Datenträgerdefekt, ein
9 * versehentliches Löschen oder eine beschädigte Datei nahmen alles mit, was
10 * über Wochen gelernt worden war.
11 *
12 * Diese Kopie liegt auf demselben Datenträger und ersetzt deshalb **keine**
13 * richtige Sicherung – das sagt die Karte auch. Sie deckt die häufigen Fälle:
14 * die eine beschädigte Datei, den Fehlgriff, den missglückten Einspielvorgang.
15 * Gegen einen Plattendefekt hilft nur eine Kopie anderswo, und dazu rät die
16 * Anwendung weiterhin.
17 *
18 * **Warum beim Beenden.** Beim Start wäre die Kopie die des vorigen Standes
19 * und ließe die Arbeit des letzten Tages aus. Beim Beenden ist sie so frisch
20 * wie möglich. `VACUUM INTO` braucht dafür die offene Verbindung – der Aufruf
21 * gehört also **vor** `lernstandSchliessen()`.
22 *
23 * **Warum nicht bei jedem Beenden.** Eine Kopie je Programmstart füllte den
24 * Datenträger mit Fassungen, die sich um Minuten unterscheiden. Der Abstand
25 * von einer Woche ist der Kompromiss: alt genug, dass sich etwas geändert
26 * hat, jung genug, dass der Verlust überschaubar bleibt.
27 */
28
29 import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
30 import { join } from 'node:path';
31
32 import type BetterSqlite3 from 'better-sqlite3';
33
34 import {
35 aufraeumen,
36 sicherungsDateiname,
37 sicherungSchreiben,
38 type DatenbankKonstruktor,
39 } from './sicherung';
40
41 /** Unterordner im `userData`-Verzeichnis. */
42 export const SELBSTSICHERUNG_ORDNER = 'sicherungen';
43
44 /** Vorsatz im Dateinamen – hält die Kopien von allem anderen getrennt. */
45 export const SELBSTSICHERUNG_VORSATZ = 'Lernstand-selbsttaetig';
46
47 /** So lange gilt die letzte Kopie als frisch genug. */
48 export const ABSTAND_MS = 7 * 24 * 60 * 60 * 1000;
49
50 /** So viele Kopien bleiben liegen. */
51 export const KOPIEN_BEHALTEN = 3;
52
53 /** Voller Pfad des Kopienordners. */
54 export function selbstsicherungsordner(userData: string): string {
55 return join(userData, SELBSTSICHERUNG_ORDNER);
56 }
57
58 /** Alle vorhandenen Kopien, jüngste zuerst. */
59 function vorhandene(ordner: string): { name: string; zeit: number }[] {
60 if (!existsSync(ordner)) {
61 return [];
62 }
63 return readdirSync(ordner)
64 .filter(
65 (name) => name.startsWith(`${SELBSTSICHERUNG_VORSATZ}-`) && name.endsWith('.wsklernstand'),
66 )
67 .map((name) => ({ name, zeit: statSync(join(ordner, name)).mtimeMs }))
68 .sort((a, b) => b.zeit - a.zeit);
69 }
70
71 /**
72 * Ob eine neue Kopie fällig ist.
73 *
74 * Als eigene Funktion, weil daran die Entscheidung hängt und sie sich sonst
75 * nur über das Vorstellen der Systemuhr prüfen ließe.
76 */
77 export function istFaellig(letzteMs: number | null, jetztMs: number): boolean {
78 if (letzteMs === null) {
79 return true;
80 }
81 /* Eine Kopie mit einem Zeitstempel aus der Zukunft – Uhrsprung, kopierter
82 Ordner – gilt als überfällig, nicht als frisch. Sonst unterbliebe die
83 Sicherung bis zu dem Tag, den ihr Stempel behauptet. */
84 return jetztMs - letzteMs >= ABSTAND_MS || letzteMs > jetztMs;
85 }
86
87 /**
88 * Legt eine Kopie an, wenn eine fällig ist.
89 *
90 * @returns Pfad der geschriebenen Kopie, oder `null`, wenn keine fällig war.
91 */
92 export function selbstsicherungAnlegen(
93 datenbank: BetterSqlite3.Database,
94 userData: string,
95 Datenbank: DatenbankKonstruktor,
96 jetzt: Date = new Date(),
97 ): string | null {
98 const ordner = selbstsicherungsordner(userData);
99 const bisher = vorhandene(ordner);
100 const letzte = bisher[0]?.zeit ?? null;
101
102 if (!istFaellig(letzte, jetzt.getTime())) {
103 return null;
104 }
105
106 mkdirSync(ordner, { recursive: true });
107
108 /* Sekundengenauer Name: Zwei Kopien in derselben Sekunde kann es nicht
109 geben, weil eine je Woche entsteht – der Stempel ist hier nur der
110 Ordnung wegen so genau. */
111 const stempel = `${String(jetzt.getFullYear())}-${zwei(jetzt.getMonth() + 1)}-${zwei(jetzt.getDate())}-${zwei(jetzt.getHours())}${zwei(jetzt.getMinutes())}${zwei(jetzt.getSeconds())}`;
112 const ziel = join(ordner, `${SELBSTSICHERUNG_VORSATZ}-${stempel}.wsklernstand`);
113 aufraeumen(ziel);
114
115 sicherungSchreiben(datenbank, ziel, Datenbank);
116 aufraeumenAlte(ordner);
117 return ziel;
118 }
119
120 function zwei(wert: number): string {
121 return String(wert).padStart(2, '0');
122 }
123
124 /** Nur die jüngsten Kopien bleiben; ausschließlich dieses Muster. */
125 function aufraeumenAlte(ordner: string): void {
126 try {
127 for (const alt of vorhandene(ordner).slice(KOPIEN_BEHALTEN)) {
128 aufraeumen(join(ordner, alt.name));
129 }
130 } catch {
131 /* Aufräumen ist Komfort. Misslingt es, bleiben ein paar Dateien mehr
132 liegen – kein Grund, eine gelungene Sicherung zu verwerfen. */
133 }
134 }
135
136 /** Vorsatz der Kopie vor einem zerstörenden Schritt – wieder ein eigener. */
137 export const VOR_DEM_VERWERFEN = 'Lernstand-vor-dem-Verwerfen';
138
139 /**
140 * Eine Sicherheitskopie vor „Neu anfangen“ und vor dem Löschen eines Profils.
141 *
142 * **Die Lücke, die sie schließt.** Beide Einspielwege legen selbstverständlich
143 * vorher eine Kopie an – das Einspielen einer fremden Sicherung
144 * (`VOR_DEM_EINSPIELEN`) und das Übernehmen eines Profils
145 * (`VOR_DEM_UEBERNEHMEN`). Die beiden Wege, die tatsächlich etwas vernichten,
146 * taten es nicht. Wer „Neu anfangen“ drückte oder ein Profil löschte, war den
147 * Lernstand los, und die selbsttätige Wochenkopie war je nach Tag bis zu
148 * sieben Tage alt.
149 *
150 * **Drei Unterschiede zur Wochenkopie.**
151 *
152 * 1. **Ungedrosselt.** {@link selbstsicherungAnlegen} schweigt, wenn die
153 * letzte Kopie noch frisch ist – hier wäre genau das der Ausfall.
154 * 2. **Eigener Vorsatz und eigenes Kontingent.** Sonst räumten die Wege sich
155 * gegenseitig ab, und die Kopie vor dem Verwerfen fiele der nächsten
156 * Wochenkopie zum Opfer.
157 * 3. **Sie wirft.** Die Wochenkopie ist eine Zugabe; misslingt sie, schließt
158 * das Fenster trotzdem. Diese hier steht vor einem Schritt, der Daten
159 * vernichtet: Lässt sie sich nicht schreiben, wird nichts vernichtet.
160 *
161 * @returns Pfad der geschriebenen Kopie.
162 */
163 export function kopieVorDemVerwerfen(
164 datenbank: BetterSqlite3.Database,
165 userData: string,
166 Datenbank: DatenbankKonstruktor,
167 jetzt: Date = new Date(),
168 ): string {
169 const ordner = selbstsicherungsordner(userData);
170 mkdirSync(ordner, { recursive: true });
171
172 /* Sekundengenau, und trotzdem mit Zähler: Zwei zerstörende Schritte in
173 derselben Sekunde sind unwahrscheinlich – aber „unwahrscheinlich“ ist
174 bei einer Sicherheitskopie das falsche Wort. */
175 let ziel = join(ordner, sicherungsDateiname(jetzt, VOR_DEM_VERWERFEN));
176 for (let zaehler = 2; existsSync(ziel) && zaehler < 100; zaehler += 1) {
177 ziel = join(
178 ordner,
179 sicherungsDateiname(jetzt, VOR_DEM_VERWERFEN).replace(
180 /\.wsklernstand$/u,
181 `-${String(zaehler)}.wsklernstand`,
182 ),
183 );
184 }
185
186 sicherungSchreiben(datenbank, ziel, Datenbank);
187 eigeneAufraeumen(ordner, VOR_DEM_VERWERFEN);
188 return ziel;
189 }
190
191 /** Nur die jüngsten Kopien eines Vorsatzes bleiben liegen. */
192 function eigeneAufraeumen(ordner: string, vorsatz: string): void {
193 try {
194 const kopien = readdirSync(ordner)
195 .filter((name) => name.startsWith(`${vorsatz}-`) && name.endsWith('.wsklernstand'))
196 .map((name) => ({ name, zeit: statSync(join(ordner, name)).mtimeMs }))
197 .sort((a, b) => b.zeit - a.zeit);
198 for (const alt of kopien.slice(KOPIEN_BEHALTEN)) {
199 aufraeumen(join(ordner, alt.name));
200 }
201 } catch {
202 /* Aufräumen ist Komfort – anders als das Schreiben selbst. */
203 }
204 }