waffensachkunde

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

/ app src main profil-uebernehmen.ts

10,8 KB Rohdatei
app/src/main/profil-uebernehmen.ts — 298 Zeilen
1 /**
2 * Ein einzelnes Profil aus einer Sicherung dazunehmen.
3 *
4 * ## Was das ist – und was ausdrücklich nicht
5 *
6 * Kopiert. Nicht verschmolzen. Das Profil kommt als **neues** Profil dazu; ein
7 * gleichnamiges hier bleibt unberührt und behält seinen Namen, das
8 * dazugekommene bekommt einen Zusatz. Zwei Historien desselben Profils
9 * zusammenzurechnen ist begründet abgelehnt – siehe
10 * `docs/entscheidung-lernstand-verschmelzen.md`.
11 *
12 * ## Warum es einen eigenen Dateivorsatz für die Sicherheitskopie gibt
13 *
14 * Das Ersetzen legt vor jedem Durchgang eine Sicherheitskopie unter dem
15 * Vorsatz `Lernstand-vor-dem-Einspielen` an und behält davon drei. Würde das
16 * Übernehmen denselben Vorsatz benutzen, löschten **drei harmlose
17 * Übernahmen** den einzigen Rückweg eines vorangegangenen Ersetzens – ohne
18 * dass es jemand erführe. Nachgestellt: 900 Antworten ersetzt, danach dreimal
19 * übernommen, und keine der drei Dateien mit dem Namen „vor dem Einspielen“
20 * enthält noch den Stand von vor dem Einspielen.
21 *
22 * Die beiden Wege teilen sich deshalb nichts: eigener Vorsatz, eigenes
23 * Kontingent.
24 *
25 * ## Warum ATTACH und eine einzige Transaktion
26 *
27 * `ATTACH` legt die Quelldatei neben die laufende Datenbank, ohne sie zu
28 * öffnen; alles Weitere sind `INSERT … SELECT` in einer Transaktion. Ein Wurf
29 * mittendrin rollt vollständig zurück – nachgemessen bleibt der Fingerabdruck
30 * der laufenden Datenbank dabei unverändert.
31 *
32 * `VACUUM INTO` weigert sich innerhalb einer Transaktion; die Reihenfolge
33 * „Sicherheitskopie, dann BEGIN“ ist damit erzwungen und nicht bloss gewählt.
34 */
35
36 import { existsSync, readdirSync, statSync } from 'node:fs';
37 import { join } from 'node:path';
38
39 import type BetterSqlite3 from 'better-sqlite3';
40
41 import type { Uebernahmeergebnis } from '../shared/sicherung';
42 import { aufraeumen, sicherungsDateiname, sicherungSchreiben } from './sicherung';
43 import type { DatenbankKonstruktor } from './sicherung';
44
45 /** Vorsatz der Sicherheitskopie vor dem Übernehmen – bewusst ein anderer. */
46 export const VOR_DEM_UEBERNEHMEN = 'Lernstand-vor-dem-Uebernehmen';
47
48 /** Eigenes Kontingent, damit die Wege sich nicht gegenseitig aufräumen. */
49 const KOPIEN_BEHALTEN = 3;
50
51 /** Tabellen, die an einem Profil hängen, in der Reihenfolge des Einfügens. */
52 const KINDTABELLEN = ['frage_stand', 'antwort_log', 'pruefung_lauf'] as const;
53
54 /**
55 * Ein freier Name für die Kopie eines gleichnamigen Profils.
56 *
57 * „Olaf“ wird zu „Olaf (übernommen)“, dann „Olaf (übernommen 2)“. Ein
58 * fortlaufender Zusatz statt eines Zeitstempels: Wer zwei Geräte hat, will
59 * sie unterscheiden können, nicht Sekunden ablesen.
60 */
61 export function freierProfilname(vorhanden: ReadonlySet<string>, name: string): string {
62 if (!vorhanden.has(name)) {
63 return name;
64 }
65 const erster = `${name} (übernommen)`;
66 if (!vorhanden.has(erster)) {
67 return erster;
68 }
69 for (let zaehler = 2; zaehler < 1000; zaehler += 1) {
70 const kandidat = `${name} (übernommen ${String(zaehler)})`;
71 if (!vorhanden.has(kandidat)) {
72 return kandidat;
73 }
74 }
75 throw new Error(`Für „${name}“ ließ sich kein freier Profilname finden.`);
76 }
77
78 /** Spaltennamen einer Tabelle in einer bestimmten Datenbank. */
79 function spalten(db: BetterSqlite3.Database, bereich: string, tabelle: string): string[] {
80 return db
81 .prepare<[], { name: string }>(`PRAGMA ${bereich}.table_info(${tabelle})`)
82 .all()
83 .map((z) => z.name);
84 }
85
86 /** Existiert die Tabelle im angehängten Bereich? */
87 function hatTabelle(db: BetterSqlite3.Database, bereich: string, tabelle: string): boolean {
88 return (
89 (db
90 .prepare<[string], { anzahl: number }>(
91 `SELECT COUNT(*) AS anzahl FROM ${bereich}.sqlite_master WHERE type = 'table' AND name = ?`,
92 )
93 .get(tabelle)?.anzahl ?? 0) > 0
94 );
95 }
96
97 export interface Uebernahmeauftrag {
98 /** Die laufende, geöffnete Datenbank. */
99 readonly ziel: BetterSqlite3.Database;
100 /** Pfad der geprüften Arbeitskopie. */
101 readonly quelle: string;
102 /** Nummer des Profils IN DER QUELLE. */
103 readonly quellProfilId: number;
104 /** Verzeichnis für die Sicherheitskopie. */
105 readonly ordner: string;
106 readonly jetzt: Date;
107 readonly Datenbank: DatenbankKonstruktor;
108 }
109
110 /**
111 * Führt die Übernahme aus.
112 *
113 * Reihenfolge, und jeder Schritt ist Bedingung für den nächsten:
114 * Sicherheitskopie schreiben, anhängen, in **einer** Transaktion Profil und
115 * Kindzeilen einfügen, abhängen. Misslingt die Sicherheitskopie, wird nichts
116 * angefasst.
117 */
118 export function profilUebernehmen(auftrag: Uebernahmeauftrag): Uebernahmeergebnis {
119 const { ziel, quelle, quellProfilId, ordner, jetzt, Datenbank } = auftrag;
120
121 if (!existsSync(quelle)) {
122 return { art: 'gescheitert', grund: 'Die geprüfte Datei ist nicht mehr da.' };
123 }
124
125 // ── Sicherheitskopie. Ohne sie wird nichts angefasst. ────────────────
126 let sicherheitskopie: string;
127 try {
128 sicherheitskopie = freierName(ordner, jetzt);
129 sicherungSchreiben(ziel, sicherheitskopie, Datenbank);
130 } catch (fehler) {
131 return {
132 art: 'gescheitert',
133 grund:
134 'Es ließ sich keine Sicherung Ihres jetzigen Lernstands anlegen, deshalb wurde nichts ' +
135 `übernommen. Möglicherweise ist der Speicherplatz erschöpft. (${fehlertext(fehler)})`,
136 };
137 }
138
139 let name = '';
140 let antworten = 0;
141 let staende = 0;
142 let neueId = 0;
143
144 try {
145 ziel.prepare('ATTACH DATABASE ? AS quelle').run(quelle);
146 } catch (fehler) {
147 return {
148 art: 'gescheitert',
149 grund: `Die Datei ließ sich nicht öffnen. (${fehlertext(fehler)})`,
150 };
151 }
152
153 try {
154 /* `kapitel_ausschluss` hängt am Profil und nicht in einer Kindtabelle –
155 die Schleife über KINDTABELLEN erfasst sie deshalb nicht. Bis 0.26.4
156 stand sie auch hier nicht, und die Abwahl fiel beim Übernehmen auf
157 den Vorgabewert zurück: Ein abgewähltes Kapitel kam auf dem zweiten
158 Rechner stillschweigend wieder, und die Prüfungsreife fiel um zwei
159 Ampelstufen (`shared/verlust.ts`: 85,0 auf 71,9 Prozent).
160
161 Abgefragt wird sie nur, wenn die Quelle sie kennt – eine Sicherung
162 von vor 0.20.0 hat die Spalte nicht, und dort ist der Vorgabewert
163 „nichts abgewählt“ die richtige Annahme. */
164 const kennteAusschluss = spalten(ziel, 'quelle', 'profil').includes('kapitel_ausschluss');
165
166 const quellprofil = ziel
167 .prepare<
168 [number],
169 { name: string; pruefungstermin: string | null; kapitel_ausschluss?: string }
170 >(
171 `SELECT name, pruefungstermin${kennteAusschluss ? ', kapitel_ausschluss' : ''}
172 FROM quelle.profil WHERE id = ?`,
173 )
174 .get(quellProfilId);
175
176 if (quellprofil === undefined) {
177 return { art: 'gescheitert', grund: 'Dieses Profil steht nicht in der Datei.' };
178 }
179
180 const vorhanden = new Set(
181 ziel
182 .prepare<[], { name: string }>('SELECT name FROM main.profil')
183 .all()
184 .map((z) => z.name),
185 );
186 name = freierProfilname(vorhanden, quellprofil.name);
187
188 /* Eine Transaktion für alles. Ein Wurf mittendrin lässt die laufende
189 Datenbank unverändert – nachgemessen bleibt ihr Fingerabdruck gleich. */
190 const uebernehmen = ziel.transaction(() => {
191 const eingefuegt = ziel
192 .prepare<[string, string | null, string, string]>(
193 `INSERT INTO main.profil (name, pruefungstermin, kapitel_ausschluss, erstellt_am)
194 VALUES (?, ?, ?, ?)`,
195 )
196 .run(
197 name,
198 quellprofil.pruefungstermin,
199 quellprofil.kapitel_ausschluss ?? '[]',
200 jetzt.toISOString(),
201 );
202 neueId = Number(eingefuegt.lastInsertRowid);
203
204 for (const tabelle of KINDTABELLEN) {
205 if (!hatTabelle(ziel, 'quelle', tabelle)) {
206 continue;
207 }
208 /* Nur die Spalten, die BEIDE Seiten kennen. Eine ältere Sicherung
209 kennt `bestaetigt` und `nur_historie` nicht; sie zu verlangen
210 bräche die Übernahme, und sie zu erfinden wäre schlechter als sie
211 wegzulassen – die Vorgabewerte des Schemas greifen dann. */
212 const gemeinsam = spalten(ziel, 'main', tabelle).filter(
213 (spalte) => spalte !== 'id' && spalten(ziel, 'quelle', tabelle).includes(spalte),
214 );
215 const felder = gemeinsam.map((s) => (s === 'profil_id' ? String(neueId) : `"${s}"`));
216 const anzahl = ziel
217 .prepare<[number]>(
218 `INSERT INTO main.${tabelle} (${gemeinsam.map((s) => `"${s}"`).join(', ')})
219 SELECT ${felder.join(', ')} FROM quelle.${tabelle} WHERE profil_id = ?`,
220 )
221 .run(quellProfilId).changes;
222
223 if (tabelle === 'antwort_log') {
224 antworten = anzahl;
225 } else if (tabelle === 'frage_stand') {
226 staende = anzahl;
227 }
228 }
229 });
230
231 uebernehmen();
232 } catch (fehler) {
233 return {
234 art: 'gescheitert',
235 grund:
236 `Das Profil ließ sich nicht übernehmen; es wurde nichts verändert. (${fehlertext(fehler)}) ` +
237 `Eine Sicherung Ihres Stands liegt als „${blossName(sicherheitskopie)}“ bereit.`,
238 };
239 } finally {
240 try {
241 ziel.prepare('DETACH DATABASE quelle').run();
242 } catch {
243 /* Ein hängengebliebener Anhang wäre schlimmer als eine Meldung, aber
244 abbrechen lässt sich hier nichts mehr – die Arbeit ist getan. */
245 }
246 }
247
248 kopienAufraeumen(ordner);
249
250 return {
251 art: 'uebernommen',
252 profilId: neueId,
253 name,
254 antworten,
255 bearbeiteteFragen: staende,
256 sicherheitskopie: blossName(sicherheitskopie),
257 };
258 }
259
260 function blossName(pfad: string): string {
261 return pfad.split(/[\\/]/u).pop() ?? pfad;
262 }
263
264 function fehlertext(fehler: unknown): string {
265 return fehler instanceof Error ? fehler.message : String(fehler);
266 }
267
268 function freierName(ordner: string, jetzt: Date): string {
269 const grund = join(ordner, sicherungsDateiname(jetzt, VOR_DEM_UEBERNEHMEN));
270 if (!existsSync(grund)) {
271 return grund;
272 }
273 for (let zaehler = 2; zaehler < 100; zaehler += 1) {
274 const kandidat = grund.replace(/\.wsklernstand$/u, `-${String(zaehler)}.wsklernstand`);
275 if (!existsSync(kandidat)) {
276 return kandidat;
277 }
278 }
279 throw new Error('Es ließ sich kein freier Name für die Sicherheitskopie finden.');
280 }
281
282 /** Räumt ausschliesslich die eigenen Kopien ab – nie die des Ersetzens. */
283 function kopienAufraeumen(ordner: string): void {
284 try {
285 const kopien = readdirSync(ordner)
286 .filter(
287 (name) => name.startsWith(`${VOR_DEM_UEBERNEHMEN}-`) && name.endsWith('.wsklernstand'),
288 )
289 .map((name) => ({ name, zeit: statSync(join(ordner, name)).mtimeMs }))
290 .sort((a, b) => b.zeit - a.zeit);
291 for (const alt of kopien.slice(KOPIEN_BEHALTEN)) {
292 aufraeumen(join(ordner, alt.name));
293 }
294 } catch {
295 /* Aufräumen ist Komfort. Misslingt es, bleiben ein paar Dateien mehr
296 liegen – kein Grund, einen erfolgreichen Vorgang anders zu melden. */
297 }
298 }