Fortschritt, .NET-Prüfung, Updates, Fenster merken, Box-Funktionen
- Große Kopien laufen im Hintergrund mit Fortschritt und Abbrechen; abgebrochene Kopien werden aufgeräumt, das Original bleibt unverändert - Sichern/Wiederherstellen mit Fortschritt, keine halben ZIP-Dateien - Installer auf .NET Framework 4.8: erkennt fehlende .NET 9 Desktop Runtime und installiert sie auf Wunsch (Microsoft-Signatur wird geprüft) - Update-Hinweis über Gitea-Releases, Update mit automatischem Neustart - Fenstergröße und -position werden gemerkt - Desktop-Boxen: umbenennen, löschen, verschieben, herausziehen - Fenster und Boxen kehren auf sichtbare Bildschirme zurück - README ergänzt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,17 +4,20 @@ using System.Text.Json;
|
||||
|
||||
namespace DeskCore;
|
||||
|
||||
/// <summary>Export/Import aller Kategorien samt Einstellungen als ZIP.</summary>
|
||||
/// <summary>Export/Import aller Kategorien samt Einstellungen als ZIP – mit Fortschritt und Abbrechen.</summary>
|
||||
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()
|
||||
public static long StorageSize() => StorageSize(Storage.Root);
|
||||
|
||||
public static long StorageSize(string root)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(Storage.Root, "*", SearchOption.AllDirectories)
|
||||
return Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)
|
||||
.Sum(f => { try { return new FileInfo(f).Length; } catch { return 0L; } });
|
||||
}
|
||||
catch { return 0; }
|
||||
@@ -27,41 +30,93 @@ public static class Backup
|
||||
_ => $"{bytes / 1024.0 / 1024 / 1024:0.##} GB",
|
||||
};
|
||||
|
||||
/// <summary>Gibt die Anzahl gesicherter Dateien zurück.</summary>
|
||||
public static int Export(string zipPath)
|
||||
/// <summary>Sichert alle Kategorien. Gibt die Anzahl gesicherter Dateien zurück.</summary>
|
||||
public static int Export(string zipPath, Action<long>? 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<string> categories, string settingsJson,
|
||||
Action<long>? onBytes = null, CancellationToken ct = default)
|
||||
{
|
||||
if (File.Exists(zipPath)) File.Delete(zipPath);
|
||||
var tmp = zipPath + ".teil";
|
||||
int count = 0;
|
||||
using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create);
|
||||
foreach (var cat in Storage.Categories())
|
||||
try
|
||||
{
|
||||
var dir = Storage.CategoryPath(cat);
|
||||
zip.CreateEntry(CategoriesPrefix + cat + "/"); // auch leere Kategorien sichern
|
||||
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||||
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))
|
||||
{
|
||||
var rel = Path.GetRelativePath(Storage.Root, f).Replace('\\', '/');
|
||||
zip.CreateEntryFromFile(f, CategoriesPrefix + rel, CompressionLevel.Optimal);
|
||||
count++;
|
||||
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);
|
||||
}
|
||||
foreach (var d in Directory.EnumerateDirectories(dir, "*", SearchOption.AllDirectories))
|
||||
if (!Directory.EnumerateFileSystemEntries(d).Any())
|
||||
zip.CreateEntry(CategoriesPrefix + Path.GetRelativePath(Storage.Root, d).Replace('\\', '/') + "/");
|
||||
// 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;
|
||||
}
|
||||
var entry = zip.CreateEntry(SettingsEntry);
|
||||
using (var w = new StreamWriter(entry.Open()))
|
||||
w.Write(JsonSerializer.Serialize(AppSettings.Current, AppSettings.Json));
|
||||
return count;
|
||||
catch
|
||||
{
|
||||
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Summe der unkomprimierten Größen (für die Fortschrittsanzeige beim Wiederherstellen).</summary>
|
||||
public static long ArchiveSize(string zipPath)
|
||||
{
|
||||
using var zip = ZipFile.OpenRead(zipPath);
|
||||
return zip.Entries.Sum(e => e.Length);
|
||||
}
|
||||
|
||||
/// <summary>Führt eine Sicherung mit dem Bestand zusammen. Vorhandenes wird nicht überschrieben.</summary>
|
||||
public static (int Added, int Skipped) Import(string zipPath)
|
||||
public static (int Added, int Skipped) Import(string zipPath, Action<long>? 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<long>? onBytes = null, CancellationToken ct = default)
|
||||
{
|
||||
int added = 0, skipped = 0;
|
||||
AppSettings? settings = null;
|
||||
using var zip = ZipFile.OpenRead(zipPath);
|
||||
var root = Path.GetFullPath(Storage.Root);
|
||||
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<AppSettings>(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;
|
||||
@@ -73,23 +128,35 @@ public static class Backup
|
||||
Directory.CreateDirectory(target);
|
||||
continue;
|
||||
}
|
||||
if (File.Exists(target)) { skipped++; continue; }
|
||||
if (File.Exists(target)) { skipped++; onBytes?.Invoke(e.Length); continue; }
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
e.ExtractToFile(target);
|
||||
added++;
|
||||
}
|
||||
|
||||
if (zip.GetEntry(SettingsEntry) is { } se)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var r = new StreamReader(se.Open());
|
||||
var imported = JsonSerializer.Deserialize<AppSettings>(r.ReadToEnd(), AppSettings.Json);
|
||||
if (imported is not null) Merge(imported);
|
||||
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 { }
|
||||
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<long>? 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);
|
||||
}
|
||||
return (added, skipped);
|
||||
}
|
||||
|
||||
private static void Merge(AppSettings from)
|
||||
|
||||
Reference in New Issue
Block a user