namespace LehrerApp.Core.Services; public record BackupInfo(string Path, DateTime CreatedAt, long SizeBytes); /// /// Rollierendes lokales Backup der LiteDB-Datei /// (<AppData>/LehrerApp/backups/lehrerapp-JJJJ-MM-TT_HH-mm-ss.db). /// Reine Dateikopie — unabhängig davon, ob die Datenbank verschlüsselt ist. /// public class BackupService { private const int DefaultKeepCount = 10; private readonly string _backupDirectory; public string BackupDirectory => _backupDirectory; /// Optionales zweites Sicherungsziel (z.B. USB-Stick, Netzlaufwerk) — Backups liegen sonst /// ausschließlich neben der Live-Datenbank und sind bei einem Plattenausfall oder einem /// versehentlich gelöschten Profilverzeichnis wertlos. Wird von außen gesetzt (Desktop-Settings, /// siehe BackupSettingsService); bewusst best-effort — ist das Ziel beim Sichern nicht /// erreichbar (Stick nicht eingesteckt, Freigabe offline), bleibt nur das Hauptbackup bestehen. public string? SecondaryBackupDirectory { get; set; } public BackupService(string appDataPath) { _backupDirectory = Path.Combine(appDataPath, "backups"); Directory.CreateDirectory(_backupDirectory); } /// Erstellt ein Backup der übergebenen Datenbankdatei, falls sie existiert. /// Gibt den Pfad des neuen Backups zurück, oder null, wenn keine Datei vorhanden war. public string? CreateBackup(string databasePath, int keepCount = DefaultKeepCount) { if (!File.Exists(databasePath)) return null; // Millisekunden + Zufallssuffix, damit mehrere Backups innerhalb derselben Sekunde // (z.B. mehrfaches Klicken auf "Jetzt sichern") sich nicht gegenseitig überschreiben. var suffix = Guid.NewGuid().ToString("N")[..8]; var fileName = $"lehrerapp-{DateTime.Now:yyyy-MM-dd_HH-mm-ss-fff}-{suffix}.db"; var target = Path.Combine(_backupDirectory, fileName); File.Copy(databasePath, target, overwrite: true); PruneOldBackups(keepCount); if (!string.IsNullOrWhiteSpace(SecondaryBackupDirectory)) TryMirrorToSecondary(target, fileName, keepCount); return target; } private void TryMirrorToSecondary(string sourcePath, string fileName, int keepCount) { try { Directory.CreateDirectory(SecondaryBackupDirectory!); File.Copy(sourcePath, Path.Combine(SecondaryBackupDirectory!, fileName), overwrite: true); foreach (var stale in Directory.GetFiles(SecondaryBackupDirectory!, "lehrerapp-*.db") .Select(p => new FileInfo(p)) .OrderByDescending(f => f.LastWriteTime) .Skip(keepCount)) { try { stale.Delete(); } catch { /* nächster Lauf versucht es erneut */ } } } catch { // Sicherungsziel nicht erreichbar — das Hauptbackup (_backupDirectory) ist davon // unberührt, ein manuelles/automatisches Backup soll daran nicht scheitern. } } public List ListBackups() => Directory.GetFiles(_backupDirectory, "lehrerapp-*.db") .Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length)) .OrderByDescending(b => b.CreatedAt) .ToList(); public void PruneOldBackups(int keepCount = DefaultKeepCount) { var backups = ListBackups(); foreach (var stale in backups.Skip(keepCount)) { try { File.Delete(stale.Path); } catch { /* nächster Lauf versucht es erneut */ } } } /// Kopiert ein Backup an die Zielposition (z.B. über die aktive Datenbankdatei). /// Der Aufrufer ist dafür verantwortlich, alle offenen Verbindungen vorher zu schließen. public void RestoreBackup(string backupPath, string databasePath) { if (!File.Exists(backupPath)) throw new FileNotFoundException("Backup nicht gefunden.", backupPath); File.Copy(backupPath, databasePath, overwrite: true); } }