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();
}
}