using System.Collections.Concurrent; using System.IO; namespace DeskCore; public enum TransferStatus { Ok, Skipped, Error, Cancelled } /// true = kopiert (Original bleibt), false = verschoben public record TransferResult(TransferStatus Status, string Source, string? Dest = null, bool Copied = false, string? Message = null); /// /// Kategorien sind einfache Unterordner von %USERPROFILE%\DeskCore. /// Icons vom Desktop werden dorthin kopiert – es bleiben normale Dateien. /// public static class Storage { public static readonly string Root = Migration.Pick(Environment.SpecialFolder.UserProfile); public static readonly string UserDesktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); public static readonly string PublicDesktop = Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory); /// Hier liegen gelöschte Elemente, solange man sie noch rückgängig machen kann. public static readonly string Holding = Migration.Pick(Environment.SpecialFolder.LocalApplicationData, "Geloescht"); // ---------- Kategorien ---------- public static List Categories() { Directory.CreateDirectory(Root); var order = AppSettings.Current.CategoryOrder; return Directory.GetDirectories(Root) .Select(Path.GetFileName) .OfType() .OrderBy(n => order.FindIndex(o => string.Equals(o, n, StringComparison.OrdinalIgnoreCase)) is var i and >= 0 ? i : int.MaxValue) .ThenBy(n => n, StringComparer.CurrentCultureIgnoreCase) .ToList(); } public static string CategoryPath(string name) => Path.Combine(Root, name); public static IEnumerable Entries(string dir) { if (!Directory.Exists(dir)) return []; try { return Directory.EnumerateFileSystemEntries(dir) .Where(p => { var n = Path.GetFileName(p); if (n.Equals("desktop.ini", StringComparison.OrdinalIgnoreCase)) return false; try { var attr = File.GetAttributes(p); return !attr.HasFlag(FileAttributes.Hidden) && !attr.HasFlag(FileAttributes.System); } catch { return false; } }) .ToList(); } catch { return []; } } public static IEnumerable DesktopEntries() => Entries(UserDesktop).Concat(Entries(PublicDesktop)); public static bool IsDesktopDir(string dir) => SamePath(dir, UserDesktop) || SamePath(dir, PublicDesktop); public static bool IsInStorage(string path) => path.StartsWith(Root.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase); public static string? ValidateName(string name) { name = name.Trim(); if (name.Length == 0) return "Bitte einen Namen eingeben."; if (name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) return "Der Name enthält ungültige Zeichen ( \\ / : * ? \" < > | )."; if (name is "." or ".." || name.EndsWith('.')) return "Ungültiger Name."; return null; } // ---------- Duplikate ---------- private static readonly ConcurrentDictionary _lnkCache = new(StringComparer.OrdinalIgnoreCase); /// Ziel einer Verknüpfung (.lnk/.url), sonst null. public static string? ShortcutTarget(string path) { var ext = Path.GetExtension(path).ToLowerInvariant(); if (ext is not (".lnk" or ".url")) return null; DateTime stamp; try { stamp = File.GetLastWriteTimeUtc(path); } catch { return null; } if (_lnkCache.TryGetValue(path, out var c) && c.Stamp == stamp) return c.Target; string? target = null; try { if (ext == ".url") { target = File.ReadLines(path).FirstOrDefault(l => l.StartsWith("URL=", StringComparison.OrdinalIgnoreCase))?[4..]; } else { dynamic shell = Activator.CreateInstance(Type.GetTypeFromProgID("WScript.Shell")!)!; dynamic lnk = shell.CreateShortcut(path); string t = lnk.TargetPath; string a = lnk.Arguments; target = string.IsNullOrEmpty(t) ? null : (t + " " + a).Trim(); } } catch { } if (string.IsNullOrWhiteSpace(target)) target = null; _lnkCache[path] = (stamp, target); return target; } /// Gleicher Dateiname oder Verknüpfung mit gleichem Ziel? public static bool SameItem(string a, string b) { if (string.Equals(Path.GetFileName(a), Path.GetFileName(b), StringComparison.OrdinalIgnoreCase)) return true; var ta = ShortcutTarget(a); return ta is not null && string.Equals(ta, ShortcutTarget(b), StringComparison.OrdinalIgnoreCase); } /// Liegt das Element (oder ein gleichwertiges) schon in diesem Ordner? public static string? FindEquivalent(string dir, string item) => Entries(dir).FirstOrDefault(e => !SamePath(e, item) && SameItem(e, item)); /// In welcher Kategorie liegt das Element bereits? (für das ✓ auf dem Desktop) public static string? FindCategoryOf(string item, IReadOnlyDictionary> categoryEntries) { foreach (var (cat, entries) in categoryEntries) if (entries.Any(e => SameItem(e, item))) return cat; return null; } // ---------- Übertragen ---------- /// /// Übernimmt Datei/Ordner nach targetDir. Kommt die Quelle von außerhalb der App (z. B. vom Desktop), /// wird kopiert – das Original kann danach gelöscht werden. Innerhalb der App und zurück auf den /// Desktop wird verschoben. Gleiche Elemente werden nicht doppelt angelegt. /// public static TransferResult Transfer(string source, string targetDir, bool forceMove = false, Action? onBytes = null, CancellationToken ct = default) { if (ct.IsCancellationRequested) return new(TransferStatus.Cancelled, source); var name = Path.GetFileName(source.TrimEnd('\\')); try { if (SamePath(Path.GetDirectoryName(source), targetDir)) return new(TransferStatus.Skipped, source, Message: $"„{name}“ ist schon dort."); if (Directory.Exists(source) && (targetDir.TrimEnd('\\') + "\\").StartsWith(source.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase)) return new(TransferStatus.Error, source, Message: $"„{name}“ kann nicht in sich selbst verschoben werden."); bool copy = !forceMove && !IsInStorage(source) && IsInStorage(targetDir); if (FindEquivalent(targetDir, source) is { } existing) { var where = IsDesktopDir(targetDir) ? "auf dem Desktop" : $"in „{Path.GetFileName(targetDir)}“"; return new(TransferStatus.Skipped, source, existing, Message: $"„{DisplayName(source)}“ ist schon {where}."); } Directory.CreateDirectory(targetDir); var dest = UniquePath(targetDir, name); try { if (copy) CopyAny(source, dest, onBytes, ct); else MoveAny(source, dest, onBytes, ct); } catch (OperationCanceledException) { // halb kopiertes Ziel wieder entfernen – die Quelle ist unverändert // (beim Verschieben zwischen Laufwerken wird die Quelle erst nach vollständiger Kopie gelöscht) DeletePartial(dest); return new(TransferStatus.Cancelled, source); } return new(TransferStatus.Ok, source, dest, copy); } catch (UnauthorizedAccessException) { return new(TransferStatus.Error, source, Message: $"„{name}“: Keine Berechtigung. " + "(Icons auf dem öffentlichen Desktop brauchen Administratorrechte – App ggf. als Administrator starten.)"); } catch (Exception ex) { return new(TransferStatus.Error, source, Message: $"„{name}“: {ex.Message}"); } } /// Verschiebt Datei oder Ordner – auch zwischen Laufwerken. public static void MoveAny(string source, string dest, Action? onBytes = null, CancellationToken ct = default) { NoteCreated(dest); if (File.Exists(source)) { File.Move(source, dest); return; } if (string.Equals(Path.GetPathRoot(source), Path.GetPathRoot(dest), StringComparison.OrdinalIgnoreCase)) { Directory.Move(source, dest); return; } CopyDirectory(source, dest, onBytes, ct); Directory.Delete(source, recursive: true); } public static void CopyAny(string source, string dest, Action? onBytes = null, CancellationToken ct = default) { NoteCreated(dest); if (Directory.Exists(source)) CopyDirectory(source, dest, onBytes, ct); else CopyFile(source, dest, onBytes, ct); } /// Benennt ein Element um. Gibt den neuen Pfad zurück. public static string Rename(string path, string newName) { var dir = Path.GetDirectoryName(path)!; var dest = Path.Combine(dir, newName); if (string.Equals(path, dest, StringComparison.Ordinal)) return path; if (string.Equals(path, dest, StringComparison.OrdinalIgnoreCase)) { // nur Groß-/Kleinschreibung geändert var tmp = path + ".umbenennen"; MoveAny(path, tmp); MoveAny(tmp, dest); return dest; } if (File.Exists(dest) || Directory.Exists(dest)) throw new IOException($"„{newName}“ gibt es dort schon."); MoveAny(path, dest); return dest; } /// /// "Löscht" ein Element: es wandert erst in einen internen Ordner, damit Strg+Z funktioniert. /// Erst wenn es nicht mehr rückgängig gemacht werden kann, geht es in den Papierkorb. /// public static string Delete(string path) { var dir = Path.Combine(Holding, Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); var dest = Path.Combine(dir, Path.GetFileName(path.TrimEnd('\\'))); MoveAny(path, dest); return dest; } /// Endgültig in den Windows-Papierkorb. public static void Recycle(string path) { try { if (Directory.Exists(path)) Microsoft.VisualBasic.FileIO.FileSystem.DeleteDirectory(path, Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs, Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin); else if (File.Exists(path)) Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile(path, Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs, Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin); // leeren Hilfsordner aufräumen var parent = Path.GetDirectoryName(path); if (parent is not null && IsUnder(parent, Holding) && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent); } catch { } } /// Reste von einem Absturz in den Papierkorb legen. public static void FlushHolding() { if (!Directory.Exists(Holding)) return; foreach (var d in Directory.GetDirectories(Holding)) foreach (var e in Directory.EnumerateFileSystemEntries(d).ToList()) Recycle(e); } // ---------- Selbst angelegte Desktop-Elemente merken (damit die Auto-Sortierung sie ignoriert) ---------- private static readonly ConcurrentDictionary _created = new(StringComparer.OrdinalIgnoreCase); private static void NoteCreated(string path) => _created[path] = DateTime.UtcNow; public static bool CreatedByApp(string path) => _created.TryGetValue(path, out var t) && DateTime.UtcNow - t < TimeSpan.FromSeconds(15); /// Größe eines Ordners in Bytes (bricht ab, sobald limit überschritten ist). public static long DirectorySize(string dir, long limit = long.MaxValue) { long sum = 0; try { foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) { try { sum += new FileInfo(f).Length; } catch { } if (sum > limit) break; } } catch { } return sum; } // ---------- Helfer ---------- public static string DisplayName(string path) { var n = Path.GetFileName(path.TrimEnd('\\')); var ext = Path.GetExtension(n).ToLowerInvariant(); return ext is ".lnk" or ".url" or ".appref-ms" ? Path.GetFileNameWithoutExtension(n) : n; } /// Endung, die beim Umbenennen erhalten bleibt (bei Verknüpfungen versteckt). public static string HiddenExtension(string path) { var ext = Path.GetExtension(path); return ext.ToLowerInvariant() is ".lnk" or ".url" or ".appref-ms" ? ext : ""; } public static bool SamePath(string? a, string? b) => a is not null && b is not null && string.Equals(Path.GetFullPath(a).TrimEnd('\\'), Path.GetFullPath(b).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase); public static bool IsUnder(string path, string dir) => (Path.GetFullPath(path).TrimEnd('\\') + "\\").StartsWith(Path.GetFullPath(dir).TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase); public static string UniquePath(string dir, string fileName) { var path = Path.Combine(dir, fileName); if (!File.Exists(path) && !Directory.Exists(path)) return path; var stem = Path.GetFileNameWithoutExtension(fileName); var ext = Path.GetExtension(fileName); for (int i = 2; ; i++) { path = Path.Combine(dir, $"{stem} ({i}){ext}"); if (!File.Exists(path) && !Directory.Exists(path)) return path; } } private static void CopyDirectory(string source, string dest, Action? onBytes, CancellationToken ct) { Directory.CreateDirectory(dest); foreach (var f in Directory.GetFiles(source)) CopyFile(f, Path.Combine(dest, Path.GetFileName(f)), onBytes, ct); foreach (var d in Directory.GetDirectories(source)) CopyDirectory(d, Path.Combine(dest, Path.GetFileName(d)), onBytes, ct); } /// Kopiert in 1-MB-Stücken, damit Fortschritt angezeigt und abgebrochen werden kann. private static void CopyFile(string source, string dest, Action? onBytes, CancellationToken ct) { ct.ThrowIfCancellationRequested(); const int Chunk = 1 << 20; using (var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, Chunk, FileOptions.SequentialScan)) using (var output = new FileStream(dest, FileMode.CreateNew, FileAccess.Write, FileShare.None, Chunk)) { 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); } } // Zeitstempel und Attribute wie beim normalen Kopieren übernehmen try { File.SetCreationTimeUtc(dest, File.GetCreationTimeUtc(source)); File.SetLastWriteTimeUtc(dest, File.GetLastWriteTimeUtc(source)); File.SetAttributes(dest, File.GetAttributes(source) & ~FileAttributes.ReadOnly); } catch { } } private static void DeletePartial(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); else if (File.Exists(path)) File.Delete(path); } catch { } } /// Wie viele Bytes müssen für diese Übertragung tatsächlich kopiert werden? (0 = nur umbenennen) public static long BytesToCopy(string source, string targetDir, bool forceMove) { bool copy = !forceMove && !IsInStorage(source) && IsInStorage(targetDir); bool crossDrive = !string.Equals(Path.GetPathRoot(Path.GetFullPath(source)), Path.GetPathRoot(Path.GetFullPath(targetDir)), StringComparison.OrdinalIgnoreCase); if (!copy && !crossDrive) return 0; try { return Directory.Exists(source) ? DirectorySize(source) : new FileInfo(source).Length; } catch { return 0; } } }