Upload via GUI (180 Dateien)
This commit is contained in:
+476
-392
@@ -1,392 +1,476 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
|
||||
namespace DeskCore;
|
||||
|
||||
public enum TransferStatus { Ok, Skipped, Error, Cancelled }
|
||||
|
||||
/// <param name="Copied">true = kopiert (Original bleibt), false = verschoben</param>
|
||||
public record TransferResult(TransferStatus Status, string Source, string? Dest = null, bool Copied = false, string? Message = null);
|
||||
|
||||
/// <summary>
|
||||
/// Kategorien sind einfache Unterordner von %USERPROFILE%\DeskCore.
|
||||
/// Icons vom Desktop werden dorthin kopiert – es bleiben normale Dateien.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Hier liegen gelöschte Elemente, solange man sie noch rückgängig machen kann.</summary>
|
||||
public static readonly string Holding = Migration.Pick(Environment.SpecialFolder.LocalApplicationData, "Geloescht");
|
||||
|
||||
// ---------- Kategorien ----------
|
||||
|
||||
public static List<string> Categories()
|
||||
{
|
||||
Directory.CreateDirectory(Root);
|
||||
var order = AppSettings.Current.CategoryOrder;
|
||||
return Directory.GetDirectories(Root)
|
||||
.Select(Path.GetFileName)
|
||||
.OfType<string>()
|
||||
.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<string> 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<string> 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<string, (DateTime Stamp, string? Target)> _lnkCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Ziel einer Verknüpfung (.lnk/.url), sonst null.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Gleicher Dateiname oder Verknüpfung mit gleichem Ziel?</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Liegt das Element (oder ein gleichwertiges) schon in diesem Ordner?</summary>
|
||||
public static string? FindEquivalent(string dir, string item) =>
|
||||
Entries(dir).FirstOrDefault(e => !SamePath(e, item) && SameItem(e, item));
|
||||
|
||||
/// <summary>In welcher Kategorie liegt das Element bereits? (für das ✓ auf dem Desktop)</summary>
|
||||
public static string? FindCategoryOf(string item, IReadOnlyDictionary<string, List<string>> categoryEntries)
|
||||
{
|
||||
foreach (var (cat, entries) in categoryEntries)
|
||||
if (entries.Any(e => SameItem(e, item))) return cat;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- Übertragen ----------
|
||||
|
||||
/// <summary>
|
||||
/// Ü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.
|
||||
/// </summary>
|
||||
public static TransferResult Transfer(string source, string targetDir, bool forceMove = false,
|
||||
Action<long>? 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verschiebt Datei oder Ordner – auch zwischen Laufwerken.</summary>
|
||||
public static void MoveAny(string source, string dest, Action<long>? 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<long>? onBytes = null, CancellationToken ct = default)
|
||||
{
|
||||
NoteCreated(dest);
|
||||
if (Directory.Exists(source)) CopyDirectory(source, dest, onBytes, ct);
|
||||
else CopyFile(source, dest, onBytes, ct);
|
||||
}
|
||||
|
||||
/// <summary>Benennt ein Element um. Gibt den neuen Pfad zurück.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Endgültig in den Windows-Papierkorb.</summary>
|
||||
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 { }
|
||||
}
|
||||
|
||||
/// <summary>Reste von einem Absturz in den Papierkorb legen.</summary>
|
||||
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<string, DateTime> _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);
|
||||
|
||||
/// <summary>Größe eines Ordners in Bytes (bricht ab, sobald limit überschritten ist).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Endung, die beim Umbenennen erhalten bleibt (bei Verknüpfungen versteckt).</summary>
|
||||
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<long>? 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);
|
||||
}
|
||||
|
||||
/// <summary>Kopiert in 1-MB-Stücken, damit Fortschritt angezeigt und abgebrochen werden kann.</summary>
|
||||
private static void CopyFile(string source, string dest, Action<long>? 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 { }
|
||||
}
|
||||
|
||||
/// <summary>Wie viele Bytes müssen für diese Übertragung tatsächlich kopiert werden? (0 = nur umbenennen)</summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
|
||||
namespace DeskCore;
|
||||
|
||||
public enum TransferStatus { Ok, Skipped, Error, Cancelled }
|
||||
|
||||
/// <param name="Copied">true = kopiert (Original bleibt), false = verschoben</param>
|
||||
public record TransferResult(TransferStatus Status, string Source, string? Dest = null, bool Copied = false, string? Message = null);
|
||||
|
||||
/// <summary>
|
||||
/// Kategorien sind einfache Unterordner von %USERPROFILE%\DeskCore.
|
||||
/// Icons vom Desktop werden dorthin kopiert – es bleiben normale Dateien.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Hier liegen gelöschte Elemente, solange man sie noch rückgängig machen kann.</summary>
|
||||
public static readonly string Holding = Migration.Pick(Environment.SpecialFolder.LocalApplicationData, "Geloescht");
|
||||
|
||||
// ---------- Kategorien ----------
|
||||
|
||||
public static List<string> Categories()
|
||||
{
|
||||
Directory.CreateDirectory(Root);
|
||||
var order = AppSettings.Current.CategoryOrder;
|
||||
return Directory.GetDirectories(Root)
|
||||
.Select(Path.GetFileName)
|
||||
.OfType<string>()
|
||||
.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<string> 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<string> 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<string, (DateTime Stamp, string? Target)> _lnkCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Ziel einer Verknüpfung (.lnk/.url), sonst null.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------- Verknüpfungen zu deinstallierten Programmen ----------
|
||||
|
||||
private static readonly ConcurrentDictionary<string, (DateTime Stamp, string? Target)> _targetPathCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Reiner Zielpfad einer .lnk-Verknüpfung (ohne Argumente), sonst null.</summary>
|
||||
public static string? LinkTargetPath(string path)
|
||||
{
|
||||
if (!string.Equals(Path.GetExtension(path), ".lnk", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
DateTime stamp;
|
||||
try { stamp = File.GetLastWriteTimeUtc(path); } catch { return null; }
|
||||
if (_targetPathCache.TryGetValue(path, out var c) && c.Stamp == stamp) return c.Target;
|
||||
string? target = null;
|
||||
try
|
||||
{
|
||||
// Direkt über IShellLink lesen (Rohpfad). WScript.Shell "repariert" Ziele, die es nicht findet,
|
||||
// z. B. \\server\… zu C:\server\… – darauf darf eine Lösch-Entscheidung nicht beruhen.
|
||||
var link = (IShellLinkW)new ShellLinkCoClass();
|
||||
try
|
||||
{
|
||||
((System.Runtime.InteropServices.ComTypes.IPersistFile)link).Load(path, 0); // STGM_READ
|
||||
var sb = new System.Text.StringBuilder(32768);
|
||||
link.GetPath(sb, sb.Capacity, IntPtr.Zero, 0x4); // SLGP_RAWPATH
|
||||
var raw = sb.ToString().Trim();
|
||||
target = raw.Length == 0 ? null : Environment.ExpandEnvironmentVariables(raw);
|
||||
}
|
||||
finally { System.Runtime.InteropServices.Marshal.ReleaseComObject(link); }
|
||||
}
|
||||
catch { }
|
||||
_targetPathCache[path] = (stamp, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.ComImport, System.Runtime.InteropServices.Guid("00021401-0000-0000-C000-000000000046")]
|
||||
private class ShellLinkCoClass { }
|
||||
|
||||
[System.Runtime.InteropServices.ComImport, System.Runtime.InteropServices.Guid("000214F9-0000-0000-C000-000000000046"),
|
||||
System.Runtime.InteropServices.InterfaceType(System.Runtime.InteropServices.ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IShellLinkW
|
||||
{
|
||||
void GetPath([System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] System.Text.StringBuilder pszFile,
|
||||
int cch, IntPtr pfd, uint fFlags);
|
||||
// weitere Methoden werden nicht gebraucht (Reihenfolge der vtable ab hier egal)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zeigt die Verknüpfung auf ein Programm, das es nicht mehr gibt (z. B. deinstalliert)?
|
||||
/// Bewusst vorsichtig – im Zweifel "nein":
|
||||
/// .lnk nur mit festem Ziel auf einer eingebauten, erreichbaren Festplatte; .url nur bei Steam/Epic, wenn deren
|
||||
/// Installationsliste das Spiel nicht mehr kennt. Netzwerk-/USB-Laufwerke, Web-Links, Store-Apps und
|
||||
/// Windows-Installer-Verknüpfungen werden nie als tot gewertet.
|
||||
/// </summary>
|
||||
public static bool IsDeadShortcut(string path)
|
||||
{
|
||||
// Steam-/Epic-Links: deren eigene Installationslisten fragen
|
||||
if (string.Equals(Path.GetExtension(path), ".url", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var url = ShortcutTarget(path);
|
||||
return url is not null && GameLibraries.IsInstalled(url) == false;
|
||||
}
|
||||
|
||||
var target = LinkTargetPath(path);
|
||||
if (target is null) return false;
|
||||
try
|
||||
{
|
||||
if (!Path.IsPathFullyQualified(target) || target.StartsWith(@"\\")) return false; // relativ oder Netzwerk
|
||||
var drive = new DriveInfo(Path.GetPathRoot(target)!);
|
||||
if (!drive.IsReady || drive.DriveType != DriveType.Fixed) return false; // abgesteckt/USB/Netz
|
||||
var installer = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Installer");
|
||||
if (IsUnder(target, installer)) return false; // MSI-"Werbe"-Verknüpfung
|
||||
return !File.Exists(target) && !Directory.Exists(target);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
/// <summary>Alle toten Verknüpfungen in den Kategorien (ohne die, die der Nutzer behalten will).</summary>
|
||||
public static List<string> FindDeadShortcuts(ICollection<string> keep) =>
|
||||
Categories()
|
||||
.SelectMany(c => Entries(CategoryPath(c)))
|
||||
.Where(p => !keep.Contains(p, StringComparer.OrdinalIgnoreCase) && IsDeadShortcut(p))
|
||||
.ToList();
|
||||
|
||||
/// <summary>Gleicher Dateiname oder Verknüpfung mit gleichem Ziel?</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Liegt das Element (oder ein gleichwertiges) schon in diesem Ordner?</summary>
|
||||
public static string? FindEquivalent(string dir, string item) =>
|
||||
Entries(dir).FirstOrDefault(e => !SamePath(e, item) && SameItem(e, item));
|
||||
|
||||
/// <summary>In welcher Kategorie liegt das Element bereits? (für das ✓ auf dem Desktop)</summary>
|
||||
public static string? FindCategoryOf(string item, IReadOnlyDictionary<string, List<string>> categoryEntries)
|
||||
{
|
||||
foreach (var (cat, entries) in categoryEntries)
|
||||
if (entries.Any(e => SameItem(e, item))) return cat;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- Übertragen ----------
|
||||
|
||||
/// <summary>
|
||||
/// Ü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.
|
||||
/// </summary>
|
||||
public static TransferResult Transfer(string source, string targetDir, bool forceMove = false,
|
||||
Action<long>? 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verschiebt Datei oder Ordner – auch zwischen Laufwerken.</summary>
|
||||
public static void MoveAny(string source, string dest, Action<long>? 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<long>? onBytes = null, CancellationToken ct = default)
|
||||
{
|
||||
NoteCreated(dest);
|
||||
if (Directory.Exists(source)) CopyDirectory(source, dest, onBytes, ct);
|
||||
else CopyFile(source, dest, onBytes, ct);
|
||||
}
|
||||
|
||||
/// <summary>Benennt ein Element um. Gibt den neuen Pfad zurück.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Endgültig in den Windows-Papierkorb.</summary>
|
||||
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 { }
|
||||
}
|
||||
|
||||
/// <summary>Reste von einem Absturz in den Papierkorb legen.</summary>
|
||||
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<string, DateTime> _created = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static void NoteCreated(string path) => _created[path] = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Von der App selbst angelegte Datei merken (z. B. Desktop-Verknüpfung einer Start-Gruppe).</summary>
|
||||
public static void MarkCreated(string path) => NoteCreated(path);
|
||||
|
||||
public static bool CreatedByApp(string path) =>
|
||||
_created.TryGetValue(path, out var t) && DateTime.UtcNow - t < TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>Größe eines Ordners in Bytes (bricht ab, sobald limit überschritten ist).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Endung, die beim Umbenennen erhalten bleibt (bei Verknüpfungen versteckt).</summary>
|
||||
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<long>? 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);
|
||||
}
|
||||
|
||||
/// <summary>Kopiert in 1-MB-Stücken, damit Fortschritt angezeigt und abgebrochen werden kann.</summary>
|
||||
private static void CopyFile(string source, string dest, Action<long>? 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 { }
|
||||
}
|
||||
|
||||
/// <summary>Wie viele Bytes müssen für diese Übertragung tatsächlich kopiert werden? (0 = nur umbenennen)</summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user