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