/** * Ein einzelnes Profil aus einer Sicherung dazunehmen. * * ## Was das ist – und was ausdrücklich nicht * * Kopiert. Nicht verschmolzen. Das Profil kommt als **neues** Profil dazu; ein * gleichnamiges hier bleibt unberührt und behält seinen Namen, das * dazugekommene bekommt einen Zusatz. Zwei Historien desselben Profils * zusammenzurechnen ist begründet abgelehnt – siehe * `docs/entscheidung-lernstand-verschmelzen.md`. * * ## Warum es einen eigenen Dateivorsatz für die Sicherheitskopie gibt * * Das Ersetzen legt vor jedem Durchgang eine Sicherheitskopie unter dem * Vorsatz `Lernstand-vor-dem-Einspielen` an und behält davon drei. Würde das * Übernehmen denselben Vorsatz benutzen, löschten **drei harmlose * Übernahmen** den einzigen Rückweg eines vorangegangenen Ersetzens – ohne * dass es jemand erführe. Nachgestellt: 900 Antworten ersetzt, danach dreimal * übernommen, und keine der drei Dateien mit dem Namen „vor dem Einspielen“ * enthält noch den Stand von vor dem Einspielen. * * Die beiden Wege teilen sich deshalb nichts: eigener Vorsatz, eigenes * Kontingent. * * ## Warum ATTACH und eine einzige Transaktion * * `ATTACH` legt die Quelldatei neben die laufende Datenbank, ohne sie zu * öffnen; alles Weitere sind `INSERT … SELECT` in einer Transaktion. Ein Wurf * mittendrin rollt vollständig zurück – nachgemessen bleibt der Fingerabdruck * der laufenden Datenbank dabei unverändert. * * `VACUUM INTO` weigert sich innerhalb einer Transaktion; die Reihenfolge * „Sicherheitskopie, dann BEGIN“ ist damit erzwungen und nicht bloss gewählt. */ import { existsSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; import type BetterSqlite3 from 'better-sqlite3'; import type { Uebernahmeergebnis } from '../shared/sicherung'; import { aufraeumen, sicherungsDateiname, sicherungSchreiben } from './sicherung'; import type { DatenbankKonstruktor } from './sicherung'; /** Vorsatz der Sicherheitskopie vor dem Übernehmen – bewusst ein anderer. */ export const VOR_DEM_UEBERNEHMEN = 'Lernstand-vor-dem-Uebernehmen'; /** Eigenes Kontingent, damit die Wege sich nicht gegenseitig aufräumen. */ const KOPIEN_BEHALTEN = 3; /** Tabellen, die an einem Profil hängen, in der Reihenfolge des Einfügens. */ const KINDTABELLEN = ['frage_stand', 'antwort_log', 'pruefung_lauf'] as const; /** * Ein freier Name für die Kopie eines gleichnamigen Profils. * * „Olaf“ wird zu „Olaf (übernommen)“, dann „Olaf (übernommen 2)“. Ein * fortlaufender Zusatz statt eines Zeitstempels: Wer zwei Geräte hat, will * sie unterscheiden können, nicht Sekunden ablesen. */ export function freierProfilname(vorhanden: ReadonlySet, name: string): string { if (!vorhanden.has(name)) { return name; } const erster = `${name} (übernommen)`; if (!vorhanden.has(erster)) { return erster; } for (let zaehler = 2; zaehler < 1000; zaehler += 1) { const kandidat = `${name} (übernommen ${String(zaehler)})`; if (!vorhanden.has(kandidat)) { return kandidat; } } throw new Error(`Für „${name}“ ließ sich kein freier Profilname finden.`); } /** Spaltennamen einer Tabelle in einer bestimmten Datenbank. */ function spalten(db: BetterSqlite3.Database, bereich: string, tabelle: string): string[] { return db .prepare<[], { name: string }>(`PRAGMA ${bereich}.table_info(${tabelle})`) .all() .map((z) => z.name); } /** Existiert die Tabelle im angehängten Bereich? */ function hatTabelle(db: BetterSqlite3.Database, bereich: string, tabelle: string): boolean { return ( (db .prepare<[string], { anzahl: number }>( `SELECT COUNT(*) AS anzahl FROM ${bereich}.sqlite_master WHERE type = 'table' AND name = ?`, ) .get(tabelle)?.anzahl ?? 0) > 0 ); } export interface Uebernahmeauftrag { /** Die laufende, geöffnete Datenbank. */ readonly ziel: BetterSqlite3.Database; /** Pfad der geprüften Arbeitskopie. */ readonly quelle: string; /** Nummer des Profils IN DER QUELLE. */ readonly quellProfilId: number; /** Verzeichnis für die Sicherheitskopie. */ readonly ordner: string; readonly jetzt: Date; readonly Datenbank: DatenbankKonstruktor; /** Katalogstand dieses Rechners – Maßstab für den Vergleich. */ readonly katalogstand: string; } /** * Führt die Übernahme aus. * * Reihenfolge, und jeder Schritt ist Bedingung für den nächsten: * Sicherheitskopie schreiben, anhängen, in **einer** Transaktion Profil und * Kindzeilen einfügen, abhängen. Misslingt die Sicherheitskopie, wird nichts * angefasst. */ export function profilUebernehmen(auftrag: Uebernahmeauftrag): Uebernahmeergebnis { const { ziel, quelle, quellProfilId, ordner, jetzt, Datenbank, katalogstand } = auftrag; let quellstand: string | null = null; if (!existsSync(quelle)) { return { art: 'gescheitert', grund: 'Die geprüfte Datei ist nicht mehr da.' }; } // ── Sicherheitskopie. Ohne sie wird nichts angefasst. ──────────────── let sicherheitskopie: string; try { sicherheitskopie = freierName(ordner, jetzt); sicherungSchreiben(ziel, sicherheitskopie, Datenbank); } catch (fehler) { return { art: 'gescheitert', grund: 'Es ließ sich keine Sicherung Ihres jetzigen Lernstands anlegen, deshalb wurde nichts ' + `übernommen. Möglicherweise ist der Speicherplatz erschöpft. (${fehlertext(fehler)})`, }; } let name = ''; let antworten = 0; let staende = 0; let neueId = 0; try { ziel.prepare('ATTACH DATABASE ? AS quelle').run(quelle); } catch (fehler) { return { art: 'gescheitert', grund: `Die Datei ließ sich nicht öffnen. (${fehlertext(fehler)})`, }; } try { /* `kapitel_ausschluss` hängt am Profil und nicht in einer Kindtabelle – die Schleife über KINDTABELLEN erfasst sie deshalb nicht. Bis 0.26.4 stand sie auch hier nicht, und die Abwahl fiel beim Übernehmen auf den Vorgabewert zurück: Ein abgewähltes Kapitel kam auf dem zweiten Rechner stillschweigend wieder, und die Prüfungsreife fiel um zwei Ampelstufen (`shared/verlust.ts`: 85,0 auf 71,9 Prozent). Abgefragt wird sie nur, wenn die Quelle sie kennt – eine Sicherung von vor 0.20.0 hat die Spalte nicht, und dort ist der Vorgabewert „nichts abgewählt“ die richtige Annahme. */ const kennteAusschluss = spalten(ziel, 'quelle', 'profil').includes('kapitel_ausschluss'); /* Unter welchem Katalogstand die Quelle gelernt wurde. Sicherungen vor Schemafassung 9 führen die Tabelle nicht – dann bleibt es bei `null`, und die Oberfläche sagt nichts. Eine Warnung „unbekannter Stand" wäre lauter als ihr Inhalt. */ quellstand = hatTabelle(ziel, 'quelle', 'katalog_stand') ? (ziel .prepare<[], { stand: string }>( 'SELECT stand FROM quelle.katalog_stand ORDER BY id DESC LIMIT 1', ) .get()?.stand ?? null) : null; const quellprofil = ziel .prepare< [number], { name: string; pruefungstermin: string | null; kapitel_ausschluss?: string } >( `SELECT name, pruefungstermin${kennteAusschluss ? ', kapitel_ausschluss' : ''} FROM quelle.profil WHERE id = ?`, ) .get(quellProfilId); if (quellprofil === undefined) { return { art: 'gescheitert', grund: 'Dieses Profil steht nicht in der Datei.' }; } const vorhanden = new Set( ziel .prepare<[], { name: string }>('SELECT name FROM main.profil') .all() .map((z) => z.name), ); name = freierProfilname(vorhanden, quellprofil.name); /* Eine Transaktion für alles. Ein Wurf mittendrin lässt die laufende Datenbank unverändert – nachgemessen bleibt ihr Fingerabdruck gleich. */ const uebernehmen = ziel.transaction(() => { const eingefuegt = ziel .prepare<[string, string | null, string, string]>( `INSERT INTO main.profil (name, pruefungstermin, kapitel_ausschluss, erstellt_am) VALUES (?, ?, ?, ?)`, ) .run( name, quellprofil.pruefungstermin, quellprofil.kapitel_ausschluss ?? '[]', jetzt.toISOString(), ); neueId = Number(eingefuegt.lastInsertRowid); for (const tabelle of KINDTABELLEN) { if (!hatTabelle(ziel, 'quelle', tabelle)) { continue; } /* Nur die Spalten, die BEIDE Seiten kennen. Eine ältere Sicherung kennt `bestaetigt` und `nur_historie` nicht; sie zu verlangen bräche die Übernahme, und sie zu erfinden wäre schlechter als sie wegzulassen – die Vorgabewerte des Schemas greifen dann. */ const gemeinsam = spalten(ziel, 'main', tabelle).filter( (spalte) => spalte !== 'id' && spalten(ziel, 'quelle', tabelle).includes(spalte), ); const felder = gemeinsam.map((s) => (s === 'profil_id' ? String(neueId) : `"${s}"`)); ziel .prepare<[number]>( `INSERT INTO main.${tabelle} (${gemeinsam.map((s) => `"${s}"`).join(', ')}) SELECT ${felder.join(', ')} FROM quelle.${tabelle} WHERE profil_id = ?`, ) .run(quellProfilId); } /* Gezählt wird für die Meldung getrennt vom Einfügen. Bis 0.27.2 stand hier die Zahl der eingefügten Zeilen, und die sagt etwas anderes als die Meldung darüber. `antwort_log` enthält seit Schemafassung 8 auch Zeilen mit `nur_historie = 1` – Fragen eines abgelaufenen Bogens, die nie aufgeschlagen wurden; die Auswahlliste, aus der der Nutzer das Profil gerade gewählt hat, filtert sie ausdrücklich aus (`kennzahlenLesen` in `sicherung.ts`). Und in `frage_stand` steht eine Zeile schon, wenn eine Frage nur gemerkt wurde – „Antworten zu N verschiedenen Fragen“ zählte sie mit. Mitgenommen wird beides unverändert; nur gezählt wird, was die Meldung behauptet. */ const zaehlen = (sql: string): number => ziel.prepare<[number], { anzahl: number }>(sql).get(neueId)?.anzahl ?? 0; const hatNurHistorie = spalten(ziel, 'main', 'antwort_log').includes('nur_historie'); antworten = zaehlen( hatNurHistorie ? 'SELECT COUNT(*) AS anzahl FROM main.antwort_log WHERE profil_id = ? AND nur_historie = 0' : 'SELECT COUNT(*) AS anzahl FROM main.antwort_log WHERE profil_id = ?', ); staende = zaehlen( hatNurHistorie ? 'SELECT COUNT(DISTINCT frage_id) AS anzahl FROM main.antwort_log WHERE profil_id = ? AND nur_historie = 0' : 'SELECT COUNT(DISTINCT frage_id) AS anzahl FROM main.antwort_log WHERE profil_id = ?', ); }); uebernehmen(); } catch (fehler) { return { art: 'gescheitert', grund: `Das Profil ließ sich nicht übernehmen; es wurde nichts verändert. (${fehlertext(fehler)}) ` + `Eine Sicherung Ihres Stands liegt als „${blossName(sicherheitskopie)}“ bereit.`, }; } finally { try { ziel.prepare('DETACH DATABASE quelle').run(); } catch { /* Ein hängengebliebener Anhang wäre schlimmer als eine Meldung, aber abbrechen lässt sich hier nichts mehr – die Arbeit ist getan. */ } } kopienAufraeumen(ordner); return { art: 'uebernommen', profilId: neueId, name, antworten, bearbeiteteFragen: staende, sicherheitskopie: blossName(sicherheitskopie), /* Nur melden, wenn der Stand bekannt UND ein anderer ist – sonst stünde bei jeder Übernahme eine Zeile, die nichts sagt. */ katalogstandDerQuelle: quellstand !== null && quellstand !== katalogstand ? quellstand : null, }; } function blossName(pfad: string): string { return pfad.split(/[\\/]/u).pop() ?? pfad; } function fehlertext(fehler: unknown): string { return fehler instanceof Error ? fehler.message : String(fehler); } function freierName(ordner: string, jetzt: Date): string { const grund = join(ordner, sicherungsDateiname(jetzt, VOR_DEM_UEBERNEHMEN)); if (!existsSync(grund)) { return grund; } for (let zaehler = 2; zaehler < 100; zaehler += 1) { const kandidat = grund.replace(/\.wsklernstand$/u, `-${String(zaehler)}.wsklernstand`); if (!existsSync(kandidat)) { return kandidat; } } throw new Error('Es ließ sich kein freier Name für die Sicherheitskopie finden.'); } /** Räumt ausschliesslich die eigenen Kopien ab – nie die des Ersetzens. */ function kopienAufraeumen(ordner: string): void { try { const kopien = readdirSync(ordner) .filter( (name) => name.startsWith(`${VOR_DEM_UEBERNEHMEN}-`) && name.endsWith('.wsklernstand'), ) .map((name) => ({ name, zeit: statSync(join(ordner, name)).mtimeMs })) .sort((a, b) => b.zeit - a.zeit); for (const alt of kopien.slice(KOPIEN_BEHALTEN)) { aufraeumen(join(ordner, alt.name)); } } catch { /* Aufräumen ist Komfort. Misslingt es, bleiben ein paar Dateien mehr liegen – kein Grund, einen erfolgreichen Vorgang anders zu melden. */ } }