- Dashboard: Fehlzeiten-Warnung lädt Mitarbeitssitzungen einmal vorab statt pro Schüler/Eintrag einzeln nachzuschlagen (N+1 vermieden); neues IParticipationSessionRepository.GetAll() dafür. - CI: .gitea/workflows/ci.yml baut und testet bei jedem Push/PR. Dabei fehlende Release|Any CPU-Konfiguration für 6 Projekte in der .sln behoben (LehrerApp.Data.Tests wurde bei Release-Builds der Solution bislang stillschweigend übersprungen). TreatWarningsAsErrors jetzt aktiv. - SettingsViewModel (1986 Zeilen) als partial class auf 20 Themen- dateien aufgeteilt, Verhalten unverändert. - Backup: optionaler zweiter Sicherungsordner (USB-Stick/Netzlaufwerk, best-effort) und Integritätsprüfung nach jedem Backup (DatabaseEncryptionService.CanOpenAndRead). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
98 lines
4.1 KiB
C#
98 lines
4.1 KiB
C#
namespace LehrerApp.Core.Services;
|
|
|
|
public record BackupInfo(string Path, DateTime CreatedAt, long SizeBytes);
|
|
|
|
/// <summary>
|
|
/// Rollierendes lokales Backup der LiteDB-Datei
|
|
/// (<c><AppData>/LehrerApp/backups/lehrerapp-JJJJ-MM-TT_HH-mm-ss.db</c>).
|
|
/// Reine Dateikopie — unabhängig davon, ob die Datenbank verschlüsselt ist.
|
|
/// </summary>
|
|
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<BackupInfo> 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);
|
|
}
|
|
}
|