/** * Die selbsttätige Sicherheitskopie des Lernstands. * * **Wozu.** Eine Sicherung entstand bis 0.22.0 ausschließlich, wenn jemand * die Karte „Lernstand sichern und übertragen“ aufschlug und bediente. Die * einzige selbsttätige Kopie entstand vor dem **Einspielen** einer fremden * Sicherung – also genau dann, wenn ohnehin jemand mit Sicherungen hantiert. * Wer die Karte nie öffnete, hatte nichts: Ein Datenträgerdefekt, ein * versehentliches Löschen oder eine beschädigte Datei nahmen alles mit, was * über Wochen gelernt worden war. * * Diese Kopie liegt auf demselben Datenträger und ersetzt deshalb **keine** * richtige Sicherung – das sagt die Karte auch. Sie deckt die häufigen Fälle: * die eine beschädigte Datei, den Fehlgriff, den missglückten Einspielvorgang. * Gegen einen Plattendefekt hilft nur eine Kopie anderswo, und dazu rät die * Anwendung weiterhin. * * **Warum beim Beenden.** Beim Start wäre die Kopie die des vorigen Standes * und ließe die Arbeit des letzten Tages aus. Beim Beenden ist sie so frisch * wie möglich. `VACUUM INTO` braucht dafür die offene Verbindung – der Aufruf * gehört also **vor** `lernstandSchliessen()`. * * **Warum nicht bei jedem Beenden.** Eine Kopie je Programmstart füllte den * Datenträger mit Fassungen, die sich um Minuten unterscheiden. Der Abstand * von einer Woche ist der Kompromiss: alt genug, dass sich etwas geändert * hat, jung genug, dass der Verlust überschaubar bleibt. */ import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; import type BetterSqlite3 from 'better-sqlite3'; import { aufraeumen, sicherungsDateiname, sicherungSchreiben, type DatenbankKonstruktor, } from './sicherung'; /** Unterordner im `userData`-Verzeichnis. */ export const SELBSTSICHERUNG_ORDNER = 'sicherungen'; /** Vorsatz im Dateinamen – hält die Kopien von allem anderen getrennt. */ export const SELBSTSICHERUNG_VORSATZ = 'Lernstand-selbsttaetig'; /** So lange gilt die letzte Kopie als frisch genug. */ export const ABSTAND_MS = 7 * 24 * 60 * 60 * 1000; /** So viele Kopien bleiben liegen. */ export const KOPIEN_BEHALTEN = 3; /** Voller Pfad des Kopienordners. */ export function selbstsicherungsordner(userData: string): string { return join(userData, SELBSTSICHERUNG_ORDNER); } /** Alle vorhandenen Kopien, jüngste zuerst. */ function vorhandene(ordner: string): { name: string; zeit: number }[] { if (!existsSync(ordner)) { return []; } return readdirSync(ordner) .filter( (name) => name.startsWith(`${SELBSTSICHERUNG_VORSATZ}-`) && name.endsWith('.wsklernstand'), ) .map((name) => ({ name, zeit: statSync(join(ordner, name)).mtimeMs })) .sort((a, b) => b.zeit - a.zeit); } /** * Ob eine neue Kopie fällig ist. * * Als eigene Funktion, weil daran die Entscheidung hängt und sie sich sonst * nur über das Vorstellen der Systemuhr prüfen ließe. */ export function istFaellig(letzteMs: number | null, jetztMs: number): boolean { if (letzteMs === null) { return true; } /* Eine Kopie mit einem Zeitstempel aus der Zukunft – Uhrsprung, kopierter Ordner – gilt als überfällig, nicht als frisch. Sonst unterbliebe die Sicherung bis zu dem Tag, den ihr Stempel behauptet. */ return jetztMs - letzteMs >= ABSTAND_MS || letzteMs > jetztMs; } /** * Legt eine Kopie an, wenn eine fällig ist. * * @returns Pfad der geschriebenen Kopie, oder `null`, wenn keine fällig war. */ export function selbstsicherungAnlegen( datenbank: BetterSqlite3.Database, userData: string, Datenbank: DatenbankKonstruktor, jetzt: Date = new Date(), ): string | null { const ordner = selbstsicherungsordner(userData); const bisher = vorhandene(ordner); const letzte = bisher[0]?.zeit ?? null; if (!istFaellig(letzte, jetzt.getTime())) { return null; } mkdirSync(ordner, { recursive: true }); /* Sekundengenauer Name: Zwei Kopien in derselben Sekunde kann es nicht geben, weil eine je Woche entsteht – der Stempel ist hier nur der Ordnung wegen so genau. */ const stempel = `${String(jetzt.getFullYear())}-${zwei(jetzt.getMonth() + 1)}-${zwei(jetzt.getDate())}-${zwei(jetzt.getHours())}${zwei(jetzt.getMinutes())}${zwei(jetzt.getSeconds())}`; const ziel = join(ordner, `${SELBSTSICHERUNG_VORSATZ}-${stempel}.wsklernstand`); aufraeumen(ziel); sicherungSchreiben(datenbank, ziel, Datenbank); aufraeumenAlte(ordner); return ziel; } function zwei(wert: number): string { return String(wert).padStart(2, '0'); } /** Nur die jüngsten Kopien bleiben; ausschließlich dieses Muster. */ function aufraeumenAlte(ordner: string): void { try { for (const alt of vorhandene(ordner).slice(KOPIEN_BEHALTEN)) { aufraeumen(join(ordner, alt.name)); } } catch { /* Aufräumen ist Komfort. Misslingt es, bleiben ein paar Dateien mehr liegen – kein Grund, eine gelungene Sicherung zu verwerfen. */ } } /** Vorsatz der Kopie vor einem zerstörenden Schritt – wieder ein eigener. */ export const VOR_DEM_VERWERFEN = 'Lernstand-vor-dem-Verwerfen'; /** * Eine Sicherheitskopie vor „Neu anfangen“ und vor dem Löschen eines Profils. * * **Die Lücke, die sie schließt.** Beide Einspielwege legen selbstverständlich * vorher eine Kopie an – das Einspielen einer fremden Sicherung * (`VOR_DEM_EINSPIELEN`) und das Übernehmen eines Profils * (`VOR_DEM_UEBERNEHMEN`). Die beiden Wege, die tatsächlich etwas vernichten, * taten es nicht. Wer „Neu anfangen“ drückte oder ein Profil löschte, war den * Lernstand los, und die selbsttätige Wochenkopie war je nach Tag bis zu * sieben Tage alt. * * **Drei Unterschiede zur Wochenkopie.** * * 1. **Ungedrosselt.** {@link selbstsicherungAnlegen} schweigt, wenn die * letzte Kopie noch frisch ist – hier wäre genau das der Ausfall. * 2. **Eigener Vorsatz und eigenes Kontingent.** Sonst räumten die Wege sich * gegenseitig ab, und die Kopie vor dem Verwerfen fiele der nächsten * Wochenkopie zum Opfer. * 3. **Sie wirft.** Die Wochenkopie ist eine Zugabe; misslingt sie, schließt * das Fenster trotzdem. Diese hier steht vor einem Schritt, der Daten * vernichtet: Lässt sie sich nicht schreiben, wird nichts vernichtet. * * @returns Pfad der geschriebenen Kopie. */ export function kopieVorDemVerwerfen( datenbank: BetterSqlite3.Database, userData: string, Datenbank: DatenbankKonstruktor, jetzt: Date = new Date(), ): string { const ordner = selbstsicherungsordner(userData); mkdirSync(ordner, { recursive: true }); /* Sekundengenau, und trotzdem mit Zähler: Zwei zerstörende Schritte in derselben Sekunde sind unwahrscheinlich – aber „unwahrscheinlich“ ist bei einer Sicherheitskopie das falsche Wort. */ let ziel = join(ordner, sicherungsDateiname(jetzt, VOR_DEM_VERWERFEN)); for (let zaehler = 2; existsSync(ziel) && zaehler < 100; zaehler += 1) { ziel = join( ordner, sicherungsDateiname(jetzt, VOR_DEM_VERWERFEN).replace( /\.wsklernstand$/u, `-${String(zaehler)}.wsklernstand`, ), ); } sicherungSchreiben(datenbank, ziel, Datenbank); eigeneAufraeumen(ordner, VOR_DEM_VERWERFEN); return ziel; } /** Nur die jüngsten Kopien eines Vorsatzes bleiben liegen. */ function eigeneAufraeumen(ordner: string, vorsatz: string): void { try { const kopien = readdirSync(ordner) .filter((name) => name.startsWith(`${vorsatz}-`) && 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 – anders als das Schreiben selbst. */ } }