using System.IO; using System.IO.Compression; using System.Text.Json; namespace DeskCore; /// Export/Import aller Kategorien samt Einstellungen als ZIP – mit Fortschritt und Abbrechen. public static class Backup { private const string CategoriesPrefix = "Kategorien/"; private const string SettingsEntry = "einstellungen.json"; private const int Chunk = 1 << 20; public static long StorageSize() => StorageSize(Storage.Root); public static long StorageSize(string root) { try { return Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) .Sum(f => { try { return new FileInfo(f).Length; } catch { return 0L; } }); } catch { return 0; } } public static string FormatSize(long bytes) => bytes switch { < 1024 * 1024 => $"{Math.Max(1, bytes / 1024)} KB", < 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024:0.#} MB", _ => $"{bytes / 1024.0 / 1024 / 1024:0.##} GB", }; /// Sichert alle Kategorien. Gibt die Anzahl gesicherter Dateien zurück. public static int Export(string zipPath, Action? onBytes = null, CancellationToken ct = default) => Export(zipPath, Storage.Root, Storage.Categories(), JsonSerializer.Serialize(AppSettings.Current, AppSettings.Json), onBytes, ct); public static int Export(string zipPath, string root, IEnumerable categories, string settingsJson, Action? onBytes = null, CancellationToken ct = default) { var tmp = zipPath + ".teil"; int count = 0; try { if (File.Exists(tmp)) File.Delete(tmp); // große Sicherungen bestehen meist aus Videos/Bildern → stärkere Kompression bringt kaum etwas var level = StorageSize(root) > 1L << 30 ? CompressionLevel.Fastest : CompressionLevel.Optimal; using (var zip = ZipFile.Open(tmp, ZipArchiveMode.Create)) { foreach (var cat in categories) { var dir = Path.Combine(root, cat); zip.CreateEntry(CategoriesPrefix + cat + "/"); // auch leere Kategorien sichern foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) { ct.ThrowIfCancellationRequested(); var rel = Path.GetRelativePath(root, f).Replace('\\', '/'); var entry = zip.CreateEntry(CategoriesPrefix + rel, level); entry.LastWriteTime = File.GetLastWriteTime(f); using (var input = new FileStream(f, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, Chunk, FileOptions.SequentialScan)) using (var output = entry.Open()) CopyWithProgress(input, output, onBytes, ct); count++; } foreach (var d in Directory.EnumerateDirectories(dir, "*", SearchOption.AllDirectories)) if (!Directory.EnumerateFileSystemEntries(d).Any()) zip.CreateEntry(CategoriesPrefix + Path.GetRelativePath(root, d).Replace('\\', '/') + "/"); } var se = zip.CreateEntry(SettingsEntry); using (var w = new StreamWriter(se.Open())) w.Write(settingsJson); } // erst am Ende an den richtigen Namen – so bleibt nie eine halbe Sicherung mit gültigem Namen liegen if (File.Exists(zipPath)) File.Delete(zipPath); File.Move(tmp, zipPath); return count; } catch { try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } throw; } } /// Summe der unkomprimierten Größen (für die Fortschrittsanzeige beim Wiederherstellen). public static long ArchiveSize(string zipPath) { using var zip = ZipFile.OpenRead(zipPath); return zip.Entries.Sum(e => e.Length); } /// Führt eine Sicherung mit dem Bestand zusammen. Vorhandenes wird nicht überschrieben. public static (int Added, int Skipped) Import(string zipPath, Action? onBytes = null, CancellationToken ct = default) { var (added, skipped, settings) = Import(zipPath, Storage.Root, onBytes, ct); if (settings is not null) Merge(settings); return (added, skipped); } public static (int Added, int Skipped, AppSettings? Settings) Import(string zipPath, string root, Action? onBytes = null, CancellationToken ct = default) { int added = 0, skipped = 0; AppSettings? settings = null; using var zip = ZipFile.OpenRead(zipPath); root = Path.GetFullPath(root); foreach (var e in zip.Entries) { ct.ThrowIfCancellationRequested(); if (e.FullName.Equals(SettingsEntry, StringComparison.OrdinalIgnoreCase)) { try { using var r = new StreamReader(e.Open()); settings = JsonSerializer.Deserialize(r.ReadToEnd(), AppSettings.Json); } catch { } continue; } if (!e.FullName.StartsWith(CategoriesPrefix, StringComparison.OrdinalIgnoreCase)) continue; var rel = e.FullName[CategoriesPrefix.Length..]; if (rel.Length == 0) continue; var target = Path.GetFullPath(Path.Combine(root, rel)); if (!Storage.IsUnder(target, root)) continue; // Schutz vor manipulierten ZIPs if (e.FullName.EndsWith('/')) { Directory.CreateDirectory(target); continue; } if (File.Exists(target)) { skipped++; onBytes?.Invoke(e.Length); continue; } Directory.CreateDirectory(Path.GetDirectoryName(target)!); try { using (var input = e.Open()) using (var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, Chunk)) CopyWithProgress(input, output, onBytes, ct); File.SetLastWriteTime(target, e.LastWriteTime.DateTime); } catch (OperationCanceledException) { try { File.Delete(target); } catch { } // halbe Datei nicht liegen lassen throw; } added++; } return (added, skipped, settings); } private static void CopyWithProgress(Stream input, Stream output, Action? onBytes, CancellationToken ct) { var buffer = new byte[Chunk]; int read; while ((read = input.Read(buffer, 0, buffer.Length)) > 0) { ct.ThrowIfCancellationRequested(); output.Write(buffer, 0, read); onBytes?.Invoke(read); } } private static void Merge(AppSettings from) { var s = AppSettings.Current; foreach (var (name, meta) in from.Categories ?? []) if (!s.Categories.ContainsKey(name)) s.Categories[name] = meta; foreach (var n in from.CategoryOrder ?? []) if (!s.CategoryOrder.Contains(n, StringComparer.OrdinalIgnoreCase)) s.CategoryOrder.Add(n); foreach (var r in from.Rules ?? []) if (!s.Rules.Any(x => string.Equals(x.Contains, r.Contains, StringComparison.OrdinalIgnoreCase) && string.Equals(x.Category, r.Category, StringComparison.OrdinalIgnoreCase))) s.Rules.Add(r); s.Save(); } }