/** * Sicherung anlegen und einspielen – der Ablauf mit Dialogen. * * Die reine Datei- und Datenbankarbeit steht in `sicherung.ts` und ist dort * ohne Electron prüfbar. Hier steht, in welcher **Reihenfolge** sie geschieht, * und die Reihenfolge ist beim Einspielen der ganze Schutz. * * ## Warum das Einspielen zwei Kanäle braucht * * Zwischen „diese Datei ist brauchbar, sie enthält Folgendes“ und „ja, ersetze * meinen Lernstand damit“ liegt eine Entscheidung des Nutzers. Ein einziger * Kanal müsste sie im Hauptprozess abwarten – und träfe sie damit im selben * Aufruf, in dem er schon schreibt. Getrennt ist der Prüfschritt folgenlos: * Bis der zweite Kanal gerufen wird, ist nichts angefasst. * * ## Die gefährlichste Einzelheit * * Ein verwaistes `lernstand.db-wal` neben einer frisch eingesetzten Datenbank * ist der einzige gemessene Weg, in diesem Vorgang wirklich Daten zu * vernichten: SQLite spielt das alte Schreibprotokoll auf die neue Datei, der * eingespielte Stand ist danach **vollständig fort**, und `integrity_check` * meldet „ok“. Kein Dateikopf und keine Prüfsumme schützen davor – nur die * Reihenfolge in {@link einspielen}: erst schliessen, dann sicherstellen, dass * die Nebendateien wirklich weg sind, und nur dann ersetzen. */ import { existsSync, copyFileSync, renameSync, readdirSync, statSync, unlinkSync } from 'node:fs'; import { basename, join, resolve } from 'node:path'; import { app, dialog, type BrowserWindow } from 'electron'; import { geschriebenMerken } from './dateizugriff'; import type BetterSqlite3 from 'better-sqlite3'; import { anzeigegroesseSetzen } from './anzeige'; import { einstellungenLesen, einstellungenSchreiben } from './einstellungen'; import { laeuftPruefung } from './laufwaechter'; import { lernstandInstanz, lernstandSchliessen, type Lernstand } from './lernstand'; import { profilUebernehmen } from './profil-uebernehmen'; import { pruefungZuruecksetzen } from './pruefung'; import { aufraeumen, dateiPruefen, einstellungenAusSicherung, HOECHSTGROESSE, kennzahlenLesen, megabyte, nebendateienAufraeumen, reisendeEinstellungen, sicherungsDateiname, sicherungSchreiben, type DatenbankKonstruktor, } from './sicherung'; import type { Einspielergebnis, Kennzahlen, Pruefergebnis, Sicherungsergebnis, Uebernahmeergebnis, } from '../shared/sicherung'; import type { Katalog } from '../shared/katalog'; /** Arbeitskopie der gewählten Datei, während sie geprüft wird. */ const PRUEFKOPIE = 'einspielen-pruefkopie.db'; /** Vorsatz der selbsttätigen Sicherheitskopie vor dem Einspielen. */ const VOR_DEM_EINSPIELEN = 'Lernstand-vor-dem-Einspielen'; /** So viele Sicherheitskopien bleiben liegen. */ const KOPIEN_BEHALTEN = 3; /** Nach dieser Zeit verfällt eine geprüfte Datei und muss neu gewählt werden. */ const KENNUNG_GUELTIG_MS = 10 * 60 * 1000; interface OffenerVorgang { readonly kennung: string; readonly arbeitskopie: string; readonly groesse: number; readonly mtimeMs: number; readonly bis: number; /** * Die Profilnummern in der Datei, in derselben Reihenfolge wie * `Kennzahlen.jeProfil`. * * Der Renderer wählt über den **Index** in diese Liste, nie über eine * Nummer. Dieselbe Überlegung wie beim Pfad: Wer eine Nummer mitbringen * darf, darf auch eine andere mitbringen – und in einer fremden Datei * führte sie geradewegs in das Profil einer anderen Person. */ readonly profilIds: readonly number[]; } /* Genau ein Vorgang zur Zeit. Der Renderer bekommt nur die Kennung und den blossen Dateinamen, nie einen Pfad – dieselbe Überlegung wie beim unterbrochenen Prüfungsbogen: Wer einen Pfad mitbringen darf, darf auch einen anderen mitbringen. */ let offen: OffenerVorgang | null = null; export interface Umgebung { readonly lernstand: () => Lernstand; readonly katalog: () => Katalog; readonly Datenbank: DatenbankKonstruktor; readonly jetzt?: () => Date; } function userData(): string { return app.getPath('userData'); } function lernstandPfad(): string { return join(userData(), 'lernstand.db'); } /** Gleicher Pfad? Unter Windows entscheidet die Schreibweise nicht mit. */ function selbeDatei(a: string, b: string): boolean { const norm = (pfad: string): string => process.platform === 'win32' ? resolve(pfad).toLowerCase() : resolve(pfad); return norm(a) === norm(b); } // ── Sicherung anlegen ────────────────────────────────────────────────── export async function sicherungAnlegen( elternfenster: BrowserWindow | null, umgebung: Umgebung, ): Promise { const jetzt = umgebung.jetzt?.() ?? new Date(); const vorschlag = join(app.getPath('documents'), sicherungsDateiname(jetzt)); const optionen: Electron.SaveDialogOptions = { title: 'Lernstand sichern', defaultPath: vorschlag, buttonLabel: 'Sichern', filters: [{ name: 'Lernstand-Sicherung', extensions: ['wsklernstand'] }], properties: ['showOverwriteConfirmation', 'createDirectory'], }; const auswahl = await (elternfenster === null ? dialog.showSaveDialog(optionen) : dialog.showSaveDialog(elternfenster, optionen)); if (auswahl.canceled || auswahl.filePath === '') { return { gespeichert: false, pfad: null, bytes: 0 }; } /* Auf die laufende Datenbank zu sichern hiesse, sie mit sich selbst zu überschreiben. Der Systemdialog kennt diesen Fall nicht. */ if (selbeDatei(auswahl.filePath, lernstandPfad())) { throw new Error( 'Dorthin lässt sich nicht sichern: Das ist der laufende Lernstand selbst. ' + 'Bitte wählen Sie einen anderen Ort.', ); } /* Die alte Sicherung bleibt liegen, bis die neue vollständig dasteht. Bis 0.24.1 stand hier `aufraeumen(auswahl.filePath)` mit der Begründung, `VACUUM INTO` weigere sich, ein vorhandenes Ziel zu überschreiben. Die Begründung trägt nicht: `sicherungSchreiben` schreibt nach `.teil`, prüft die fertige Datei und schiebt sie erst dann mit `renameSync` an ihren Platz – und `renameSync` ersetzt ein vorhandenes Ziel unteilbar (nachgemessen). Das Löschen davor war also nicht nötig, aber zerstörend: Scheiterte danach irgendetwas – voller Datenträger, abgezogener USB-Stick, eine Gegenprobe, die die frische Datei ablehnt –, stand der Nutzer ohne die alte UND ohne die neue Sicherung da. Die Zustimmung im Systemdialog gilt dem Überschreiben, nicht dem Verlust von beidem. Weggeräumt werden nur die Nebendateien: Ein verwaistes Schreibprotokoll neben der neuen Datei ist der einzige gemessene Weg, in diesem Vorgang wirklich Daten zu vernichten (siehe Modulkopf von `main/sicherung.ts`). Sie gehören nicht zur Sicherung und tragen nichts, was verloren gehen könnte. */ nebendateienAufraeumen(auswahl.filePath); /* Die Einstellungen reisen mit. Wer 400 Prozent Anzeigegröße oder hohen Kontrast braucht, musste bis 0.26.7 auf dem zweiten Rechner ohne sie anfangen – um sie einzustellen. Welche mitreisen und warum nicht alle, steht bei EINSTELLUNGEN_REISEN. */ const bytes = sicherungSchreiben( umgebung.lernstand().datenbank, auswahl.filePath, umgebung.Datenbank, reisendeEinstellungen(einstellungenLesen()), ); /* Vermerken, wann zuletzt gesichert wurde – sonst kann die Karte nicht sagen, ob es überhaupt je geschah, und eine Erinnerung wäre geraten statt gewusst. Das Vermerken darf den Vorgang nicht zu Fall bringen: Die Sicherung liegt zu diesem Zeitpunkt bereits vollständig da. */ try { einstellungenSchreiben({ letzteSicherung: jetzt.toISOString() }); } catch (fehler: unknown) { console.warn('[sicherung] Zeitpunkt konnte nicht vermerkt werden:', fehler); } return { gespeichert: true, pfad: auswahl.filePath, bytes, dateiKennung: geschriebenMerken(auswahl.filePath), }; } // ── Einspielen, erste Hälfte: prüfen ─────────────────────────────────── export async function sicherungPruefen( elternfenster: BrowserWindow | null, umgebung: Umgebung, ): Promise { restePruefen(); /* Vor jedem Dialog: Läuft ein Prüfungsbogen, gibt es eine Antwort und keinen gesperrten Knopf. */ if (elternfenster !== null && laeuftPruefung(elternfenster)) { return { art: 'abgelehnt', grund: 'Es läuft gerade eine Prüfungssimulation. Bitte beenden Sie sie zuerst – ' + 'ein Wechsel des Lernstands mittendrin verlöre den angefangenen Bogen. ' + 'Es wurde nichts verändert.', }; } const optionen: Electron.OpenDialogOptions = { title: 'Sicherung auswählen', buttonLabel: 'Ansehen', properties: ['openFile'], filters: [ { name: 'Lernstand-Sicherung', extensions: ['wsklernstand'] }, /* „Alle Dateien“ steht bewusst daneben: Mailprogramme und Downloadordner benennen Anhänge um. Über die Brauchbarkeit entscheidet der Inhalt, nie die Endung. */ { name: 'Alle Dateien', extensions: ['*'] }, ], }; const auswahl = await (elternfenster === null ? dialog.showOpenDialog(optionen) : dialog.showOpenDialog(elternfenster, optionen)); const gewaehlt = auswahl.filePaths[0]; if (auswahl.canceled || gewaehlt === undefined) { /* Ein Abbruch ist eine Entscheidung, kein Ereignis – wie beim Lernbericht gibt es dazu keine Meldung. */ return { art: 'abgebrochen' }; } if (selbeDatei(gewaehlt, lernstandPfad())) { return { art: 'abgelehnt', grund: 'Das ist der laufende Lernstand selbst, keine Sicherung. Es wurde nichts verändert.', }; } /* Ab hier wird ausschliesslich auf einer Arbeitskopie im eigenen Verzeichnis gearbeitet. Zwei Gründe: Jedes Öffnen einer WAL-Datenbank legt `-wal` und `-shm` daneben und lässt sie liegen – auf dem USB-Stick des Nutzers. Und die geprüften Bytes sind dann genau die eingesetzten; die gewählte Datei wird nie geöffnet, nur gelesen. */ /* Erst die Größe ansehen, dann kopieren. Die Prüfkette in `dateiPruefen` lehnt alles über einem halben Gigabyte ab – aber sie sieht die **Arbeitskopie**, also erst, nachdem die Datei vollständig ins Programmverzeichnis geschrieben wurde. Wer aus Versehen einen Film auswählt, wartete bis 0.24.1 auf eine Kopie von mehreren Gigabyte, um danach zu lesen, dass die Datei zu groß ist – und die Kopie lag anschließend im Benutzerverzeichnis, bis der nächste Prüflauf sie überschrieb. Dieselbe Grenze, nur an der richtigen Stelle. */ let quellgroesse: number; try { quellgroesse = statSync(gewaehlt).size; } catch { return { art: 'abgelehnt', grund: 'Diese Datei lässt sich nicht lesen. Es wurde nichts verändert.', }; } if (quellgroesse > HOECHSTGROESSE) { return { art: 'abgelehnt', grund: `Diese Datei ist ${megabyte(quellgroesse)} groß und kann kein Lernstand sein – ` + 'ein Lernstand ist wenige Megabyte groß. Es wurde nichts verändert.', }; } const arbeitskopie = join(userData(), PRUEFKOPIE); aufraeumen(arbeitskopie); try { copyFileSync(gewaehlt, arbeitskopie); } catch { return { art: 'abgelehnt', grund: 'Diese Datei lässt sich nicht lesen. Es wurde nichts verändert.', }; } const befund = dateiPruefen(arbeitskopie, umgebung.Datenbank); if (befund.art === 'abgelehnt') { aufraeumen(arbeitskopie); return { art: 'abgelehnt', grund: befund.grund }; } const stand = statSync(arbeitskopie); const vorgang: OffenerVorgang = { kennung: `${String(Date.now())}-${String(Math.floor(Math.random() * 1e9))}`, arbeitskopie, groesse: stand.size, mtimeMs: stand.mtimeMs, bis: Date.now() + KENNUNG_GUELTIG_MS, profilIds: befund.profilIds, }; offen = vorgang; return { art: 'brauchbar', vorgang: vorgang.kennung, dateiname: basename(gewaehlt), ausDatei: befund.kennzahlen, jetzt: kennzahlenDesLaufenden(umgebung), }; } // ── Einspielen, zweite Hälfte: vollziehen ────────────────────────────── /** * Nimmt ein Profil aus der geprüften Datei dazu – ohne zu ersetzen. * * Dieselben Wachen wie beim Ersetzen: Token einlösen (Wegwerfstück), Grösse * und mtime der Arbeitskopie gegenprüfen, laufende Prüfungssimulation * abweisen. Der eine Unterschied ist der wichtigste: Es wird **in die lebende * Datenbank geschrieben**, nicht getauscht. Ein Rückweg über das * Zurücktauschen einer Datei gibt es hier nicht – nur die Sicherheitskopie, * die `profilUebernehmen` unter eigenem Vorsatz anlegt. * * Ohne `await` wie das Ersetzen: Der Hauptprozess ist einfädig, und solange * nichts abgewartet wird, kann sich kein zweiter IPC-Kanal dazwischenschieben * und in derselben Datenbank schreiben. */ export function sicherungUebernehmen( vorgangRoh: unknown, profilIndexRoh: unknown, elternfenster: BrowserWindow | null, umgebung: Umgebung, ): Uebernahmeergebnis { const merkposten = offen; offen = null; if ( merkposten === null || typeof vorgangRoh !== 'string' || vorgangRoh !== merkposten.kennung || Date.now() > merkposten.bis || !existsSync(merkposten.arbeitskopie) ) { return { art: 'abgelaufen' }; } const stand = statSync(merkposten.arbeitskopie); if (stand.size !== merkposten.groesse || stand.mtimeMs !== merkposten.mtimeMs) { aufraeumen(merkposten.arbeitskopie); return { art: 'abgelaufen' }; } if (elternfenster !== null && laeuftPruefung(elternfenster)) { aufraeumen(merkposten.arbeitskopie); return { art: 'gescheitert', grund: 'Es läuft inzwischen eine Prüfungssimulation. Es wurde nichts verändert.', }; } /* Der Renderer nennt einen Index in die Liste, die der Hauptprozess selbst festgehalten hat – nie eine Profilnummer. */ if ( typeof profilIndexRoh !== 'number' || !Number.isInteger(profilIndexRoh) || profilIndexRoh < 0 || profilIndexRoh >= merkposten.profilIds.length ) { aufraeumen(merkposten.arbeitskopie); return { art: 'gescheitert', grund: 'Dieses Profil steht nicht in der Datei.' }; } const quellProfilId = merkposten.profilIds[profilIndexRoh] ?? -1; try { return profilUebernehmen({ ziel: umgebung.lernstand().datenbank, quelle: merkposten.arbeitskopie, quellProfilId, katalogstand: umgebung.katalog().meta.stand, ordner: userData(), jetzt: umgebung.jetzt?.() ?? new Date(), Datenbank: umgebung.Datenbank, }); } finally { aufraeumen(merkposten.arbeitskopie); } } export function sicherungEinspielen( vorgang: unknown, elternfenster: BrowserWindow | null, umgebung: Umgebung, ): Einspielergebnis { const merkposten = offen; offen = null; if ( merkposten === null || typeof vorgang !== 'string' || vorgang !== merkposten.kennung || Date.now() > merkposten.bis || !existsSync(merkposten.arbeitskopie) ) { return { art: 'abgelaufen' }; } /* Die Arbeitskopie muss noch dieselbe sein, die geprüft wurde. */ const stand = statSync(merkposten.arbeitskopie); if (stand.size !== merkposten.groesse || stand.mtimeMs !== merkposten.mtimeMs) { aufraeumen(merkposten.arbeitskopie); return { art: 'abgelaufen' }; } /* Ein zweites Mal: Zwischen Rückfrage und Zustimmung liegt Zeit. */ if (elternfenster !== null && laeuftPruefung(elternfenster)) { aufraeumen(merkposten.arbeitskopie); return { art: 'gescheitert', grund: 'Es läuft inzwischen eine Prüfungssimulation. Es wurde nichts verändert.', }; } const ziel = lernstandPfad(); const jetzt = umgebung.jetzt?.() ?? new Date(); // ── Sicherheitskopie. Ohne sie wird nicht gesprungen. ──────────────── let sicherheitskopie: string; try { sicherheitskopie = freierName(userData(), jetzt); sicherungSchreiben( umgebung.lernstand().datenbank, sicherheitskopie, umgebung.Datenbank, /* Auch die Sicherheitskopie trägt sie. Sonst wäre der Rückweg nach einem missglückten Einspielen der eine Weg, auf dem Einstellungen doch noch verloren gingen. */ reisendeEinstellungen(einstellungenLesen()), ); } catch (fehler) { aufraeumen(merkposten.arbeitskopie); return { art: 'gescheitert', grund: 'Es ließ sich keine Sicherung Ihres jetzigen Lernstands anlegen, deshalb wurde ' + `nichts eingelesen. Möglicherweise ist der Speicherplatz erschöpft. (${fehlertext(fehler)}) ` + 'Es wurde nichts verändert.', }; } // ── Schliessen, Nebendateien beseitigen, ersetzen ──────────────────── lernstandSchliessen(); pruefungZuruecksetzen(); /* Nach einem sauberen `close()` sind `-wal` und `-shm` weg. Liegen sie doch noch da, hält ein fremdes Handle die Datei. Löschen gelingt dann unter Windows nicht – und weitermachen wäre der eine Weg, auf dem wirklich Daten verschwinden: SQLite spielte das alte Schreibprotokoll auf die neue Datenbank, der eingespielte Stand wäre fort, und `integrity_check` meldete „ok“. */ for (const anhang of ['-wal', '-shm']) { const nebendatei = `${ziel}${anhang}`; if (!existsSync(nebendatei)) { continue; } try { unlinkSync(nebendatei); } catch { lernstandInstanz(ziel, umgebung.katalog()); return { art: 'gescheitert', grund: 'Der Lernstand wird gerade von einem anderen Programm benutzt und ließ sich nicht ' + 'ersetzen. Bitte schließen Sie andere Fenster dieser Anwendung und versuchen Sie es ' + `erneut. Ihr Lernstand ist unverändert; eine Sicherung liegt unter ${basename(sicherheitskopie)}.`, }; } } try { renameSync(merkposten.arbeitskopie, ziel); } catch (fehler) { lernstandInstanz(ziel, umgebung.katalog()); return { art: 'gescheitert', grund: `Die Datei ließ sich nicht an die Stelle des Lernstands setzen (${fehlertext(fehler)}). ` + 'Ihr Lernstand ist unverändert.', }; } // ── Öffnen, migrieren, Probe ───────────────────────────────────────── try { const neuer = lernstandInstanz(ziel, umgebung.katalog()); const profile = neuer.profile(); const erstes = profile[0]; if (erstes === undefined) { throw new Error('Die eingespielte Datei enthält kein Profil.'); } /* Eine Datei, die sich öffnen lässt, aber bei der ersten echten Abfrage umfällt, ist kein Erfolg. Gefragt wird **jedes** Profil: Die Probe soll die ganze Datei treffen, nicht ihren ersten Eintrag. Und die Zahl gehört über alle Profile gebildet. Bis Fassung 0.24.1 nahm sie nur das erste; nach dem Einspielen einer Datei mit drei Profilen stand da „3 Profile, 24 verschiedene Fragen bearbeitet“, obwohl es die 24 des ersten waren. Wer die Zahl mit der Gegenüberstellung von vorhin verglich, fand einen Widerspruch, den es nicht gab. */ const bearbeiteteFragen = profile.reduce( (summe, profil) => summe + neuer.uebersicht(profil.id).beantwortet, 0, ); kopienAufraeumen(userData()); return { art: 'eingespielt', einstellungenUebernommen: einstellungenUebernehmen(neuer.datenbank), profilzahl: profile.length, ersteProfilId: erstes.id, bearbeiteteFragen, sicherheitskopie: basename(sicherheitskopie), }; } catch (fehler) { /* Rückweg – und zwar unteilbar wie der Hinweg: erst nach `.teil`, dann umbenennen. Ein Rückweg, der auf halbem Weg abbricht, wäre die schlimmste aller Lagen. */ lernstandSchliessen(); for (const anhang of ['', '-wal', '-shm']) { aufraeumen(`${ziel}${anhang}`); } try { copyFileSync(sicherheitskopie, `${ziel}.teil`); renameSync(`${ziel}.teil`, ziel); lernstandInstanz(ziel, umgebung.katalog()); } catch { /* Auch der Rückweg misslang. Die Sicherheitskopie liegt und wird benannt – von Hand ist sie einspielbar. */ } return { art: 'gescheitert', grund: `Die Datei ließ sich nicht öffnen (${fehlertext(fehler)}). Ihr bisheriger Lernstand ` + `wurde wiederhergestellt. Eine Sicherung davon liegt unter ${basename(sicherheitskopie)}.`, }; } } // ── Hilfen ───────────────────────────────────────────────────────────── /** * Übernimmt die mitgereisten Einstellungen aus einer eingespielten Datei. * * @returns `true`, wenn welche übernommen wurden. * * **Warum das Scheitern hier nichts umwirft.** Der Lernstand ist an dieser * Stelle bereits eingespielt und geprüft. Eine Einstellung, die sich nicht * setzen lässt, darf diesen Erfolg nicht in einen Fehlschlag verwandeln – der * Nutzer stünde sonst vor „gescheitert“, obwohl seine Daten da sind. * * **Warum die Anzeigegröße einen eigenen Weg geht.** Sie hängt nicht nur in * der Einstellungsdatei, sondern als Zoomfaktor an den `webContents` jedes * Fensters und in einem Merkposten in `anzeige.ts`. Bloßes Schreiben änderte * die Datei und nicht das Bild. `anzeigegroesseSetzen` macht beides und meldet * es den Fenstern. */ function einstellungenUebernehmen(db: BetterSqlite3.Database): boolean { try { const mitgereist = einstellungenAusSicherung(db); if (mitgereist === null) return false; const { anzeigegroesse, ...uebrige } = mitgereist; if (Object.keys(uebrige).length > 0) einstellungenSchreiben(uebrige); if (anzeigegroesse !== undefined) anzeigegroesseSetzen(anzeigegroesse); return true; } catch (fehler: unknown) { console.warn('[sicherung] Einstellungen konnten nicht übernommen werden:', fehler); return false; } } function kennzahlenDesLaufenden(umgebung: Umgebung): Kennzahlen { const db = umgebung.lernstand().datenbank; const tabellen = new Set( db .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'") .all() .map((zeile) => zeile.name), ); const fassung = db .prepare<[], { version: number | null }>('SELECT MAX(version) AS version FROM schema_version') .get()?.version ?? 0; /* Dieselbe Funktion wie für die Datei – zwei Zählweisen nebeneinander wären zwei Wahrheiten in derselben Rückfrage. */ return kennzahlenLesen(db, fassung, tabellen); } /** * Ein freier Name für die Sicherheitskopie. * * Sekundengenau, und trotzdem mit Zähler: Diese Kopie entsteht ohne * Rückfrage, und `sicherungSchreiben` schiebt die fertige Datei mit * `renameSync` an ihren Platz – das ersetzt ein vorhandenes Ziel unteilbar * und wortlos. Zweimal in derselben Sekunde ist unwahrscheinlich, aber * „unwahrscheinlich“ ist bei einer Sicherheitskopie das falsche Wort. * * Bis 0.27.2 stand hier als Begründung, `VACUUM INTO` weigere sich, ein * vorhandenes Ziel zu überschreiben. Das trifft auf den Zielnamen nicht mehr * zu: Geschrieben wird nach `⟨ziel⟩.teil`, und der wird vorher weggeräumt. */ function freierName(ordner: string, jetzt: Date): string { const grund = join(ordner, sicherungsDateiname(jetzt, VOR_DEM_EINSPIELEN)); if (!existsSync(grund)) { return grund; } for (let zaehler = 2; zaehler < 100; zaehler++) { 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.'); } /** Nur die jüngsten Sicherheitskopien bleiben; ausschliesslich dieses Muster. */ function kopienAufraeumen(ordner: string): void { try { const kopien = readdirSync(ordner) .filter((name) => name.startsWith(`${VOR_DEM_EINSPIELEN}-`) && 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 – das ist kein Grund, einen erfolgreichen Vorgang zu melden. */ } } /** Reste eines abgebrochenen Vorgangs, bevor ein neuer beginnt. */ function restePruefen(): void { offen = null; aufraeumen(join(userData(), PRUEFKOPIE)); } function fehlertext(fehler: unknown): string { return fehler instanceof Error ? fehler.message : String(fehler); }