waffensachkunde

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

/ app src main profil-uebernehmen.ts

11,7 KB Rohdatei
app/src/main/profil-uebernehmen.ts — 316 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 /** Katalogstand dieses Rechners – Maßstab für den Vergleich. */
109 readonly katalogstand: string;
110 }
111
112 /**
113 * Führt die Übernahme aus.
114 *
115 * Reihenfolge, und jeder Schritt ist Bedingung für den nächsten:
116 * Sicherheitskopie schreiben, anhängen, in **einer** Transaktion Profil und
117 * Kindzeilen einfügen, abhängen. Misslingt die Sicherheitskopie, wird nichts
118 * angefasst.
119 */
120 export function profilUebernehmen(auftrag: Uebernahmeauftrag): Uebernahmeergebnis {
121 const { ziel, quelle, quellProfilId, ordner, jetzt, Datenbank, katalogstand } = auftrag;
122 let quellstand: string | null = null;
123
124 if (!existsSync(quelle)) {
125 return { art: 'gescheitert', grund: 'Die geprüfte Datei ist nicht mehr da.' };
126 }
127
128 // ── Sicherheitskopie. Ohne sie wird nichts angefasst. ────────────────
129 let sicherheitskopie: string;
130 try {
131 sicherheitskopie = freierName(ordner, jetzt);
132 sicherungSchreiben(ziel, sicherheitskopie, Datenbank);
133 } catch (fehler) {
134 return {
135 art: 'gescheitert',
136 grund:
137 'Es ließ sich keine Sicherung Ihres jetzigen Lernstands anlegen, deshalb wurde nichts ' +
138 `übernommen. Möglicherweise ist der Speicherplatz erschöpft. (${fehlertext(fehler)})`,
139 };
140 }
141
142 let name = '';
143 let antworten = 0;
144 let staende = 0;
145 let neueId = 0;
146
147 try {
148 ziel.prepare('ATTACH DATABASE ? AS quelle').run(quelle);
149 } catch (fehler) {
150 return {
151 art: 'gescheitert',
152 grund: `Die Datei ließ sich nicht öffnen. (${fehlertext(fehler)})`,
153 };
154 }
155
156 try {
157 /* `kapitel_ausschluss` hängt am Profil und nicht in einer Kindtabelle –
158 die Schleife über KINDTABELLEN erfasst sie deshalb nicht. Bis 0.26.4
159 stand sie auch hier nicht, und die Abwahl fiel beim Übernehmen auf
160 den Vorgabewert zurück: Ein abgewähltes Kapitel kam auf dem zweiten
161 Rechner stillschweigend wieder, und die Prüfungsreife fiel um zwei
162 Ampelstufen (`shared/verlust.ts`: 85,0 auf 71,9 Prozent).
163
164 Abgefragt wird sie nur, wenn die Quelle sie kennt – eine Sicherung
165 von vor 0.20.0 hat die Spalte nicht, und dort ist der Vorgabewert
166 „nichts abgewählt“ die richtige Annahme. */
167 const kennteAusschluss = spalten(ziel, 'quelle', 'profil').includes('kapitel_ausschluss');
168
169 /* Unter welchem Katalogstand die Quelle gelernt wurde. Sicherungen vor
170 Schemafassung 9 führen die Tabelle nicht – dann bleibt es bei `null`,
171 und die Oberfläche sagt nichts. Eine Warnung „unbekannter Stand" wäre
172 lauter als ihr Inhalt. */
173 quellstand = hatTabelle(ziel, 'quelle', 'katalog_stand')
174 ? (ziel
175 .prepare<[], { stand: string }>(
176 'SELECT stand FROM quelle.katalog_stand ORDER BY id DESC LIMIT 1',
177 )
178 .get()?.stand ?? null)
179 : null;
180
181 const quellprofil = ziel
182 .prepare<
183 [number],
184 { name: string; pruefungstermin: string | null; kapitel_ausschluss?: string }
185 >(
186 `SELECT name, pruefungstermin${kennteAusschluss ? ', kapitel_ausschluss' : ''}
187 FROM quelle.profil WHERE id = ?`,
188 )
189 .get(quellProfilId);
190
191 if (quellprofil === undefined) {
192 return { art: 'gescheitert', grund: 'Dieses Profil steht nicht in der Datei.' };
193 }
194
195 const vorhanden = new Set(
196 ziel
197 .prepare<[], { name: string }>('SELECT name FROM main.profil')
198 .all()
199 .map((z) => z.name),
200 );
201 name = freierProfilname(vorhanden, quellprofil.name);
202
203 /* Eine Transaktion für alles. Ein Wurf mittendrin lässt die laufende
204 Datenbank unverändert – nachgemessen bleibt ihr Fingerabdruck gleich. */
205 const uebernehmen = ziel.transaction(() => {
206 const eingefuegt = ziel
207 .prepare<[string, string | null, string, string]>(
208 `INSERT INTO main.profil (name, pruefungstermin, kapitel_ausschluss, erstellt_am)
209 VALUES (?, ?, ?, ?)`,
210 )
211 .run(
212 name,
213 quellprofil.pruefungstermin,
214 quellprofil.kapitel_ausschluss ?? '[]',
215 jetzt.toISOString(),
216 );
217 neueId = Number(eingefuegt.lastInsertRowid);
218
219 for (const tabelle of KINDTABELLEN) {
220 if (!hatTabelle(ziel, 'quelle', tabelle)) {
221 continue;
222 }
223 /* Nur die Spalten, die BEIDE Seiten kennen. Eine ältere Sicherung
224 kennt `bestaetigt` und `nur_historie` nicht; sie zu verlangen
225 bräche die Übernahme, und sie zu erfinden wäre schlechter als sie
226 wegzulassen – die Vorgabewerte des Schemas greifen dann. */
227 const gemeinsam = spalten(ziel, 'main', tabelle).filter(
228 (spalte) => spalte !== 'id' && spalten(ziel, 'quelle', tabelle).includes(spalte),
229 );
230 const felder = gemeinsam.map((s) => (s === 'profil_id' ? String(neueId) : `"${s}"`));
231 const anzahl = ziel
232 .prepare<[number]>(
233 `INSERT INTO main.${tabelle} (${gemeinsam.map((s) => `"${s}"`).join(', ')})
234 SELECT ${felder.join(', ')} FROM quelle.${tabelle} WHERE profil_id = ?`,
235 )
236 .run(quellProfilId).changes;
237
238 if (tabelle === 'antwort_log') {
239 antworten = anzahl;
240 } else if (tabelle === 'frage_stand') {
241 staende = anzahl;
242 }
243 }
244 });
245
246 uebernehmen();
247 } catch (fehler) {
248 return {
249 art: 'gescheitert',
250 grund:
251 `Das Profil ließ sich nicht übernehmen; es wurde nichts verändert. (${fehlertext(fehler)}) ` +
252 `Eine Sicherung Ihres Stands liegt als „${blossName(sicherheitskopie)}“ bereit.`,
253 };
254 } finally {
255 try {
256 ziel.prepare('DETACH DATABASE quelle').run();
257 } catch {
258 /* Ein hängengebliebener Anhang wäre schlimmer als eine Meldung, aber
259 abbrechen lässt sich hier nichts mehr – die Arbeit ist getan. */
260 }
261 }
262
263 kopienAufraeumen(ordner);
264
265 return {
266 art: 'uebernommen',
267 profilId: neueId,
268 name,
269 antworten,
270 bearbeiteteFragen: staende,
271 sicherheitskopie: blossName(sicherheitskopie),
272 /* Nur melden, wenn der Stand bekannt UND ein anderer ist – sonst stünde
273 bei jeder Übernahme eine Zeile, die nichts sagt. */
274 katalogstandDerQuelle: quellstand !== null && quellstand !== katalogstand ? quellstand : null,
275 };
276 }
277
278 function blossName(pfad: string): string {
279 return pfad.split(/[\\/]/u).pop() ?? pfad;
280 }
281
282 function fehlertext(fehler: unknown): string {
283 return fehler instanceof Error ? fehler.message : String(fehler);
284 }
285
286 function freierName(ordner: string, jetzt: Date): string {
287 const grund = join(ordner, sicherungsDateiname(jetzt, VOR_DEM_UEBERNEHMEN));
288 if (!existsSync(grund)) {
289 return grund;
290 }
291 for (let zaehler = 2; zaehler < 100; zaehler += 1) {
292 const kandidat = grund.replace(/\.wsklernstand$/u, `-${String(zaehler)}.wsklernstand`);
293 if (!existsSync(kandidat)) {
294 return kandidat;
295 }
296 }
297 throw new Error('Es ließ sich kein freier Name für die Sicherheitskopie finden.');
298 }
299
300 /** Räumt ausschliesslich die eigenen Kopien ab – nie die des Ersetzens. */
301 function kopienAufraeumen(ordner: string): void {
302 try {
303 const kopien = readdirSync(ordner)
304 .filter(
305 (name) => name.startsWith(`${VOR_DEM_UEBERNEHMEN}-`) && name.endsWith('.wsklernstand'),
306 )
307 .map((name) => ({ name, zeit: statSync(join(ordner, name)).mtimeMs }))
308 .sort((a, b) => b.zeit - a.zeit);
309 for (const alt of kopien.slice(KOPIEN_BEHALTEN)) {
310 aufraeumen(join(ordner, alt.name));
311 }
312 } catch {
313 /* Aufräumen ist Komfort. Misslingt es, bleiben ein paar Dateien mehr
314 liegen – kein Grund, einen erfolgreichen Vorgang anders zu melden. */
315 }
316 }