diff --git a/Backlog.md b/Backlog.md
index 66089f2..5670710 100644
--- a/Backlog.md
+++ b/Backlog.md
@@ -294,18 +294,19 @@ Goal: Replace FileRenamer workflows.
- [x] Counter padding
- [x] Case conversion
- [x] Extension handling
-- [ ] Metadata placeholders
+- [x] Metadata placeholders
Potential placeholders:
- `{CreatedDate}`
+- `{TakenDate}`
- `{ModifiedDate}`
- `{Counter}`
- `{Width}`
- `{Height}`
- `{Artist}`
- `{Title}`
-- `{Project}`
+- `{Project}` (Git repo folder, else parent)
- `{Extension}`
## Workflow
@@ -318,6 +319,25 @@ Potential placeholders:
- [x] Add Rename operations to File Operations Queue
- [x] Store `OldPath -> NewPath`
- [x] Support Undo for completed rename batches
+- [x] Tags from filename
+- [x] Filename from tags
+- [x] Tag editor (Artist, Title, Album, Track, Year, Genre)
+- [x] Queue tag writes
+- [x] Saved name patterns
+- [x] EXIF Date Taken on photos
+- [x] `{Project}` from Git repo / parent folder
+
+---
+
+# Move to
+
+Patterned move with per-file destination folders (Plex-style movie folders, dated backups, and similar).
+
+- [x] Destination pattern popup
+- [x] `%filename%` `%filename_noext%` `%ext%` `%year%` `%month%` `%parent%` `%source_drive%`
+- [x] Create missing destination folders
+- [x] History / saved patterns
+- [x] Preview and queue through File Operations Queue
---
@@ -440,7 +460,7 @@ Goal: Understand what files and folders represent rather than relying only on ex
- [ ] MIME/content signature
- [x] Folder structure
- [x] Git metadata
-- [ ] Media metadata
+- [x] Media metadata
- [x] Known application structures (`node_modules`, `bin`, `obj`, `.vs`)
- [x] File age (old installers flagged in preview)
- [ ] File relationships
diff --git a/docs/Documentation.md b/docs/Documentation.md
index 9f8ea79..70be125 100644
--- a/docs/Documentation.md
+++ b/docs/Documentation.md
@@ -44,7 +44,7 @@ Specialized tools still do specialized jobs. 7-Zip compresses. FFmpeg converts a
| --- | --- |
| `%LocalAppData%\ExplorerWorkbench\index.db` | Index, queue, profiles |
| `%LocalAppData%\ExplorerWorkbench\logs\` | Rolling logs |
-| `%LocalAppData%\ExplorerWorkbench\ui-preferences.txt` | Theme, layout, tool paths, organize destinations, favorite folders |
+| `%LocalAppData%\ExplorerWorkbench\ui-preferences.txt` | Theme, layout, tool paths, organize destinations, favorite folders, saved name patterns, Move to history |
---
@@ -226,9 +226,34 @@ Auto-clear when done is a setting. Failed items stay until you dismiss or retry
Workflow: configure → preview → validate → queue.
-Rules: search/replace, regex, prefix, suffix, counter (with padding), case, extension. Collisions and illegal Windows names are caught before enqueue. Completed batches can be undone (**Tools → File Operations → Undo last rename batch**).
+Rules: search/replace, regex, prefix, suffix, counter (with padding), case, extension. An optional **name pattern** can replace the current name using placeholders: `{Artist}`, `{Title}`, `{Album}`, `{Track}`, `{Year}`, `{Genre}`, `{CreatedDate}`, `{TakenDate}`, `{ModifiedDate}`, `{Width}`, `{Height}`, `{Name}`, `{Extension}`, `{Parent}`, `{Project}`, `{Counter}`. Dates accept a format (`{TakenDate:yyyyMMdd}`). `{CreatedDate}` uses EXIF Date Taken when that was already read; `{TakenDate}` is the token that reads photo metadata. `{Project}` is the Git repository folder, or the parent folder if the file is not in a repo. **Save pattern** stores a custom pattern in `ui-preferences.txt` (built-in patterns stay in the list: `{Artist} - {Title}`, `{Track:00} - {Title}`, `{TakenDate}_{Name}`, `{CreatedDate}_{Name}`, `{Project}_{Name}`). Collisions and illegal Windows names are caught before enqueue. Completed batches can be undone (**Tools → File Operations → Undo last rename batch**).
-Metadata placeholders such as `{CreatedDate}` or `{Width}` are not implemented yet. `{Counter}` and `{Extension}` work.
+**Tags…** (select files first) is the Tag&Rename-style editor. **Filename → tags** fills Artist/Title/… from the current names using the same pattern. **Tags → filename** builds new names from tags. **Queue tags** writes ID3 on audio and EXIF (title, comment, creator, date taken) on photos through the File Operations Queue; **Queue rename** queues the new names. Online-only cloud files are skipped so they are not downloaded.
+
+---
+
+## Move to
+
+**Tools → File Operations → Move to…** or **Move to…** on the item context menu (select items first).
+
+Not a plain “move into this folder”. The destination is a **pattern**. Placeholders expand per file, missing folders are created, then the move is queued.
+
+| Token | Meaning |
+| --- | --- |
+| `%filename%` | Name including extension |
+| `%filename_noext%` | Name without extension |
+| `%ext%` | Extension without the dot |
+| `%year%` / `%month%` | Last-write time (`2024` / `08`) |
+| `%parent%` | Parent folder name |
+| `%source_drive%` | Drive (`D:`) or UNC share (`\\10.0.0.31\media`) |
+
+`{filename_noext}` and the other `{…}` forms work the same.
+
+Plex-style movies: `\\10.0.0.31\media\movies\%filename_noext%` creates `movies\Inception\` and places `Inception.mkv` inside it. If the last segment is `%filename%` or `%ext%`, the pattern is the full destination file path.
+
+Type the path in the text box (or **Browse…**), then click a token to insert it at the caret. **Recent** lists saved patterns; the `\\host\share\…` row is only an example and is rejected if you queue it. **Queue** and **Save** store real patterns in `ui-preferences.txt` (`move-to=`).
+
+If a UNC share or mapped drive is disconnected, Workbench tries to reconnect it (same credentials, no extra prompt). Jobs that still cannot reach the destination wait in the queue; **Retry** re-probes and reconnects. Online-only cloud files are skipped.
---
@@ -387,7 +412,6 @@ Left open on purpose:
- Scheduled profiles and folder-watcher triggers
- MIME/EXIF classification
- Duplicate “backup copy” auto-tagging
-- Rename placeholders from EXIF or dates
---
diff --git a/src/Explorer.App/BatchRenameWindow.xaml b/src/Explorer.App/BatchRenameWindow.xaml
index cf97a24..3d453bd 100644
--- a/src/Explorer.App/BatchRenameWindow.xaml
+++ b/src/Explorer.App/BatchRenameWindow.xaml
@@ -68,7 +68,14 @@
+ IsEnabled="{Binding ChangeExtension}" Margin="0,0,0,16"/>
+
+
+
+
+
+
+
+
+
diff --git a/src/Explorer.App/MainWindow.xaml.cs b/src/Explorer.App/MainWindow.xaml.cs
index fb20e77..50b32eb 100644
--- a/src/Explorer.App/MainWindow.xaml.cs
+++ b/src/Explorer.App/MainWindow.xaml.cs
@@ -22,6 +22,10 @@ public partial class MainWindow : Window
private bool _dragPending;
private MouseButton _dragButton;
private FolderItemViewModel? _dragItem;
+ private ListView? _dragList;
+ private FolderItemViewModel[] _dragSelection = [];
+ private bool _dragFromMultiSelect;
+ private bool _syncingSelection;
private readonly ListMarquee _marquee = new();
private bool _suppressItemContextMenu;
private bool _incomingRightDrag;
@@ -577,12 +581,17 @@ public partial class MainWindow : Window
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
- if (sender is not ListView { IsVisible: true } list)
+ if (_syncingSelection || sender is not ListView { IsVisible: true } list)
{
return;
}
ActivatePaneFromList(list);
+ if (_dragFromMultiSelect && list == _dragList && _dragSelection.Length > 1)
+ {
+ RestoreListSelection(list, _dragSelection);
+ }
+
if (_clickRenameItem is not null && !list.SelectedItems.Contains(_clickRenameItem))
{
CancelClickRename();
@@ -597,6 +606,26 @@ public partial class MainWindow : Window
Vm.RefreshCloudActions();
}
+ private void RestoreListSelection(ListView list, IReadOnlyList items)
+ {
+ _syncingSelection = true;
+ try
+ {
+ list.SelectedItems.Clear();
+ foreach (var item in items)
+ {
+ if (list.Items.Contains(item))
+ {
+ list.SelectedItems.Add(item);
+ }
+ }
+ }
+ finally
+ {
+ _syncingSelection = false;
+ }
+ }
+
private void OnPaneFocus(object sender, RoutedEventArgs e)
{
if (sender is ListView list)
@@ -620,11 +649,35 @@ public partial class MainWindow : Window
private void OnListMouseDown(object sender, MouseButtonEventArgs e)
{
+ if (IsInsideInlineRenameBox(e.OriginalSource as DependencyObject))
+ {
+ _dragPending = false;
+ _marquee.Disarm();
+ CancelClickRename();
+ return;
+ }
+
_suppressItemContextMenu = false;
_dragStart = e.GetPosition(null);
_dragPending = true;
_dragButton = e.ChangedButton;
+ _dragList = sender as ListView;
_dragItem = HitTestFolderItem(sender as DependencyObject, e.GetPosition((IInputElement)sender));
+ _dragSelection = _dragList is not null
+ ? _dragList.SelectedItems.OfType().ToArray()
+ : [];
+ var additive = (Keyboard.Modifiers & (ModifierKeys.Control | ModifierKeys.Shift)) != 0;
+ _dragFromMultiSelect = !additive
+ && _dragItem is not null
+ && _dragSelection.Length > 1
+ && Array.IndexOf(_dragSelection, _dragItem) >= 0;
+ if (_dragFromMultiSelect && e.ChangedButton == MouseButton.Left && _dragList is not null)
+ {
+ e.Handled = true;
+ _dragList.Focus();
+ _dragList.CaptureMouse();
+ }
+
if (sender is ListView list
&& _dragItem is null
&& ListMarquee.IsBackground(e.OriginalSource as DependencyObject))
@@ -800,6 +853,13 @@ public partial class MainWindow : Window
return;
}
+ if (IsInsideInlineRenameBox(e.OriginalSource as DependencyObject)
+ || _dragItem is { IsRenaming: true })
+ {
+ _dragPending = false;
+ return;
+ }
+
var pos = e.GetPosition(null);
if (Math.Abs(pos.X - _dragStart.X) < SystemParameters.MinimumHorizontalDragDistance
&& Math.Abs(pos.Y - _dragStart.Y) < SystemParameters.MinimumVerticalDragDistance)
@@ -809,18 +869,13 @@ public partial class MainWindow : Window
_dragPending = false;
CancelClickRename();
- var selected = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
- IReadOnlyList paths;
- if (_dragItem is not null && (selected.Count == 0
- || selected.TrueForAll(p => !p.Equals(_dragItem.FullPath, StringComparison.OrdinalIgnoreCase))))
+ if (_dragList is { IsMouseCaptured: true })
{
- paths = [_dragItem.FullPath];
- }
- else
- {
- paths = selected;
+ _dragList.ReleaseMouseCapture();
}
+ var paths = DragPaths();
+ _dragFromMultiSelect = false;
if (paths.Count == 0)
{
return;
@@ -878,11 +933,42 @@ public partial class MainWindow : Window
}
base.OnPreviewMouseUp(e);
- if (e.ChangedButton == _dragButton)
+ if (e.ChangedButton != _dragButton)
{
- _dragPending = false;
- _marquee.Disarm();
+ return;
}
+
+ if (_dragFromMultiSelect && _dragPending && _dragList is not null && _dragItem is not null)
+ {
+ _dragFromMultiSelect = false;
+ _dragList.SelectedItems.Clear();
+ _dragList.SelectedItem = _dragItem;
+ }
+
+ _dragPending = false;
+ _dragFromMultiSelect = false;
+ _marquee.Disarm();
+ if (_dragList is { IsMouseCaptured: true })
+ {
+ _dragList.ReleaseMouseCapture();
+ }
+ }
+
+ private IReadOnlyList DragPaths()
+ {
+ if (_dragFromMultiSelect && _dragSelection.Length > 0)
+ {
+ return _dragSelection.Select(i => i.FullPath).ToList();
+ }
+
+ var selected = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
+ if (_dragItem is not null && (selected.Count == 0
+ || selected.TrueForAll(p => !p.Equals(_dragItem.FullPath, StringComparison.OrdinalIgnoreCase))))
+ {
+ return [_dragItem.FullPath];
+ }
+
+ return selected;
}
private bool CompleteMarqueeMouseUp(MouseButtonEventArgs e)
@@ -1293,6 +1379,36 @@ public partial class MainWindow : Window
}
}
+ private void OnTags(object sender, RoutedEventArgs e)
+ {
+ var vm = Vm.CreateTagRenameViewModel();
+ if (vm is null)
+ {
+ return;
+ }
+
+ var dlg = new TagRenameWindow(vm) { Owner = this };
+ if (dlg.ShowDialog() == true)
+ {
+ Vm.Footer = "Tag or rename work queued.";
+ }
+ }
+
+ private void OnMoveTo(object sender, RoutedEventArgs e)
+ {
+ var vm = Vm.CreateMoveToViewModel();
+ if (vm is null)
+ {
+ return;
+ }
+
+ var dlg = new MoveToWindow(vm) { Owner = this };
+ if (dlg.ShowDialog() == true)
+ {
+ Vm.Footer = "Move queued.";
+ }
+ }
+
private async void OnExtractHere(object sender, RoutedEventArgs e)
=> await Vm.ExtractSelectedAsync(null).ConfigureAwait(true);
diff --git a/src/Explorer.App/MoveToWindow.xaml b/src/Explorer.App/MoveToWindow.xaml
new file mode 100644
index 0000000..09e3054
--- /dev/null
+++ b/src/Explorer.App/MoveToWindow.xaml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Explorer.App/MoveToWindow.xaml.cs b/src/Explorer.App/MoveToWindow.xaml.cs
new file mode 100644
index 0000000..f7303de
--- /dev/null
+++ b/src/Explorer.App/MoveToWindow.xaml.cs
@@ -0,0 +1,69 @@
+using System.Windows;
+using System.Windows.Controls;
+using Explorer.Presentation.ViewModels;
+
+namespace Explorer.App;
+
+public partial class MoveToWindow : Window
+{
+ public MoveToWindow(MoveToViewModel vm)
+ {
+ InitializeComponent();
+ DataContext = vm;
+ vm.CloseRequested += (_, _) =>
+ {
+ try
+ {
+ DialogResult = true;
+ }
+ catch (InvalidOperationException)
+ {
+ // not shown as a dialog
+ }
+
+ Close();
+ };
+ Loaded += (_, _) =>
+ {
+ PatternBox.CaretIndex = PatternBox.Text?.Length ?? 0;
+ PatternBox.Focus();
+ };
+ }
+
+ private void OnBrowse(object sender, RoutedEventArgs e)
+ {
+ var picker = new Microsoft.Win32.OpenFolderDialog
+ {
+ Title = "Move to",
+ Multiselect = false
+ };
+ if (picker.ShowDialog(this) != true || string.IsNullOrWhiteSpace(picker.FolderName))
+ {
+ return;
+ }
+
+ if (DataContext is MoveToViewModel vm)
+ {
+ vm.Pattern = picker.FolderName;
+ }
+
+ PatternBox.CaretIndex = PatternBox.Text?.Length ?? 0;
+ PatternBox.Focus();
+ }
+
+ private void OnInsertToken(object sender, RoutedEventArgs e)
+ {
+ if (sender is not Button { Tag: string token } || DataContext is not MoveToViewModel vm)
+ {
+ return;
+ }
+
+ var text = PatternBox.Text ?? vm.Pattern ?? "";
+ var caret = Math.Clamp(PatternBox.CaretIndex, 0, text.Length);
+ var slash = caret > 0 && text[caret - 1] != '\\' ? "\\" : "";
+ var insert = slash + token;
+ vm.Pattern = text.Insert(caret, insert);
+ PatternBox.Focus();
+ PatternBox.CaretIndex = caret + insert.Length;
+ }
+}
diff --git a/src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml b/src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml
index a416b32..41b5795 100644
--- a/src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml
+++ b/src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml
@@ -1,7 +1,6 @@
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
diff --git a/src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml.cs b/src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml.cs
index 5275420..4c9841d 100644
--- a/src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml.cs
+++ b/src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml.cs
@@ -10,7 +10,7 @@ public partial class AdvancedSettingsPage : UserControl
private void OnBrowseGit(object sender, RoutedEventArgs e)
{
- if (DataContext is SettingsDraft draft
+ if (SettingsPageContext.DraftOf(this) is { } draft
&& SettingsPathBrowse.TryPick(
Window.GetWindow(this),
"Git executable",
diff --git a/src/Explorer.App/Settings/Pages/AppearanceSettingsPage.xaml b/src/Explorer.App/Settings/Pages/AppearanceSettingsPage.xaml
index 3508e97..4a15d3b 100644
--- a/src/Explorer.App/Settings/Pages/AppearanceSettingsPage.xaml
+++ b/src/Explorer.App/Settings/Pages/AppearanceSettingsPage.xaml
@@ -1,7 +1,6 @@
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
diff --git a/src/Explorer.App/Settings/Pages/FileOperationsSettingsPage.xaml b/src/Explorer.App/Settings/Pages/FileOperationsSettingsPage.xaml
index 2a95a4a..7e7bd5b 100644
--- a/src/Explorer.App/Settings/Pages/FileOperationsSettingsPage.xaml
+++ b/src/Explorer.App/Settings/Pages/FileOperationsSettingsPage.xaml
@@ -1,7 +1,6 @@
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
diff --git a/src/Explorer.App/Settings/Pages/FileOperationsSettingsPage.xaml.cs b/src/Explorer.App/Settings/Pages/FileOperationsSettingsPage.xaml.cs
index cfade10..7b4b33d 100644
--- a/src/Explorer.App/Settings/Pages/FileOperationsSettingsPage.xaml.cs
+++ b/src/Explorer.App/Settings/Pages/FileOperationsSettingsPage.xaml.cs
@@ -10,7 +10,7 @@ public partial class FileOperationsSettingsPage : UserControl
private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
{
- if (DataContext is SettingsDraft draft
+ if (SettingsPageContext.DraftOf(this) is { } draft
&& SettingsPathBrowse.TryPick(
Window.GetWindow(this),
"7-Zip executable",
@@ -24,7 +24,7 @@ public partial class FileOperationsSettingsPage : UserControl
private void OnBrowseFfmpeg(object sender, RoutedEventArgs e)
{
- if (DataContext is SettingsDraft draft
+ if (SettingsPageContext.DraftOf(this) is { } draft
&& SettingsPathBrowse.TryPick(
Window.GetWindow(this),
"FFmpeg executable",
diff --git a/src/Explorer.App/Settings/Pages/GeneralSettingsPage.xaml b/src/Explorer.App/Settings/Pages/GeneralSettingsPage.xaml
index d4b8faa..2b1210d 100644
--- a/src/Explorer.App/Settings/Pages/GeneralSettingsPage.xaml
+++ b/src/Explorer.App/Settings/Pages/GeneralSettingsPage.xaml
@@ -1,7 +1,6 @@
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
diff --git a/src/Explorer.App/Settings/Pages/IndexingSettingsPage.xaml b/src/Explorer.App/Settings/Pages/IndexingSettingsPage.xaml
index cebe962..5d95fa3 100644
--- a/src/Explorer.App/Settings/Pages/IndexingSettingsPage.xaml
+++ b/src/Explorer.App/Settings/Pages/IndexingSettingsPage.xaml
@@ -1,7 +1,7 @@
+ xmlns:sys="clr-namespace:System;assembly=System.Runtime">
@@ -31,16 +31,23 @@
Text="The host watches Windows idle time even if this window is closed. Conservative defaults wait 10 minutes and prefer AC power."/>
-
-
+
+ AutomationProperties.Name="Idle threshold in minutes">
+
+
+ 5
+ 10
+ 30
+
+
+
-
+
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
diff --git a/src/Explorer.App/Settings/SettingsPageContext.cs b/src/Explorer.App/Settings/SettingsPageContext.cs
new file mode 100644
index 0000000..ebff5de
--- /dev/null
+++ b/src/Explorer.App/Settings/SettingsPageContext.cs
@@ -0,0 +1,11 @@
+using System.Windows;
+
+namespace Explorer.App.Settings;
+
+internal static class SettingsPageContext
+{
+ public static SettingsDraft? DraftOf(FrameworkElement page)
+ => page.DataContext as SettingsDraft
+ ?? (page.DataContext as SettingsShell)?.Draft
+ ?? (Window.GetWindow(page) as SettingsWindow)?.Draft;
+}
diff --git a/src/Explorer.App/SettingsWindow.xaml b/src/Explorer.App/SettingsWindow.xaml
index dbd0653..cb98fea 100644
--- a/src/Explorer.App/SettingsWindow.xaml
+++ b/src/Explorer.App/SettingsWindow.xaml
@@ -63,9 +63,7 @@
BorderThickness="1" Padding="16">
-
+
diff --git a/src/Explorer.App/SettingsWindow.xaml.cs b/src/Explorer.App/SettingsWindow.xaml.cs
index 31ed935..a45d8bf 100644
--- a/src/Explorer.App/SettingsWindow.xaml.cs
+++ b/src/Explorer.App/SettingsWindow.xaml.cs
@@ -12,16 +12,46 @@ public partial class SettingsWindow : Window
private readonly SettingsDraft _draft;
private readonly string _originalTheme;
+ public SettingsDraft Draft => _draft;
+
public SettingsWindow(MainViewModel vm)
{
- InitializeComponent();
_vm = vm;
var prefs = vm.CurrentPreferences();
_originalTheme = prefs.Theme;
_draft = SettingsDraft.From(prefs);
_draft.PropertyChanged += OnDraftChanged;
- DataContext = new SettingsShell(_draft, SettingsCatalog.Create());
- Closed += (_, _) => _draft.PropertyChanged -= OnDraftChanged;
+ var shell = new SettingsShell(_draft, SettingsCatalog.Create());
+ DataContext = shell;
+ InitializeComponent();
+ shell.PropertyChanged += OnShellChanged;
+ ShowSelectedPage();
+ Closed += (_, _) =>
+ {
+ _draft.PropertyChanged -= OnDraftChanged;
+ shell.PropertyChanged -= OnShellChanged;
+ };
+ }
+
+ private void OnShellChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(SettingsShell.SelectedCategory) or null)
+ {
+ ShowSelectedPage();
+ }
+ }
+
+ private void ShowSelectedPage()
+ {
+ PageHost.Children.Clear();
+ if (DataContext is not SettingsShell shell)
+ {
+ return;
+ }
+
+ var page = shell.SelectedCategory.Page;
+ PageHost.Children.Add(page);
+ page.DataContext = _draft;
}
private void OnDraftChanged(object? sender, PropertyChangedEventArgs e)
diff --git a/src/Explorer.App/TagRenameWindow.xaml b/src/Explorer.App/TagRenameWindow.xaml
new file mode 100644
index 0000000..8e78133
--- /dev/null
+++ b/src/Explorer.App/TagRenameWindow.xaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Explorer.App/TagRenameWindow.xaml.cs b/src/Explorer.App/TagRenameWindow.xaml.cs
new file mode 100644
index 0000000..d5ea4b3
--- /dev/null
+++ b/src/Explorer.App/TagRenameWindow.xaml.cs
@@ -0,0 +1,26 @@
+using System.Windows;
+using Explorer.Presentation.ViewModels;
+
+namespace Explorer.App;
+
+public partial class TagRenameWindow : Window
+{
+ public TagRenameWindow(TagRenameViewModel vm)
+ {
+ InitializeComponent();
+ DataContext = vm;
+ vm.CloseRequested += (_, _) =>
+ {
+ try
+ {
+ DialogResult = true;
+ }
+ catch (InvalidOperationException)
+ {
+ // not shown as a dialog
+ }
+
+ Close();
+ };
+ }
+}
diff --git a/src/Explorer.Application/DestinationPattern.cs b/src/Explorer.Application/DestinationPattern.cs
new file mode 100644
index 0000000..d1cc385
--- /dev/null
+++ b/src/Explorer.Application/DestinationPattern.cs
@@ -0,0 +1,193 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+using Explorer.Domain;
+
+namespace Explorer.Application;
+
+public sealed record MoveToFields(
+ string FileName,
+ string Stem,
+ string Extension,
+ string Parent,
+ string SourceDrive,
+ DateTimeOffset? Modified,
+ DateTimeOffset? Created);
+
+public static class DestinationPattern
+{
+ private static readonly Regex TokenRx = new(
+ @"%([A-Za-z_]+)%|\{([A-Za-z_]+)\}",
+ RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
+ public static bool TryResolve(
+ string pattern,
+ RenameSubject subject,
+ out string destination,
+ out string? error)
+ {
+ destination = "";
+ var fields = FromSubject(subject);
+ if (!TryExpand(pattern, fields, out var expanded, out error))
+ {
+ return false;
+ }
+
+ destination = subject.IsDirectory || EndsWithFileNameToken(pattern)
+ ? expanded
+ : PathRules.Combine(expanded.TrimEnd('\\'), fields.FileName);
+
+ if (!IsRooted(destination))
+ {
+ error = "Enter a full destination path (drive or UNC).";
+ return false;
+ }
+
+ foreach (var segment in NameSegments(destination))
+ {
+ if (!WindowsFileNames.IsValid(segment, out error))
+ {
+ return false;
+ }
+ }
+
+ error = null;
+ return true;
+ }
+
+ public static bool TryExpand(string pattern, MoveToFields fields, out string expanded, out string? error)
+ {
+ expanded = "";
+ if (string.IsNullOrWhiteSpace(pattern))
+ {
+ error = "Enter a destination path. Use placeholders such as %filename_noext%.";
+ return false;
+ }
+
+ Match leftover = Match.Empty;
+ expanded = PathRules.NormalizeDirectorySeparators(TokenRx.Replace(pattern.Trim(), match =>
+ {
+ var name = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value;
+ var value = Resolve(name, fields);
+ if (value is null)
+ {
+ leftover = match;
+ return match.Value;
+ }
+
+ return value;
+ }));
+
+ if (leftover.Success)
+ {
+ error = "Unknown placeholder " + leftover.Value + ".";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(expanded))
+ {
+ error = "The destination path is empty.";
+ return false;
+ }
+
+ error = null;
+ return true;
+ }
+
+ public static MoveToFields FromSubject(RenameSubject subject)
+ {
+ var tags = FilenamePattern.FromFile(subject.FullPath, subject.IsDirectory);
+ var (stem, extension) = WindowsFileNames.Split(subject.Name);
+ return new MoveToFields(
+ subject.Name,
+ subject.IsDirectory ? subject.Name : stem,
+ subject.IsDirectory ? "" : extension,
+ tags.Parent ?? "",
+ PathRules.VolumeRoot(subject.FullPath),
+ tags.Modified,
+ tags.Created);
+ }
+
+ public static bool EndsWithFileNameToken(string pattern)
+ {
+ var last = LastSegment(pattern);
+ foreach (Match match in TokenRx.Matches(last))
+ {
+ switch (TokenName(match))
+ {
+ case "filename":
+ case "ext":
+ case "extension":
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static string TokenName(Match match)
+ => (match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value).ToLowerInvariant();
+
+ private static string LastSegment(string path)
+ {
+ var normalized = path.Replace('/', '\\').TrimEnd('\\');
+ var idx = normalized.LastIndexOf('\\');
+ return idx >= 0 ? normalized[(idx + 1)..] : normalized;
+ }
+
+ private static string? Resolve(string name, MoveToFields fields)
+ => name.ToLowerInvariant() switch
+ {
+ "filename" => Sanitize(fields.FileName),
+ "filename_noext" or "stem" => Sanitize(fields.Stem),
+ "ext" or "extension" => Sanitize(fields.Extension),
+ "parent" => Sanitize(fields.Parent),
+ "source_drive" or "drive" => fields.SourceDrive,
+ "year" => DatePart(fields, "yyyy"),
+ "month" => DatePart(fields, "MM"),
+ _ => null
+ };
+
+ private static string Sanitize(string? value)
+ => WindowsFileNames.SanitizeForFileName(value ?? "");
+
+ private static string DatePart(MoveToFields fields, string format)
+ {
+ var stamp = (fields.Modified ?? fields.Created ?? DateTimeOffset.Now).ToLocalTime();
+ return stamp.ToString(format, CultureInfo.InvariantCulture);
+ }
+
+ private static bool IsRooted(string path)
+ {
+ var p = PathRules.FromExtended(path);
+ return PathRules.IsUnc(p) || (p.Length >= 2 && p[1] == ':');
+ }
+
+ internal static IEnumerable NameSegments(string path)
+ {
+ var p = PathRules.FromExtended(path).TrimEnd('\\');
+ string rest;
+ if (PathRules.IsUnc(p))
+ {
+ var root = PathRules.CanonicalUncRoot(p);
+ if (p.Length <= root.Length)
+ {
+ yield break;
+ }
+
+ rest = p[(root.Length + 1)..];
+ }
+ else if (p.Length >= 2 && p[1] == ':')
+ {
+ rest = p.Length > 3 ? p[3..].TrimStart('\\') : "";
+ }
+ else
+ {
+ rest = p;
+ }
+
+ foreach (var part in rest.Split('\\', StringSplitOptions.RemoveEmptyEntries))
+ {
+ yield return part;
+ }
+ }
+}
diff --git a/src/Explorer.Application/DestinationPatterns.cs b/src/Explorer.Application/DestinationPatterns.cs
new file mode 100644
index 0000000..102ce27
--- /dev/null
+++ b/src/Explorer.Application/DestinationPatterns.cs
@@ -0,0 +1,88 @@
+namespace Explorer.Application;
+
+public static class DestinationPatterns
+{
+ public const int MaxCount = 24;
+ public const int MaxLength = 500;
+
+ public static IReadOnlyList BuiltIn { get; } =
+ [
+ @"\\host\share\movies\%filename_noext%",
+ @"%source_drive%\sorted\%year%\%month%"
+ ];
+
+ public static IReadOnlyList Combine(IEnumerable? saved)
+ {
+ var result = new List();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var pattern in (saved ?? []).Concat(BuiltIn))
+ {
+ if (!TryNormalize(pattern, out var text) || !seen.Add(text))
+ {
+ continue;
+ }
+
+ result.Add(text);
+ if (result.Count >= MaxCount)
+ {
+ break;
+ }
+ }
+
+ return result;
+ }
+
+ public static IReadOnlyList Add(IEnumerable? saved, string pattern)
+ {
+ if (!TryNormalize(pattern, out var text))
+ {
+ return Normalize(saved);
+ }
+
+ var next = new List { text };
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase) { text };
+ foreach (var existing in Normalize(saved))
+ {
+ if (seen.Add(existing))
+ {
+ next.Add(existing);
+ }
+
+ if (next.Count >= MaxCount)
+ {
+ break;
+ }
+ }
+
+ return next;
+ }
+
+ public static IReadOnlyList Normalize(IEnumerable? saved)
+ {
+ var result = new List();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var raw in saved ?? [])
+ {
+ if (!TryNormalize(raw, out var text)
+ || BuiltIn.Contains(text, StringComparer.OrdinalIgnoreCase)
+ || !seen.Add(text))
+ {
+ continue;
+ }
+
+ result.Add(text);
+ if (result.Count >= MaxCount)
+ {
+ break;
+ }
+ }
+
+ return result;
+ }
+
+ public static bool TryNormalize(string? raw, out string pattern)
+ {
+ pattern = (raw ?? "").Trim();
+ return pattern.Length is > 0 && pattern.Length <= MaxLength;
+ }
+}
diff --git a/src/Explorer.Application/FilenamePattern.cs b/src/Explorer.Application/FilenamePattern.cs
new file mode 100644
index 0000000..b17ff9e
--- /dev/null
+++ b/src/Explorer.Application/FilenamePattern.cs
@@ -0,0 +1,314 @@
+using System.Globalization;
+using System.Text;
+using System.Text.RegularExpressions;
+using Explorer.Domain;
+
+namespace Explorer.Application;
+
+public static class FilenamePattern
+{
+ public const string DefaultAudioPattern = "{Artist} - {Title}";
+ public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(250);
+ private static readonly Regex TokenRx = new(
+ @"\{([A-Za-z]+)(?::([^}]+))?\}",
+ RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
+ public static string Expand(string pattern, MediaTagFields tags, string counter = "")
+ {
+ if (string.IsNullOrEmpty(pattern))
+ {
+ return pattern;
+ }
+
+ return TokenRx.Replace(pattern, match =>
+ {
+ var name = match.Groups[1].Value;
+ var format = match.Groups[2].Success ? match.Groups[2].Value : null;
+ return Resolve(name, format, tags, counter) ?? match.Value;
+ });
+ }
+
+ public static bool UsesMediaContent(string? text)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ return false;
+ }
+
+ foreach (Match match in TokenRx.Matches(text))
+ {
+ switch (match.Groups[1].Value.ToLowerInvariant())
+ {
+ case "artist":
+ case "title":
+ case "album":
+ case "track":
+ case "trackcount":
+ case "year":
+ case "genre":
+ case "comment":
+ case "width":
+ case "height":
+ case "takendate":
+ case "datetaken":
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public static bool TryParse(string pattern, string stem, out MediaTagFields tags, out string? error)
+ {
+ tags = new MediaTagFields();
+ error = null;
+ if (string.IsNullOrWhiteSpace(pattern))
+ {
+ error = "Enter a filename pattern such as {Artist} - {Title}.";
+ return false;
+ }
+
+ var regex = new StringBuilder("^");
+ var names = new List();
+ var last = 0;
+ foreach (Match match in TokenRx.Matches(pattern))
+ {
+ regex.Append(Regex.Escape(pattern[last..match.Index]));
+ var name = match.Groups[1].Value;
+ names.Add(name);
+ regex.Append(Capture(name, names.Count == CountTokens(pattern)));
+ last = match.Index + match.Length;
+ }
+
+ regex.Append(Regex.Escape(pattern[last..]));
+ regex.Append('$');
+
+ Match parsed;
+ try
+ {
+ parsed = Regex.Match(stem, regex.ToString(), RegexOptions.CultureInvariant, RegexTimeout);
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ error = "The filename pattern took too long.";
+ return false;
+ }
+ catch (ArgumentException ex)
+ {
+ error = "Invalid filename pattern: " + ex.Message;
+ return false;
+ }
+
+ if (!parsed.Success)
+ {
+ error = "The name does not match the pattern.";
+ return false;
+ }
+
+ var artist = Value(parsed, "Artist");
+ var title = Value(parsed, "Title");
+ var album = Value(parsed, "Album");
+ var genre = Value(parsed, "Genre");
+ var comment = Value(parsed, "Comment");
+ int? track = ParseInt(Value(parsed, "Track"));
+ int? year = ParseInt(Value(parsed, "Year"));
+ var taken = ParseDate(
+ Value(parsed, "TakenDate") ?? Value(parsed, "DateTaken") ?? Value(parsed, "CreatedDate") ?? Value(parsed, "Date"),
+ DateFormat(pattern));
+ tags = new MediaTagFields
+ {
+ Artist = artist,
+ Title = title,
+ Album = album,
+ Genre = genre,
+ Comment = comment,
+ Track = track,
+ Year = year ?? taken?.Year,
+ Taken = taken
+ };
+ return tags.HasWritableTags;
+ }
+
+ public static MediaTagFields FromFile(string path, bool isDirectory)
+ {
+ var name = PathRules.GetFileName(path);
+ var (stem, extension) = WindowsFileNames.Split(name);
+ var parent = PathRules.GetFileName(PathRules.Parent(path).TrimEnd('\\'));
+ DateTimeOffset? created = null;
+ DateTimeOffset? modified = null;
+ var disk = PathRules.ToExtended(path);
+ try
+ {
+ if (isDirectory)
+ {
+ if (Directory.Exists(disk))
+ {
+ created = Directory.GetCreationTime(disk);
+ modified = Directory.GetLastWriteTime(disk);
+ }
+ }
+ else if (System.IO.File.Exists(disk))
+ {
+ created = System.IO.File.GetCreationTime(disk);
+ modified = System.IO.File.GetLastWriteTime(disk);
+ }
+ }
+ catch (IOException)
+ {
+ // Timestamps are optional.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+
+ return new MediaTagFields
+ {
+ Stem = isDirectory ? name : stem,
+ Extension = isDirectory ? "" : extension,
+ Parent = parent,
+ Created = created,
+ Modified = modified
+ };
+ }
+
+ public static MediaTagFields WithProject(MediaTagFields fields, string? repoRoot)
+ {
+ var project = string.IsNullOrWhiteSpace(repoRoot)
+ ? fields.Parent
+ : PathRules.GetFileName(repoRoot.TrimEnd('\\'));
+ return fields with { Project = string.IsNullOrWhiteSpace(project) ? fields.Parent : project };
+ }
+
+ private static int CountTokens(string pattern) => TokenRx.Matches(pattern).Count;
+
+ private static string Capture(string name, bool last)
+ {
+ var key = name.ToLowerInvariant() switch
+ {
+ "track" or "trackcount" or "year" or "width" or "height" or "counter" => @"\d+",
+ _ => last ? ".+" : ".+?"
+ };
+ return $"(?<{SanitizeGroup(name)}>{key})";
+ }
+
+ private static string SanitizeGroup(string name)
+ => string.Concat(name.Where(char.IsLetterOrDigit));
+
+ private static string? Value(Match match, string name)
+ {
+ var group = SanitizeGroup(name);
+ return match.Groups[group].Success ? match.Groups[group].Value.Trim() : null;
+ }
+
+ private static int? ParseInt(string? text)
+ => int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null;
+
+ private static string? Resolve(string name, string? format, MediaTagFields tags, string counter)
+ => name.ToLowerInvariant() switch
+ {
+ "artist" => Sanitize(tags.Artist),
+ "title" => Sanitize(tags.Title),
+ "album" => Sanitize(tags.Album),
+ "genre" => Sanitize(tags.Genre),
+ "comment" => Sanitize(tags.Comment),
+ "track" => Number(tags.Track, format),
+ "trackcount" => Number(tags.TrackCount, format),
+ "year" => Number(tags.Year, format),
+ "width" => Number(tags.Width, format),
+ "height" => Number(tags.Height, format),
+ "counter" => counter,
+ "name" or "stem" => Sanitize(tags.Stem),
+ "extension" => Sanitize(tags.Extension),
+ "parent" => Sanitize(tags.Parent),
+ "project" => Sanitize(tags.Project ?? tags.Parent),
+ "createddate" or "date" => Date(tags.Taken ?? tags.Created, format),
+ "takendate" or "datetaken" => Date(tags.Taken ?? tags.Created, format),
+ "modifieddate" => Date(tags.Modified, format),
+ _ => null
+ };
+
+ private static string Sanitize(string? value)
+ => WindowsFileNames.SanitizeForFileName(value ?? "");
+
+ private static string Number(int? value, string? format)
+ {
+ if (value is null)
+ {
+ return "";
+ }
+
+ if (string.IsNullOrWhiteSpace(format))
+ {
+ return value.Value.ToString(CultureInfo.InvariantCulture);
+ }
+
+ try
+ {
+ return value.Value.ToString(format, CultureInfo.InvariantCulture);
+ }
+ catch (FormatException)
+ {
+ return value.Value.ToString(CultureInfo.InvariantCulture);
+ }
+ }
+
+ private static string Date(DateTimeOffset? value, string? format)
+ {
+ if (value is null)
+ {
+ return "";
+ }
+
+ var text = value.Value.ToLocalTime().ToString(
+ string.IsNullOrWhiteSpace(format) ? "yyyy-MM-dd" : format,
+ CultureInfo.InvariantCulture);
+ return WindowsFileNames.SanitizeForFileName(text);
+ }
+
+ private static string? DateFormat(string pattern)
+ {
+ foreach (Match match in TokenRx.Matches(pattern))
+ {
+ var name = match.Groups[1].Value.ToLowerInvariant();
+ if ((name is "takendate" or "datetaken" or "createddate" or "date")
+ && match.Groups[2].Success)
+ {
+ return match.Groups[2].Value;
+ }
+ }
+
+ return null;
+ }
+
+ private static DateTimeOffset? ParseDate(string? text, string? format)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return null;
+ }
+
+ var formats = new List();
+ if (!string.IsNullOrWhiteSpace(format))
+ {
+ formats.Add(format);
+ }
+
+ formats.AddRange(["yyyy-MM-dd", "yyyyMMdd", "yyyy-MM-dd HH-mm", "yyyy-MM-dd-HH-mm-ss"]);
+ foreach (var candidate in formats)
+ {
+ if (DateTime.TryParseExact(
+ text,
+ candidate,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeLocal,
+ out var exact))
+ {
+ return new DateTimeOffset(exact);
+ }
+ }
+
+ return DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed)
+ ? parsed
+ : null;
+ }
+}
diff --git a/src/Explorer.Application/FilenamePatterns.cs b/src/Explorer.Application/FilenamePatterns.cs
new file mode 100644
index 0000000..4206572
--- /dev/null
+++ b/src/Explorer.Application/FilenamePatterns.cs
@@ -0,0 +1,88 @@
+namespace Explorer.Application;
+
+public static class FilenamePatterns
+{
+ public const int MaxCount = 24;
+
+ public static IReadOnlyList BuiltIn { get; } =
+ [
+ "{Artist} - {Title}",
+ "{Track:00} - {Title}",
+ "{TakenDate}_{Name}",
+ "{CreatedDate}_{Name}",
+ "{Project}_{Name}"
+ ];
+
+ public static IReadOnlyList Combine(IEnumerable? saved)
+ {
+ var result = new List();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var pattern in BuiltIn.Concat(saved ?? []))
+ {
+ if (!TryNormalize(pattern, out var text) || !seen.Add(text))
+ {
+ continue;
+ }
+
+ result.Add(text);
+ if (result.Count >= MaxCount)
+ {
+ break;
+ }
+ }
+
+ return result;
+ }
+
+ public static IReadOnlyList Add(IEnumerable? saved, string pattern)
+ {
+ if (!TryNormalize(pattern, out var text))
+ {
+ return Normalize(saved);
+ }
+
+ var next = new List { text };
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase) { text };
+ foreach (var existing in Normalize(saved))
+ {
+ if (seen.Add(existing))
+ {
+ next.Add(existing);
+ }
+
+ if (next.Count >= MaxCount)
+ {
+ break;
+ }
+ }
+
+ return next;
+ }
+
+ public static IReadOnlyList Normalize(IEnumerable? saved)
+ {
+ var result = new List();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var raw in saved ?? [])
+ {
+ if (!TryNormalize(raw, out var text) || BuiltIn.Contains(text, StringComparer.OrdinalIgnoreCase) || !seen.Add(text))
+ {
+ continue;
+ }
+
+ result.Add(text);
+ if (result.Count >= MaxCount)
+ {
+ break;
+ }
+ }
+
+ return result;
+ }
+
+ public static bool TryNormalize(string? raw, out string pattern)
+ {
+ pattern = (raw ?? "").Trim();
+ return pattern.Length is > 0 and <= 200;
+ }
+}
diff --git a/src/Explorer.Application/IMediaTagService.cs b/src/Explorer.Application/IMediaTagService.cs
new file mode 100644
index 0000000..6f6c11d
--- /dev/null
+++ b/src/Explorer.Application/IMediaTagService.cs
@@ -0,0 +1,9 @@
+using Explorer.Domain;
+
+namespace Explorer.Application;
+
+public interface IMediaTagService
+{
+ bool TryRead(string path, out MediaTagFields fields);
+ bool TryWrite(string path, MediaTagFields fields, out string? error);
+}
diff --git a/src/Explorer.Application/MoveToPlanner.cs b/src/Explorer.Application/MoveToPlanner.cs
new file mode 100644
index 0000000..7edb8b0
--- /dev/null
+++ b/src/Explorer.Application/MoveToPlanner.cs
@@ -0,0 +1,116 @@
+using Explorer.Domain;
+
+namespace Explorer.Application;
+
+public sealed class MoveToPlanner
+{
+ public OperationPlan Build(
+ IReadOnlyList subjects,
+ string pattern,
+ Func? pathExists = null,
+ Func? wouldHydrate = null)
+ {
+ if (subjects.Count == 0)
+ {
+ return new OperationPlan
+ {
+ Issues = [new PlanIssue(PlanIssueSeverity.Error, "Select files or folders to move.")]
+ };
+ }
+
+ var issues = new List();
+ var operations = new List();
+ var preview = new List();
+ var taken = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var subject in subjects)
+ {
+ if (wouldHydrate?.Invoke(subject.FullPath) == true)
+ {
+ issues.Add(new PlanIssue(
+ PlanIssueSeverity.Error,
+ "This file is online-only. Moving it would download it.",
+ subject.FullPath));
+ preview.Add(new ProfilePreviewRow("Skip", subject.Name, "Online-only"));
+ continue;
+ }
+
+ if (!DestinationPattern.TryResolve(pattern, subject, out var dest, out var error))
+ {
+ issues.Add(new PlanIssue(PlanIssueSeverity.Error, error ?? "Invalid destination.", subject.FullPath));
+ preview.Add(new ProfilePreviewRow("Skip", subject.Name, error));
+ continue;
+ }
+
+ dest = PathRules.FromExtended(dest);
+ if (IsExampleHost(dest))
+ {
+ issues.Add(new PlanIssue(
+ PlanIssueSeverity.Error,
+ @"Replace \\host\share with a real server or mapped drive, or use Browse….",
+ dest));
+ preview.Add(new ProfilePreviewRow("Skip", dest, "Example host — not a real share"));
+ continue;
+ }
+ var source = PathRules.FromExtended(subject.FullPath);
+ if (Same(source, dest))
+ {
+ preview.Add(new ProfilePreviewRow("Skip", dest, "Already there"));
+ continue;
+ }
+
+ if (subject.IsDirectory && SameOrUnder(source, dest))
+ {
+ issues.Add(new PlanIssue(
+ PlanIssueSeverity.Error,
+ "Would move a folder into itself.",
+ subject.FullPath));
+ preview.Add(new ProfilePreviewRow("Skip", dest, "Would move a folder into itself."));
+ continue;
+ }
+
+ if (!taken.Add(dest) || (pathExists?.Invoke(dest) == true && !Same(source, dest)))
+ {
+ issues.Add(new PlanIssue(PlanIssueSeverity.Error, "A file with that name already exists.", dest));
+ preview.Add(new ProfilePreviewRow("Skip", dest, "A file with that name already exists."));
+ continue;
+ }
+
+ operations.Add(new PlannedOperation(TransferOp.Move, subject.FullPath, dest));
+ preview.Add(new ProfilePreviewRow("Move", dest, subject.Name));
+ }
+
+ if (issues.Any(i => i.Severity == PlanIssueSeverity.Error))
+ {
+ return new OperationPlan { Issues = issues, ProfilePreview = preview };
+ }
+
+ return new OperationPlan
+ {
+ Operations = operations,
+ Issues = issues,
+ ProfilePreview = preview
+ };
+ }
+
+ private static bool Same(string left, string right)
+ {
+ var a = PathRules.FromExtended(left).TrimEnd('\\');
+ var b = PathRules.FromExtended(right).TrimEnd('\\');
+ return a.Equals(b, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool SameOrUnder(string parent, string child)
+ {
+ var p = PathRules.FromExtended(parent).TrimEnd('\\');
+ var c = PathRules.FromExtended(child).TrimEnd('\\');
+ return c.StartsWith(p + "\\", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsExampleHost(string destination)
+ {
+ var root = PathRules.CanonicalUncRoot(destination);
+ return root.Equals(@"\\host", StringComparison.OrdinalIgnoreCase)
+ || root.StartsWith(@"\\host\", StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/Explorer.Application/RenamePlanner.cs b/src/Explorer.Application/RenamePlanner.cs
index 908f385..2be7af5 100644
--- a/src/Explorer.Application/RenamePlanner.cs
+++ b/src/Explorer.Application/RenamePlanner.cs
@@ -10,7 +10,8 @@ public sealed class RenamePlanner
public OperationPlan Build(
IReadOnlyList subjects,
RenameRuleSet rules,
- Func? pathExists = null)
+ Func? pathExists = null,
+ IReadOnlyDictionary? tagsByPath = null)
{
var issues = new List();
Regex? regex = null;
@@ -42,7 +43,21 @@ public sealed class RenamePlanner
string newName;
try
{
- newName = Apply(subject.Name, rules, index, regex);
+ var tags = tagsByPath is not null && tagsByPath.TryGetValue(subject.FullPath, out var found)
+ ? found
+ : FilenamePattern.FromFile(subject.FullPath, subject.IsDirectory);
+ if (NeedsMedia(rules) && tags.HydrationBlocked)
+ {
+ issues.Add(new PlanIssue(
+ PlanIssueSeverity.Error,
+ "This file is online-only. Reading tags would download it.",
+ subject.FullPath));
+ rows.Add((subject, subject.Name, subject.FullPath, true, "Online-only"));
+ index++;
+ continue;
+ }
+
+ newName = Apply(subject.Name, rules, index, regex, tags);
}
catch (RegexMatchTimeoutException)
{
@@ -186,27 +201,38 @@ public sealed class RenamePlanner
return new OperationPlan { Operations = operations };
}
- internal static string Apply(string name, RenameRuleSet rules, int index, Regex? regex)
+ internal static string Apply(string name, RenameRuleSet rules, int index, Regex? regex, MediaTagFields? tags = null)
{
var (stem, extension) = WindowsFileNames.Split(name);
- var text = rules.IncludeExtensionInSearch ? name : stem;
- text = Replace(text, rules, regex);
- text = (rules.Prefix ?? "") + text + (rules.Suffix ?? "");
+ tags ??= new MediaTagFields { Stem = stem, Extension = extension };
var counter = rules.UseCounter
? WindowsFileNames.FormatCounter(rules.CounterStart + index * Math.Max(1, rules.CounterStep), rules.CounterPadding)
: "";
+ string text;
+ if (!string.IsNullOrWhiteSpace(rules.NamePattern))
+ {
+ text = FilenamePattern.Expand(rules.NamePattern, tags, counter);
+ }
+ else
+ {
+ text = rules.IncludeExtensionInSearch ? name : stem;
+ text = Replace(text, rules, regex);
+ }
+
+ text = (rules.Prefix ?? "") + text + (rules.Suffix ?? "");
if (text.Contains("{Counter}", StringComparison.OrdinalIgnoreCase))
{
text = Regex.Replace(text, "\\{Counter\\}", counter, RegexOptions.IgnoreCase);
}
- else if (rules.UseCounter)
+ else if (rules.UseCounter && string.IsNullOrWhiteSpace(rules.NamePattern))
{
text += counter;
}
+ text = FilenamePattern.Expand(text, tags, counter);
text = WindowsFileNames.ApplyCase(text, rules.CaseMode);
text = text.Replace("{Extension}", extension, StringComparison.OrdinalIgnoreCase);
- if (rules.IncludeExtensionInSearch && !rules.ChangeExtension)
+ if (rules.IncludeExtensionInSearch && !rules.ChangeExtension && string.IsNullOrWhiteSpace(rules.NamePattern))
{
return text;
}
@@ -215,6 +241,13 @@ public sealed class RenamePlanner
return WindowsFileNames.Join(text, newExt);
}
+ private static bool NeedsMedia(RenameRuleSet rules)
+ => FilenamePattern.UsesMediaContent(rules.NamePattern)
+ || FilenamePattern.UsesMediaContent(rules.Prefix)
+ || FilenamePattern.UsesMediaContent(rules.Suffix)
+ || FilenamePattern.UsesMediaContent(rules.Replace)
+ || FilenamePattern.UsesMediaContent(rules.Search);
+
private static string Replace(string text, RenameRuleSet rules, Regex? regex)
{
if (string.IsNullOrEmpty(rules.Search))
diff --git a/src/Explorer.Application/TagRenamePlanner.cs b/src/Explorer.Application/TagRenamePlanner.cs
new file mode 100644
index 0000000..cd88e16
--- /dev/null
+++ b/src/Explorer.Application/TagRenamePlanner.cs
@@ -0,0 +1,123 @@
+using Explorer.Domain;
+
+namespace Explorer.Application;
+
+public sealed class TagRenamePlanner
+{
+ public OperationPlan BuildWrite(
+ IReadOnlyList subjects,
+ string pattern,
+ IReadOnlyDictionary current,
+ Func? wouldHydrate = null)
+ {
+ var issues = new List();
+ var preview = new List();
+ var operations = new List();
+ foreach (var subject in subjects)
+ {
+ if (subject.IsDirectory)
+ {
+ preview.Add(Row(subject, current, null, "Folders do not have media tags.", true, true));
+ continue;
+ }
+
+ if (wouldHydrate?.Invoke(subject.FullPath) == true)
+ {
+ issues.Add(new PlanIssue(
+ PlanIssueSeverity.Error,
+ "This file is online-only. Writing tags would download it.",
+ subject.FullPath));
+ preview.Add(Row(subject, current, null, "Online-only", true, true));
+ continue;
+ }
+
+ var (stem, _) = WindowsFileNames.Split(subject.Name);
+ if (!FilenamePattern.TryParse(pattern, stem, out var parsed, out var error))
+ {
+ issues.Add(new PlanIssue(PlanIssueSeverity.Error, error ?? "The name does not match the pattern.", subject.FullPath));
+ preview.Add(Row(subject, current, parsed, error, true, true));
+ continue;
+ }
+
+ var existing = current.GetValueOrDefault(subject.FullPath) ?? new MediaTagFields();
+ var unchanged = SameWritable(existing, parsed);
+ preview.Add(Row(subject, current, parsed, unchanged ? "Unchanged" : null, unchanged, true));
+ if (!unchanged)
+ {
+ operations.Add(new PlannedOperation(TransferOp.WriteTags, subject.FullPath, parsed.Payload()));
+ }
+ }
+
+ if (issues.Any(i => i.Severity == PlanIssueSeverity.Error))
+ {
+ return new OperationPlan { Issues = issues, TagPreview = preview };
+ }
+
+ return new OperationPlan { Operations = operations, Issues = issues, TagPreview = preview };
+ }
+
+ public OperationPlan BuildRename(
+ IReadOnlyList subjects,
+ string pattern,
+ IReadOnlyDictionary current,
+ RenamePlanner rename,
+ Func? pathExists = null,
+ Func? wouldHydrate = null)
+ {
+ var issues = new List();
+ foreach (var subject in subjects)
+ {
+ if (wouldHydrate?.Invoke(subject.FullPath) == true
+ && FilenamePattern.UsesMediaContent(pattern))
+ {
+ issues.Add(new PlanIssue(
+ PlanIssueSeverity.Error,
+ "This file is online-only. Reading tags would download it.",
+ subject.FullPath));
+ }
+ }
+
+ if (issues.Count > 0)
+ {
+ return new OperationPlan { Issues = issues };
+ }
+
+ return rename.Build(
+ subjects,
+ new RenameRuleSet { NamePattern = pattern },
+ pathExists,
+ current);
+ }
+
+ private static TagPreviewRow Row(
+ RenameSubject subject,
+ IReadOnlyDictionary current,
+ MediaTagFields? parsed,
+ string? status,
+ bool tagsUnchanged,
+ bool nameUnchanged)
+ {
+ var fields = parsed ?? current.GetValueOrDefault(subject.FullPath) ?? new MediaTagFields();
+ return new TagPreviewRow(
+ subject.FullPath,
+ subject.Name,
+ fields.Artist ?? "",
+ fields.Title ?? "",
+ fields.Album ?? "",
+ fields.Track?.ToString() ?? "",
+ fields.Year?.ToString() ?? "",
+ fields.Genre ?? "",
+ null,
+ status,
+ tagsUnchanged,
+ nameUnchanged);
+ }
+
+ private static bool SameWritable(MediaTagFields left, MediaTagFields right)
+ => string.Equals(left.Artist ?? "", right.Artist ?? "", StringComparison.Ordinal)
+ && string.Equals(left.Title ?? "", right.Title ?? "", StringComparison.Ordinal)
+ && string.Equals(left.Album ?? "", right.Album ?? "", StringComparison.Ordinal)
+ && left.Track == right.Track
+ && left.Year == right.Year
+ && string.Equals(left.Genre ?? "", right.Genre ?? "", StringComparison.Ordinal);
+}
diff --git a/src/Explorer.Application/UiPreferencesStore.cs b/src/Explorer.Application/UiPreferencesStore.cs
index faf31c1..9e147fa 100644
--- a/src/Explorer.Application/UiPreferencesStore.cs
+++ b/src/Explorer.Application/UiPreferencesStore.cs
@@ -42,7 +42,9 @@ public sealed record UiPreferences(
bool PreferFavoritesInTree = false,
bool BackgroundMaintenanceWhenIdle = true,
int IdleMaintenanceMinutes = 10,
- bool IdleMaintenanceAcOnly = true)
+ bool IdleMaintenanceAcOnly = true,
+ IReadOnlyList? NamePatterns = null,
+ IReadOnlyList? MoveToPatterns = null)
{
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
}
@@ -99,6 +101,8 @@ public sealed class UiPreferencesStore
.. OrganizeLines(preferences),
.. LayoutLines(preferences),
.. FavoriteLines(preferences),
+ .. NamePatternLines(preferences),
+ .. MoveToPatternLines(preferences),
.. SessionLines(preferences)
]);
}
@@ -142,6 +146,8 @@ public sealed class UiPreferencesStore
var sessionTabs = new List();
var sessionActiveTab = 0;
var favorites = new List();
+ var namePatterns = new List();
+ var moveToPatterns = new List();
foreach (var raw in lines)
{
var line = raw.Trim();
@@ -280,6 +286,16 @@ public sealed class UiPreferencesStore
{
favorites.Add(value);
}
+ else if (key.Equals("name-pattern", StringComparison.OrdinalIgnoreCase)
+ && namePatterns.Count < FilenamePatterns.MaxCount)
+ {
+ namePatterns.Add(value);
+ }
+ else if (key.Equals("move-to", StringComparison.OrdinalIgnoreCase)
+ && moveToPatterns.Count < DestinationPatterns.MaxCount)
+ {
+ moveToPatterns.Add(value);
+ }
else if (key.Equals("session-active-tab", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var activeTab)
&& activeTab >= 0)
@@ -305,7 +321,9 @@ public sealed class UiPreferencesStore
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon,
FavoriteFolders.Normalize(favorites), sessionTabs, sessionActiveTab, preferFavoritesInTree,
- backgroundMaintenanceWhenIdle, idleMaintenanceMinutes, idleMaintenanceAcOnly);
+ backgroundMaintenanceWhenIdle, idleMaintenanceMinutes, idleMaintenanceAcOnly,
+ FilenamePatterns.Normalize(namePatterns),
+ DestinationPatterns.Normalize(moveToPatterns));
}
private static IEnumerable SevenZipLines(UiPreferences preferences)
@@ -403,6 +421,22 @@ public sealed class UiPreferencesStore
}
}
+ private static IEnumerable NamePatternLines(UiPreferences preferences)
+ {
+ foreach (var pattern in FilenamePatterns.Normalize(preferences.NamePatterns))
+ {
+ yield return "name-pattern=" + pattern;
+ }
+ }
+
+ private static IEnumerable MoveToPatternLines(UiPreferences preferences)
+ {
+ foreach (var pattern in DestinationPatterns.Normalize(preferences.MoveToPatterns))
+ {
+ yield return "move-to=" + pattern;
+ }
+ }
+
private static IEnumerable FavoriteLines(UiPreferences preferences)
{
foreach (var path in FavoriteFolders.Normalize(preferences.FavoriteFolders))
diff --git a/src/Explorer.Contracts/IWorkbenchHost.cs b/src/Explorer.Contracts/IWorkbenchHost.cs
index db34d57..2e492f0 100644
--- a/src/Explorer.Contracts/IWorkbenchHost.cs
+++ b/src/Explorer.Contracts/IWorkbenchHost.cs
@@ -57,6 +57,10 @@ public interface ITransferHost
=> Task.CompletedTask;
Task EnqueueConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
+ Task EnqueueWriteTagsAsync(string path, string payload, CancellationToken cancellationToken = default)
+ => Task.CompletedTask;
+ Task EnqueueMoveToAsync(string source, string destinationPath, CancellationToken cancellationToken = default)
+ => Task.CompletedTask;
}
public interface ISourceHost
diff --git a/src/Explorer.Domain/Abstractions/Platform.cs b/src/Explorer.Domain/Abstractions/Platform.cs
index 9f236e6..ca586d9 100644
--- a/src/Explorer.Domain/Abstractions/Platform.cs
+++ b/src/Explorer.Domain/Abstractions/Platform.cs
@@ -23,6 +23,7 @@ public interface IVolumeService
VolumeFingerprint? Probe(string path);
VolumeSpace GetSpace(string path);
bool IsPathReachable(string path);
+ bool TryEnsureReachable(string path) => IsPathReachable(path);
}
public interface IFileSystemEnumerator
diff --git a/src/Explorer.Domain/Enums.cs b/src/Explorer.Domain/Enums.cs
index 0b9c7c0..f06fe51 100644
--- a/src/Explorer.Domain/Enums.cs
+++ b/src/Explorer.Domain/Enums.cs
@@ -99,7 +99,8 @@ public enum TransferOp
Compress,
AddToArchive,
VerifyArchive,
- Convert
+ Convert,
+ WriteTags
}
public enum ArchiveFormat
diff --git a/src/Explorer.Domain/MediaTags.cs b/src/Explorer.Domain/MediaTags.cs
new file mode 100644
index 0000000..67c9205
--- /dev/null
+++ b/src/Explorer.Domain/MediaTags.cs
@@ -0,0 +1,148 @@
+namespace Explorer.Domain;
+
+public sealed record MediaTagFields
+{
+ public string? Artist { get; init; }
+ public string? Title { get; init; }
+ public string? Album { get; init; }
+ public int? Track { get; init; }
+ public int? TrackCount { get; init; }
+ public int? Year { get; init; }
+ public string? Genre { get; init; }
+ public string? Comment { get; init; }
+ public int? Width { get; init; }
+ public int? Height { get; init; }
+ public DateTimeOffset? Created { get; init; }
+ public DateTimeOffset? Modified { get; init; }
+ public DateTimeOffset? Taken { get; init; }
+ public string? Stem { get; init; }
+ public string? Extension { get; init; }
+ public string? Parent { get; init; }
+ public string? Project { get; init; }
+ public bool HydrationBlocked { get; init; }
+ public string? ReadError { get; init; }
+
+ public MediaTagFields Merge(MediaTagFields other)
+ => new()
+ {
+ Artist = other.Artist ?? Artist,
+ Title = other.Title ?? Title,
+ Album = other.Album ?? Album,
+ Track = other.Track ?? Track,
+ TrackCount = other.TrackCount ?? TrackCount,
+ Year = other.Year ?? Year,
+ Genre = other.Genre ?? Genre,
+ Comment = other.Comment ?? Comment,
+ Width = other.Width ?? Width,
+ Height = other.Height ?? Height,
+ Created = other.Created ?? Created,
+ Modified = other.Modified ?? Modified,
+ Taken = other.Taken ?? Taken,
+ Stem = other.Stem ?? Stem,
+ Extension = other.Extension ?? Extension,
+ Parent = other.Parent ?? Parent,
+ Project = other.Project ?? Project,
+ HydrationBlocked = HydrationBlocked || other.HydrationBlocked,
+ ReadError = other.ReadError ?? ReadError
+ };
+
+ public string Payload()
+ {
+ var parts = new List();
+ Add(parts, "Artist", Artist);
+ Add(parts, "Title", Title);
+ Add(parts, "Album", Album);
+ if (Track is int track)
+ {
+ parts.Add("Track=" + track);
+ }
+
+ if (TrackCount is int count)
+ {
+ parts.Add("TrackCount=" + count);
+ }
+
+ if (Year is int year)
+ {
+ parts.Add("Year=" + year);
+ }
+
+ Add(parts, "Genre", Genre);
+ Add(parts, "Comment", Comment);
+ if (Taken is DateTimeOffset taken)
+ {
+ parts.Add("Taken=" + taken.ToString("O"));
+ }
+
+ return string.Join("\n", parts);
+ }
+
+ public static MediaTagFields FromPayload(string? payload)
+ {
+ var fields = new MediaTagFields();
+ if (string.IsNullOrWhiteSpace(payload))
+ {
+ return fields;
+ }
+
+ foreach (var line in payload.Split('\n'))
+ {
+ var split = line.IndexOf('=');
+ if (split <= 0)
+ {
+ continue;
+ }
+
+ var key = line[..split].Trim();
+ var value = Unescape(line[(split + 1)..]);
+ fields = key.ToLowerInvariant() switch
+ {
+ "artist" => fields with { Artist = value },
+ "title" => fields with { Title = value },
+ "album" => fields with { Album = value },
+ "track" when int.TryParse(value, out var track) => fields with { Track = track },
+ "trackcount" when int.TryParse(value, out var count) => fields with { TrackCount = count },
+ "year" when int.TryParse(value, out var year) => fields with { Year = year },
+ "genre" => fields with { Genre = value },
+ "comment" => fields with { Comment = value },
+ "taken" when DateTimeOffset.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind, out var taken) => fields with { Taken = taken },
+ _ => fields
+ };
+ }
+
+ return fields;
+ }
+
+ public bool HasWritableTags
+ => Artist is not null || Title is not null || Album is not null || Track is not null
+ || TrackCount is not null || Year is not null || Genre is not null || Comment is not null
+ || Taken is not null;
+
+ private static void Add(List parts, string key, string? value)
+ {
+ if (value is not null)
+ {
+ parts.Add(key + "=" + Escape(value));
+ }
+ }
+
+ private static string Escape(string value)
+ => value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\n", "\\n", StringComparison.Ordinal);
+
+ private static string Unescape(string value)
+ => value.Replace("\\n", "\n", StringComparison.Ordinal).Replace("\\\\", "\\", StringComparison.Ordinal);
+}
+
+public sealed record TagPreviewRow(
+ string SourcePath,
+ string FileName,
+ string Artist,
+ string Title,
+ string Album,
+ string Track,
+ string Year,
+ string Genre,
+ string? ProposedName,
+ string? Status,
+ bool TagsUnchanged,
+ bool NameUnchanged);
diff --git a/src/Explorer.Domain/OperationPlan.cs b/src/Explorer.Domain/OperationPlan.cs
index a6167f6..d7b4483 100644
--- a/src/Explorer.Domain/OperationPlan.cs
+++ b/src/Explorer.Domain/OperationPlan.cs
@@ -22,6 +22,7 @@ public sealed class OperationPlan
public IReadOnlyList SyncPreview { get; init; } = [];
public IReadOnlyList ProfilePreview { get; init; } = [];
public IReadOnlyList OrganizePreview { get; init; } = [];
+ public IReadOnlyList TagPreview { get; init; } = [];
public bool HasErrors => Issues.Any(i => i.Severity == PlanIssueSeverity.Error);
public bool CanEnqueue => !HasErrors && Operations.Count > 0;
diff --git a/src/Explorer.Domain/RenameRules.cs b/src/Explorer.Domain/RenameRules.cs
index 6e4dbe8..0dd7f2d 100644
--- a/src/Explorer.Domain/RenameRules.cs
+++ b/src/Explorer.Domain/RenameRules.cs
@@ -26,6 +26,7 @@ public sealed class RenameRuleSet
public RenameCaseMode CaseMode { get; init; }
public bool ChangeExtension { get; init; }
public string NewExtension { get; init; } = "";
+ public string NamePattern { get; init; } = "";
}
public sealed class RenameBatch
diff --git a/src/Explorer.Domain/WindowsFileNames.cs b/src/Explorer.Domain/WindowsFileNames.cs
index f060a39..aed182f 100644
--- a/src/Explorer.Domain/WindowsFileNames.cs
+++ b/src/Explorer.Domain/WindowsFileNames.cs
@@ -61,6 +61,27 @@ public static class WindowsFileNames
return true;
}
+ public static string SanitizeForFileName(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return "";
+ }
+
+ var chars = value.Trim().ToCharArray();
+ for (var i = 0; i < chars.Length; i++)
+ {
+ var c = chars[i];
+ if (c is '<' or '>' or ':' or '"' or '/' or '\\' or '|' or '?' or '*' || c < 32)
+ {
+ chars[i] = '-';
+ }
+ }
+
+ var text = new string(chars).Trim().TrimEnd('.');
+ return text.Length == 0 ? "" : text;
+ }
+
public static (string Stem, string Extension) Split(string name)
{
var dot = name.LastIndexOf('.');
diff --git a/src/Explorer.FileOperations/FileOperationService.cs b/src/Explorer.FileOperations/FileOperationService.cs
index e4482f1..6890334 100644
--- a/src/Explorer.FileOperations/FileOperationService.cs
+++ b/src/Explorer.FileOperations/FileOperationService.cs
@@ -31,6 +31,14 @@ public sealed class FileOperationService
public Task MoveAsync(IReadOnlyList sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> _queue.EnqueueMoveAsync(sources, destinationDirectory, cancellationToken);
+ public async Task MoveToAsync(IReadOnlyList operations, CancellationToken cancellationToken = default)
+ {
+ foreach (var op in operations.Where(o => o.Op == TransferOp.Move && o.DestinationPath is not null))
+ {
+ await _queue.EnqueueMoveToAsync(op.SourcePath, op.DestinationPath!, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
public Task DeleteAsync(IReadOnlyList paths, bool permanent = false, CancellationToken cancellationToken = default)
=> _queue.EnqueueDeleteAsync(paths, permanent, cancellationToken);
@@ -102,6 +110,14 @@ public sealed class FileOperationService
}
}
+ public async Task EnqueueWriteTagsAsync(IReadOnlyList operations, CancellationToken cancellationToken = default)
+ {
+ foreach (var op in operations.Where(o => o.Op == TransferOp.WriteTags && o.DestinationPath is not null))
+ {
+ await _queue.EnqueueWriteTagsAsync(op.SourcePath, op.DestinationPath!, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
public static string UniqueArchivePath(string directory, string stem, string extension)
{
extension = extension.Trim().TrimStart('.');
diff --git a/src/Explorer.FileOperations/NativeFileOperationExecutor.cs b/src/Explorer.FileOperations/NativeFileOperationExecutor.cs
index 1264146..4fbc803 100644
--- a/src/Explorer.FileOperations/NativeFileOperationExecutor.cs
+++ b/src/Explorer.FileOperations/NativeFileOperationExecutor.cs
@@ -11,25 +11,29 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
private readonly IArchiveExecutor? _archives;
private readonly IHydrationGuard? _hydration;
private readonly IMediaConversionProvider? _conversion;
+ private readonly IMediaTagService? _tags;
public NativeFileOperationExecutor(
IShellFileOperations shell,
IFileSystemEnumerator enumerator,
IArchiveExecutor? archives = null,
IHydrationGuard? hydration = null,
- IMediaConversionProvider? conversion = null)
+ IMediaConversionProvider? conversion = null,
+ IMediaTagService? tags = null)
{
_shell = shell;
_enumerator = enumerator;
_archives = archives;
_hydration = hydration;
_conversion = conversion;
+ _tags = tags;
}
public bool CanExecute(TransferOp op)
=> op is TransferOp.Copy or TransferOp.Move or TransferOp.Delete or TransferOp.Rename
or TransferOp.EmptyRecycleBin or TransferOp.Extract or TransferOp.Compress
- or TransferOp.AddToArchive or TransferOp.VerifyArchive or TransferOp.Convert;
+ or TransferOp.AddToArchive or TransferOp.VerifyArchive or TransferOp.Convert
+ or TransferOp.WriteTags;
public async Task ExecuteAsync(TransferJob job, Func pauseRequested, Action? reportProgress, CancellationToken cancellationToken)
{
@@ -59,6 +63,9 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
case TransferOp.Convert:
await ConvertAsync(job, reportProgress, cancellationToken).ConfigureAwait(false);
break;
+ case TransferOp.WriteTags:
+ await WriteTagsAsync(job, cancellationToken).ConfigureAwait(false);
+ break;
default:
job.Status = TransferStatus.Failed;
job.Error = $"Unsupported operation {job.Op}";
@@ -96,6 +103,11 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
job.FilesTotal = Math.Max(job.FilesTotal, 1);
job.CurrentPath = src;
+ if (!EnsureParent(dst, job))
+ {
+ return;
+ }
+
var resume = File.Exists(PathRules.ToExtended(dst));
if (!TransferFile(src, dst, move, resume, job, committed: 0, pauseRequested, reportProgress, stoppingToken, out var error))
{
@@ -109,6 +121,28 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
await Task.CompletedTask.ConfigureAwait(false);
}
+ private static bool EnsureParent(string destination, TransferJob job)
+ {
+ try
+ {
+ var parent = PathRules.Parent(destination);
+ if (string.IsNullOrWhiteSpace(parent)
+ || parent.Equals(destination, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ Directory.CreateDirectory(PathRules.ToExtended(parent));
+ return true;
+ }
+ catch (Exception ex)
+ {
+ job.Status = TransferStatus.Failed;
+ job.Error = ex.Message;
+ return false;
+ }
+ }
+
private async Task CopyDirectory(
string src,
string dst,
@@ -462,6 +496,36 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
}
}
+ private async Task WriteTagsAsync(TransferJob job, CancellationToken cancellationToken)
+ {
+ if (_tags is null)
+ {
+ job.Status = TransferStatus.Failed;
+ job.Error = "Tag editing is not available.";
+ return;
+ }
+
+ if (await WouldHydrateAsync(job.SourcePath, cancellationToken).ConfigureAwait(false))
+ {
+ FailHydration(job);
+ return;
+ }
+
+ job.FilesTotal = Math.Max(job.FilesTotal, 1);
+ job.CurrentPath = job.SourcePath;
+ var patch = MediaTagFields.FromPayload(job.DestinationPath);
+ if (!_tags.TryWrite(job.SourcePath, patch, out var error))
+ {
+ job.Status = TransferStatus.Failed;
+ job.Error = string.IsNullOrWhiteSpace(error) ? FileOperationErrors.FileInUse : error;
+ return;
+ }
+
+ job.FilesDone = 1;
+ job.CurrentPath = null;
+ await Task.CompletedTask.ConfigureAwait(false);
+ }
+
private static ConversionKind ConversionKindOf(TransferJob job)
{
if (job.AdditionalSources.Count == 1
diff --git a/src/Explorer.FileOperations/OperationAvailability.cs b/src/Explorer.FileOperations/OperationAvailability.cs
index aeed620..dd51c53 100644
--- a/src/Explorer.FileOperations/OperationAvailability.cs
+++ b/src/Explorer.FileOperations/OperationAvailability.cs
@@ -51,6 +51,9 @@ internal static class OperationAvailability
yield return PathRules.Parent(job.DestinationPath);
}
+ yield break;
+ case TransferOp.WriteTags:
+ yield return job.SourcePath;
yield break;
case TransferOp.VerifyArchive:
yield return job.SourcePath;
@@ -60,6 +63,14 @@ internal static class OperationAvailability
}
}
+ public static void TryPrepare(IVolumeService volumes, TransferJob job)
+ {
+ foreach (var path in PathsToCheck(job))
+ {
+ volumes.TryEnsureReachable(path);
+ }
+ }
+
public static bool IsVolumeReachable(IVolumeService volumes, string path)
{
if (string.IsNullOrWhiteSpace(path))
diff --git a/src/Explorer.FileOperations/RenameBatchService.cs b/src/Explorer.FileOperations/RenameBatchService.cs
index 25ab300..0d7d58c 100644
--- a/src/Explorer.FileOperations/RenameBatchService.cs
+++ b/src/Explorer.FileOperations/RenameBatchService.cs
@@ -26,8 +26,11 @@ public sealed class RenameBatchService
return File.Exists(ext) || Directory.Exists(ext);
}
- public OperationPlan Preview(IReadOnlyList subjects, RenameRuleSet rules)
- => _planner.Build(subjects, rules, PathExists);
+ public OperationPlan Preview(
+ IReadOnlyList subjects,
+ RenameRuleSet rules,
+ IReadOnlyDictionary? tagsByPath = null)
+ => _planner.Build(subjects, rules, PathExists, tagsByPath);
public async Task EnqueueAsync(OperationPlan plan, CancellationToken cancellationToken = default)
{
diff --git a/src/Explorer.FileOperations/TransferQueue.cs b/src/Explorer.FileOperations/TransferQueue.cs
index c12ec29..7c30f2b 100644
--- a/src/Explorer.FileOperations/TransferQueue.cs
+++ b/src/Explorer.FileOperations/TransferQueue.cs
@@ -80,17 +80,20 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
foreach (var src in sources)
{
var dest = Path.Combine(destinationDirectory, PathRules.GetFileName(src));
- await EnqueueAsync(new TransferJob
- {
- Op = TransferOp.Move,
- SourcePath = src,
- DestinationPath = dest,
- Status = TransferStatus.Queued,
- CreatedUtc = DateTimeOffset.UtcNow
- }, cancellationToken).ConfigureAwait(false);
+ await EnqueueMoveToAsync(src, dest, cancellationToken).ConfigureAwait(false);
}
}
+ public Task EnqueueMoveToAsync(string source, string destinationPath, CancellationToken cancellationToken = default)
+ => EnqueueAsync(new TransferJob
+ {
+ Op = TransferOp.Move,
+ SourcePath = source,
+ DestinationPath = destinationPath,
+ Status = TransferStatus.Queued,
+ CreatedUtc = DateTimeOffset.UtcNow
+ }, cancellationToken);
+
public async Task EnqueueDeleteAsync(IReadOnlyList paths, bool permanent = false, CancellationToken cancellationToken = default)
{
await EnqueueAsync(new TransferJob
@@ -178,6 +181,16 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
AdditionalSources = [kind.ToString()]
}, cancellationToken);
+ public Task EnqueueWriteTagsAsync(string path, string payload, CancellationToken cancellationToken = default)
+ => EnqueueAsync(new TransferJob
+ {
+ Op = TransferOp.WriteTags,
+ SourcePath = path,
+ DestinationPath = payload,
+ Status = TransferStatus.Queued,
+ CreatedUtc = DateTimeOffset.UtcNow
+ }, cancellationToken);
+
public void PauseAll()
{
_queuePaused = true;
@@ -256,18 +269,38 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
public void Retry(long jobId)
{
+ TransferJob? job;
lock (_gate)
{
- var job = Find(jobId);
- if (job is null || job.Status != TransferStatus.Failed)
+ job = Find(jobId);
+ if (job is null || job.Status is not (TransferStatus.Failed or TransferStatus.Waiting))
+ {
+ return;
+ }
+ }
+
+ OperationAvailability.TryPrepare(_volumes, job);
+
+ lock (_gate)
+ {
+ if (job.Status is not (TransferStatus.Failed or TransferStatus.Waiting))
{
return;
}
job.RetryCount++;
- job.Status = TransferStatus.Queued;
job.Error = null;
- job.WaitReason = null;
+ if (OperationAvailability.IsReady(_volumes, job))
+ {
+ job.Status = TransferStatus.Queued;
+ job.WaitReason = null;
+ }
+ else
+ {
+ job.Status = TransferStatus.Waiting;
+ job.WaitReason = FileOperationErrors.DestinationUnavailable;
+ }
+
QueuePersist(job);
}
@@ -392,12 +425,23 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
public void NotifyAvailability()
{
+ List waiting;
+ lock (_gate)
+ {
+ waiting = _jobs.Where(j => j.Status == TransferStatus.Waiting).ToList();
+ }
+
+ foreach (var job in waiting)
+ {
+ OperationAvailability.TryPrepare(_volumes, job);
+ }
+
var resumed = false;
lock (_gate)
{
- foreach (var job in _jobs.Where(j => j.Status == TransferStatus.Waiting))
+ foreach (var job in waiting)
{
- if (!OperationAvailability.IsReady(_volumes, job))
+ if (job.Status != TransferStatus.Waiting || !OperationAvailability.IsReady(_volumes, job))
{
continue;
}
@@ -418,6 +462,11 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
private async Task EnqueueAsync(TransferJob job, CancellationToken cancellationToken)
{
+ if (!OperationAvailability.IsReady(_volumes, job))
+ {
+ OperationAvailability.TryPrepare(_volumes, job);
+ }
+
if (!OperationAvailability.IsReady(_volumes, job))
{
job.Status = TransferStatus.Waiting;
@@ -462,6 +511,11 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
continue;
}
+ if (!OperationAvailability.IsReady(_volumes, job))
+ {
+ OperationAvailability.TryPrepare(_volumes, job);
+ }
+
if (!OperationAvailability.IsReady(_volumes, job))
{
job.Status = TransferStatus.Waiting;
diff --git a/src/Explorer.Hosting.Client/ExplorerHostClientServices.cs b/src/Explorer.Hosting.Client/ExplorerHostClientServices.cs
index e1b9c38..bddce9c 100644
--- a/src/Explorer.Hosting.Client/ExplorerHostClientServices.cs
+++ b/src/Explorer.Hosting.Client/ExplorerHostClientServices.cs
@@ -57,6 +57,7 @@ public static class ExplorerHostClientServices
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
diff --git a/src/Explorer.Hosting.Client/Ipc/WorkbenchPipeClient.cs b/src/Explorer.Hosting.Client/Ipc/WorkbenchPipeClient.cs
index 6463173..e94a6a8 100644
--- a/src/Explorer.Hosting.Client/Ipc/WorkbenchPipeClient.cs
+++ b/src/Explorer.Hosting.Client/Ipc/WorkbenchPipeClient.cs
@@ -504,6 +504,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
=> _client.CallAsync("Transfers.EnqueueCopy", cancellationToken, dest: destinationDirectory, paths: sources.ToArray());
public Task EnqueueMoveAsync(IReadOnlyList sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueMove", cancellationToken, dest: destinationDirectory, paths: sources.ToArray());
+ public Task EnqueueMoveToAsync(string source, string destinationPath, CancellationToken cancellationToken = default)
+ => _client.CallAsync("Transfers.EnqueueMoveTo", cancellationToken, s: source, dest: destinationPath);
public Task EnqueueDeleteAsync(IReadOnlyList paths, bool permanent = false, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueDelete", cancellationToken, paths: paths.ToArray(), flag: permanent);
public Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default)
@@ -520,6 +522,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
=> _client.CallAsync("Transfers.EnqueueVerifyArchive", cancellationToken, s: archivePath);
public Task EnqueueConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueConvert", cancellationToken, s: kind.ToString(), dest: destinationPath, paths: [sourcePath]);
+ public Task EnqueueWriteTagsAsync(string path, string payload, CancellationToken cancellationToken = default)
+ => _client.CallAsync("Transfers.EnqueueWriteTags", cancellationToken, s: path, dest: payload);
public void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
public void RaiseFinished(TransferJob job) => JobFinished?.Invoke(this, job);
}
diff --git a/src/Explorer.Hosting/ExplorerHostServices.cs b/src/Explorer.Hosting/ExplorerHostServices.cs
index e7459c4..1f856b5 100644
--- a/src/Explorer.Hosting/ExplorerHostServices.cs
+++ b/src/Explorer.Hosting/ExplorerHostServices.cs
@@ -51,6 +51,7 @@ public static class ExplorerHostServices
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
diff --git a/src/Explorer.Hosting/Ipc/WorkbenchPipeServer.cs b/src/Explorer.Hosting/Ipc/WorkbenchPipeServer.cs
index 15064ab..43c9571 100644
--- a/src/Explorer.Hosting/Ipc/WorkbenchPipeServer.cs
+++ b/src/Explorer.Hosting/Ipc/WorkbenchPipeServer.cs
@@ -383,6 +383,9 @@ public sealed class WorkbenchPipeServer : BackgroundService
case "Transfers.EnqueueMove":
await Workbench.Transfers.EnqueueMoveAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
return reply;
+ case "Transfers.EnqueueMoveTo":
+ await Workbench.Transfers.EnqueueMoveToAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
+ return reply;
case "Transfers.EnqueueDelete":
await Workbench.Transfers.EnqueueDeleteAsync(request.Paths ?? [], request.Flag == true).ConfigureAwait(false);
return reply;
@@ -411,6 +414,9 @@ public sealed class WorkbenchPipeServer : BackgroundService
var convertSource = request.Paths is { Length: > 0 } paths ? paths[0] : "";
await Workbench.Transfers.EnqueueConvertAsync(convertSource, request.Dest ?? "", convertKind).ConfigureAwait(false);
return reply;
+ case "Transfers.EnqueueWriteTags":
+ await Workbench.Transfers.EnqueueWriteTagsAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
+ return reply;
case "Sources.Refresh":
await Workbench.Sources.RefreshAsync().ConfigureAwait(false);
return reply;
diff --git a/src/Explorer.Presentation/ViewModels/BatchRenameViewModel.cs b/src/Explorer.Presentation/ViewModels/BatchRenameViewModel.cs
index 11c8ccf..ec260a7 100644
--- a/src/Explorer.Presentation/ViewModels/BatchRenameViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/BatchRenameViewModel.cs
@@ -12,6 +12,11 @@ public sealed partial class BatchRenameViewModel : ObservableObject
private readonly RenamePlanner _planner;
private readonly RenameBatchService _batches;
private readonly IReadOnlyList _subjects;
+ private readonly IMediaTagService? _tags;
+ private readonly IHydrationGuard? _hydration;
+ private readonly UiPreferencesStore? _preferences;
+ private readonly IGitStatusProvider? _git;
+ private readonly Dictionary _metadata = new(StringComparer.OrdinalIgnoreCase);
[ObservableProperty] private string _search = "";
[ObservableProperty] private string _replace = "";
@@ -27,22 +32,40 @@ public sealed partial class BatchRenameViewModel : ObservableObject
[ObservableProperty] private RenameCaseMode _caseMode = RenameCaseMode.Unchanged;
[ObservableProperty] private bool _changeExtension;
[ObservableProperty] private string _newExtension = "";
+ [ObservableProperty] private string _namePattern = "";
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _canQueue;
public BatchRenameViewModel(
IReadOnlyList subjects,
RenamePlanner planner,
- RenameBatchService batches)
+ RenameBatchService batches,
+ IMediaTagService? tags = null,
+ IHydrationGuard? hydration = null,
+ UiPreferencesStore? preferences = null,
+ IGitStatusProvider? git = null)
{
_subjects = subjects;
_planner = planner;
_batches = batches;
+ _tags = tags;
+ _hydration = hydration;
+ _preferences = preferences;
+ _git = git;
Rows = [];
+ PatternChoices = new ObservableCollection(
+ FilenamePatterns.Combine(preferences?.Load().NamePatterns));
+ foreach (var subject in _subjects)
+ {
+ _metadata[subject.FullPath] = Seed(subject);
+ }
+
Rebuild();
+ _ = LoadMediaAsync();
}
public ObservableCollection Rows { get; }
+ public ObservableCollection PatternChoices { get; }
public IReadOnlyList CaseOptions { get; } =
[
new("Leave case", RenameCaseMode.Unchanged),
@@ -68,13 +91,14 @@ public sealed partial class BatchRenameViewModel : ObservableObject
CounterPadding = Math.Max(0, CounterPadding),
CaseMode = CaseMode,
ChangeExtension = ChangeExtension,
- NewExtension = NewExtension
+ NewExtension = NewExtension,
+ NamePattern = NamePattern
};
[RelayCommand]
public async Task QueueAsync()
{
- var plan = _batches.Preview(_subjects, Rules);
+ var plan = _batches.Preview(_subjects, Rules, _metadata);
if (!plan.CanEnqueue)
{
Status = plan.Issues.FirstOrDefault()?.Message ?? "Nothing to rename.";
@@ -85,6 +109,27 @@ public sealed partial class BatchRenameViewModel : ObservableObject
CloseRequested?.Invoke(this, EventArgs.Empty);
}
+ [RelayCommand]
+ public void SavePattern()
+ {
+ if (_preferences is null || !FilenamePatterns.TryNormalize(NamePattern, out var pattern))
+ {
+ return;
+ }
+
+ var stored = _preferences.Load();
+ var next = FilenamePatterns.Add(stored.NamePatterns, pattern);
+ _preferences.Save(stored with { NamePatterns = next });
+ PatternChoices.Clear();
+ foreach (var item in FilenamePatterns.Combine(next))
+ {
+ PatternChoices.Add(item);
+ }
+
+ NamePattern = pattern;
+ Status = "Pattern saved.";
+ }
+
partial void OnSearchChanged(string value) => Rebuild();
partial void OnReplaceChanged(string value) => Rebuild();
partial void OnUseRegexChanged(bool value) => Rebuild();
@@ -99,10 +144,39 @@ public sealed partial class BatchRenameViewModel : ObservableObject
partial void OnCaseModeChanged(RenameCaseMode value) => Rebuild();
partial void OnChangeExtensionChanged(bool value) => Rebuild();
partial void OnNewExtensionChanged(string value) => Rebuild();
+ partial void OnNamePatternChanged(string value) => Rebuild();
+
+ private async Task LoadMediaAsync()
+ {
+ foreach (var subject in _subjects)
+ {
+ if (subject.IsDirectory)
+ {
+ continue;
+ }
+
+ var current = _metadata.GetValueOrDefault(subject.FullPath) ?? Seed(subject);
+ if (_hydration is not null && await _hydration.WouldHydrateOnReadAsync(subject.FullPath).ConfigureAwait(true))
+ {
+ _metadata[subject.FullPath] = current with { HydrationBlocked = true };
+ continue;
+ }
+
+ await Task.Run(() =>
+ {
+ if (_tags is not null && _tags.TryRead(subject.FullPath, out var media))
+ {
+ _metadata[subject.FullPath] = current.Merge(media);
+ }
+ }).ConfigureAwait(true);
+ }
+
+ Rebuild();
+ }
private void Rebuild()
{
- var plan = _planner.Build(_subjects, Rules, RenameBatchService.PathExists);
+ var plan = _planner.Build(_subjects, Rules, RenameBatchService.PathExists, _metadata);
Rows.Clear();
foreach (var row in plan.Preview)
{
@@ -118,6 +192,11 @@ public sealed partial class BatchRenameViewModel : ObservableObject
? $"{_subjects.Count} items · nothing to rename"
: $"{plan.Operations.Count} will be queued · {unchanged} unchanged";
}
+
+ private MediaTagFields Seed(RenameSubject subject)
+ => FilenamePattern.WithProject(
+ FilenamePattern.FromFile(subject.FullPath, subject.IsDirectory),
+ _git?.FindRepoRoot(subject.FullPath));
}
public sealed record RenameCaseOption(string Label, RenameCaseMode Mode);
diff --git a/src/Explorer.Presentation/ViewModels/MainViewModel.cs b/src/Explorer.Presentation/ViewModels/MainViewModel.cs
index 9c547a0..e78a21d 100644
--- a/src/Explorer.Presentation/ViewModels/MainViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/MainViewModel.cs
@@ -38,6 +38,7 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IHostConnection? _host;
private readonly IBackgroundMaintenance? _maintenance;
private readonly IShellContextMenu? _shellMenu;
+ private readonly IMediaTagService? _tags;
private bool _hostStopped;
private List _clipboard = [];
private bool _clipboardIsCut;
@@ -53,6 +54,8 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private bool _showEmptyRecycleBin;
[ObservableProperty] private bool _showImportWindowsLocation;
[ObservableProperty] private bool _showBatchRename;
+ [ObservableProperty] private bool _showMoveTo;
+ [ObservableProperty] private bool _showTags;
[ObservableProperty] private bool _showRunProfile;
[ObservableProperty] private bool _showOrganizeFolder;
[ObservableProperty] private bool _canUndoRenameBatch;
@@ -101,7 +104,8 @@ public sealed partial class MainViewModel : ObservableObject
IThumbnailService? thumbnails = null,
IHostConnection? hostConnection = null,
IBackgroundMaintenance? maintenance = null,
- IShellContextMenu? shellMenu = null)
+ IShellContextMenu? shellMenu = null,
+ IMediaTagService? tags = null)
{
_browse = browse;
_ops = ops;
@@ -127,6 +131,7 @@ public sealed partial class MainViewModel : ObservableObject
_host = hostConnection;
_maintenance = maintenance;
_shellMenu = shellMenu;
+ _tags = tags;
if (_host is not null)
{
_host.StatusChanged += (_, status) =>
@@ -597,7 +602,43 @@ public sealed partial class MainViewModel : ObservableObject
return null;
}
- return new BatchRenameViewModel(items, _renamePlanner, _renameBatches);
+ return new BatchRenameViewModel(items, _renamePlanner, _renameBatches, _tags, _hydration, _preferences, _git);
+ }
+
+ public TagRenameViewModel? CreateTagRenameViewModel()
+ {
+ var items = ActivePane.SelectedItems
+ .Where(i => !LocationRoots.IsVirtual(i.FullPath) && !i.IsDirectory)
+ .Select(i => new RenameSubject(i.FullPath, i.Item.Name, false))
+ .ToList();
+ if (items.Count == 0)
+ {
+ Footer = "Select files to edit tags.";
+ return null;
+ }
+
+ if (_tags is null)
+ {
+ Footer = "Tag editing is not available.";
+ return null;
+ }
+
+ return new TagRenameViewModel(items, _renamePlanner, _renameBatches, _ops, _tags, _hydration, _preferences, _git);
+ }
+
+ public MoveToViewModel? CreateMoveToViewModel()
+ {
+ var items = ActivePane.SelectedItems
+ .Where(i => !LocationRoots.IsVirtual(i.FullPath))
+ .Select(i => new RenameSubject(i.FullPath, i.Item.Name, i.IsDirectory))
+ .ToList();
+ if (items.Count == 0)
+ {
+ Footer = "Select files or folders to move.";
+ return null;
+ }
+
+ return new MoveToViewModel(items, _ops, _hydration, _enumerator, _preferences);
}
public async Task RefreshUndoRenameAsync()
@@ -875,6 +916,9 @@ public sealed partial class MainViewModel : ObservableObject
&& ActivePane.SelectedItems[0].Item.AvailableToImport;
ShowBatchRename = ActivePane.SelectedItems.Count > 0
&& ActivePane.SelectedItems.All(i => !LocationRoots.IsVirtual(i.FullPath));
+ ShowMoveTo = ShowBatchRename;
+ ShowTags = ActivePane.SelectedItems.Count > 0
+ && ActivePane.SelectedItems.Any(i => !i.IsDirectory && !LocationRoots.IsVirtual(i.FullPath));
ShowRunProfile = ShowBatchRename;
ShowOrganizeFolder = OrganizeSourcePath() is not null;
var real = ActivePane.SelectedItems.Where(IsRealFileSystemItem).ToList();
@@ -1529,7 +1573,7 @@ public sealed partial class MainViewModel : ObservableObject
}
Add(job.SourcePath);
- if (job.Op != TransferOp.Delete && job.Op != TransferOp.EmptyRecycleBin)
+ if (job.Op is not TransferOp.Delete and not TransferOp.EmptyRecycleBin and not TransferOp.WriteTags)
{
Add(job.DestinationPath);
}
diff --git a/src/Explorer.Presentation/ViewModels/MoveToViewModel.cs b/src/Explorer.Presentation/ViewModels/MoveToViewModel.cs
new file mode 100644
index 0000000..b4e777e
--- /dev/null
+++ b/src/Explorer.Presentation/ViewModels/MoveToViewModel.cs
@@ -0,0 +1,141 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Explorer.Application;
+using Explorer.Domain;
+using Explorer.Domain.Abstractions;
+using Explorer.FileOperations;
+
+namespace Explorer.Presentation.ViewModels;
+
+public sealed partial class MoveToViewModel : ObservableObject
+{
+ private readonly MoveToPlanner _planner = new();
+ private readonly FileOperationService _ops;
+ private readonly IHydrationGuard _hydration;
+ private readonly IFileSystemEnumerator _enumerator;
+ private readonly UiPreferencesStore _preferences;
+ private readonly IReadOnlyList _subjects;
+
+ [ObservableProperty] private string _pattern = "";
+ [ObservableProperty] private string? _selectedChoice;
+ [ObservableProperty] private string _status = "";
+ [ObservableProperty] private bool _canQueue;
+
+ public MoveToViewModel(
+ IReadOnlyList subjects,
+ FileOperationService ops,
+ IHydrationGuard hydration,
+ IFileSystemEnumerator enumerator,
+ UiPreferencesStore preferences)
+ {
+ _subjects = subjects;
+ _ops = ops;
+ _hydration = hydration;
+ _enumerator = enumerator;
+ _preferences = preferences;
+ Rows = [];
+ PatternChoices = new ObservableCollection(
+ DestinationPatterns.Combine(preferences.Load().MoveToPatterns));
+ Pattern = DestinationPatterns.Normalize(preferences.Load().MoveToPatterns).FirstOrDefault() ?? "";
+ Rebuild();
+ }
+
+ public ObservableCollection Rows { get; }
+ public ObservableCollection PatternChoices { get; }
+ public IReadOnlyList Tokens { get; } =
+ [
+ "%filename%",
+ "%filename_noext%",
+ "%ext%",
+ "%year%",
+ "%month%",
+ "%parent%",
+ "%source_drive%"
+ ];
+
+ public event EventHandler? CloseRequested;
+
+ partial void OnSelectedChoiceChanged(string? value)
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ Pattern = value;
+ }
+ }
+
+ [RelayCommand]
+ public void SavePattern()
+ {
+ Remember(Pattern, "Pattern saved.");
+ }
+
+ [RelayCommand]
+ public async Task QueueAsync()
+ {
+ var plan = Preview();
+ if (!plan.CanEnqueue)
+ {
+ Status = plan.Issues.FirstOrDefault()?.Message ?? "Nothing to move.";
+ return;
+ }
+
+ Remember(Pattern, null);
+ await _ops.MoveToAsync(plan.Operations).ConfigureAwait(true);
+ CloseRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ partial void OnPatternChanged(string value) => Rebuild();
+
+ private void Rebuild()
+ {
+ var plan = Preview();
+ Rows.Clear();
+ foreach (var row in plan.ProfilePreview)
+ {
+ Rows.Add(row);
+ }
+
+ CanQueue = plan.CanEnqueue;
+ var errors = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Error);
+ Status = errors > 0
+ ? $"{_subjects.Count} items · {errors} errors"
+ : plan.Operations.Count == 0
+ ? $"{_subjects.Count} items · nothing to move"
+ : $"{plan.Operations.Count} will be queued";
+ }
+
+ private OperationPlan Preview()
+ => _planner.Build(
+ _subjects,
+ Pattern,
+ RenameBatchService.PathExists,
+ path =>
+ {
+ var item = _enumerator.GetItem(path);
+ return item is not null && _hydration.WouldHydrateOnRead(item);
+ });
+
+ private void Remember(string pattern, string? status)
+ {
+ if (!DestinationPatterns.TryNormalize(pattern, out var text))
+ {
+ return;
+ }
+
+ var stored = _preferences.Load();
+ var next = DestinationPatterns.Add(stored.MoveToPatterns, text);
+ _preferences.Save(stored with { MoveToPatterns = next });
+ PatternChoices.Clear();
+ foreach (var item in DestinationPatterns.Combine(next))
+ {
+ PatternChoices.Add(item);
+ }
+
+ Pattern = text;
+ if (status is not null)
+ {
+ Status = status;
+ }
+ }
+}
diff --git a/src/Explorer.Presentation/ViewModels/TagRenameViewModel.cs b/src/Explorer.Presentation/ViewModels/TagRenameViewModel.cs
new file mode 100644
index 0000000..296701d
--- /dev/null
+++ b/src/Explorer.Presentation/ViewModels/TagRenameViewModel.cs
@@ -0,0 +1,324 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Explorer.Application;
+using Explorer.Domain;
+using Explorer.FileOperations;
+
+namespace Explorer.Presentation.ViewModels;
+
+public sealed partial class TagRenameViewModel : ObservableObject
+{
+ private readonly TagRenamePlanner _planner = new();
+ private readonly RenamePlanner _rename;
+ private readonly RenameBatchService _batches;
+ private readonly FileOperationService _ops;
+ private readonly IMediaTagService _tags;
+ private readonly IHydrationGuard _hydration;
+ private readonly UiPreferencesStore _preferences;
+ private readonly IGitStatusProvider _git;
+ private readonly IReadOnlyList _subjects;
+ private readonly Dictionary _original = new(StringComparer.OrdinalIgnoreCase);
+
+ [ObservableProperty] private string _pattern = FilenamePattern.DefaultAudioPattern;
+ [ObservableProperty] private string _status = "";
+ [ObservableProperty] private bool _canQueueTags;
+ [ObservableProperty] private bool _canQueueRename;
+
+ public TagRenameViewModel(
+ IReadOnlyList subjects,
+ RenamePlanner rename,
+ RenameBatchService batches,
+ FileOperationService ops,
+ IMediaTagService tags,
+ IHydrationGuard hydration,
+ UiPreferencesStore preferences,
+ IGitStatusProvider git)
+ {
+ _subjects = subjects;
+ _rename = rename;
+ _batches = batches;
+ _ops = ops;
+ _tags = tags;
+ _hydration = hydration;
+ _preferences = preferences;
+ _git = git;
+ Rows = [];
+ PatternChoices = new ObservableCollection(FilenamePatterns.Combine(preferences.Load().NamePatterns));
+ Pattern = PatternChoices.FirstOrDefault() ?? FilenamePattern.DefaultAudioPattern;
+ foreach (var subject in _subjects)
+ {
+ var seed = FilenamePattern.WithProject(
+ FilenamePattern.FromFile(subject.FullPath, subject.IsDirectory),
+ _git.FindRepoRoot(subject.FullPath));
+ _original[subject.FullPath] = seed;
+ Rows.Add(TagRowViewModel.From(subject, seed, Pattern));
+ }
+
+ foreach (var row in Rows)
+ {
+ row.Changed += (_, _) => RefreshStatus();
+ }
+
+ RefreshStatus();
+ _ = LoadAsync();
+ }
+
+ public ObservableCollection Rows { get; }
+ public ObservableCollection PatternChoices { get; }
+ public event EventHandler? CloseRequested;
+
+ [RelayCommand]
+ public void ApplyFilenameToTags()
+ {
+ foreach (var row in Rows)
+ {
+ var (stem, _) = WindowsFileNames.Split(row.FileName);
+ if (!FilenamePattern.TryParse(Pattern, stem, out var parsed, out var error))
+ {
+ row.Status = error ?? "No match";
+ continue;
+ }
+
+ row.ApplyParsed(parsed);
+ row.RefreshProposedName(Pattern);
+ row.Status = "From filename";
+ }
+
+ RefreshStatus();
+ }
+
+ [RelayCommand]
+ public void ApplyTagsToNames()
+ {
+ foreach (var row in Rows)
+ {
+ row.RefreshProposedName(Pattern);
+ row.Status = row.NameUnchanged ? "Unchanged" : "From tags";
+ }
+
+ RefreshStatus();
+ }
+
+ [RelayCommand]
+ public void SavePattern()
+ {
+ if (!FilenamePatterns.TryNormalize(Pattern, out var pattern))
+ {
+ return;
+ }
+
+ var stored = _preferences.Load();
+ var next = FilenamePatterns.Add(stored.NamePatterns, pattern);
+ _preferences.Save(stored with { NamePatterns = next });
+ PatternChoices.Clear();
+ foreach (var item in FilenamePatterns.Combine(next))
+ {
+ PatternChoices.Add(item);
+ }
+
+ Pattern = pattern;
+ Status = "Pattern saved.";
+ }
+
+ [RelayCommand]
+ public async Task QueueTagsAsync()
+ {
+ var operations = Rows
+ .Where(row => row.TagsDirty)
+ .Select(row => new PlannedOperation(TransferOp.WriteTags, row.Path, row.ToFields().Payload()))
+ .ToList();
+ if (operations.Count == 0)
+ {
+ Status = "No tag changes to write.";
+ return;
+ }
+
+ await _ops.EnqueueWriteTagsAsync(operations).ConfigureAwait(true);
+ CloseRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ [RelayCommand]
+ public async Task QueueRenameAsync()
+ {
+ ApplyTagsToNames();
+ var current = Rows.ToDictionary(r => r.Path, r => r.ToFields(), StringComparer.OrdinalIgnoreCase);
+ var plan = _planner.BuildRename(
+ _subjects,
+ Pattern,
+ current,
+ _rename,
+ RenameBatchService.PathExists,
+ path => Rows.FirstOrDefault(r => r.Path.Equals(path, StringComparison.OrdinalIgnoreCase))?.HydrationBlocked == true);
+ if (!plan.CanEnqueue)
+ {
+ Status = plan.Issues.FirstOrDefault()?.Message ?? "Nothing to rename.";
+ return;
+ }
+
+ await _batches.EnqueueAsync(plan).ConfigureAwait(true);
+ CloseRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ partial void OnPatternChanged(string value)
+ {
+ foreach (var row in Rows)
+ {
+ row.RefreshProposedName(value);
+ }
+
+ RefreshStatus();
+ }
+
+ private async Task LoadAsync()
+ {
+ foreach (var row in Rows)
+ {
+ if (row.IsDirectory)
+ {
+ row.Status = "Folder";
+ continue;
+ }
+
+ if (await _hydration.WouldHydrateOnReadAsync(row.Path).ConfigureAwait(true))
+ {
+ row.HydrationBlocked = true;
+ row.Status = "Online-only";
+ continue;
+ }
+
+ await Task.Run(() =>
+ {
+ if (_tags.TryRead(row.Path, out var media))
+ {
+ var merged = FilenamePattern.WithProject(
+ (_original.GetValueOrDefault(row.Path) ?? new MediaTagFields()).Merge(media),
+ _git.FindRepoRoot(row.Path));
+ _original[row.Path] = merged;
+ row.Load(merged, Pattern);
+ row.Status = null;
+ }
+ }).ConfigureAwait(true);
+ }
+
+ RefreshStatus();
+ }
+
+ private void RefreshStatus()
+ {
+ CanQueueTags = Rows.Any(r => r.TagsDirty && !r.HydrationBlocked);
+ CanQueueRename = Rows.Any(r => !r.NameUnchanged && !r.HydrationBlocked);
+ var blocked = Rows.Count(r => r.HydrationBlocked);
+ Status = blocked > 0
+ ? $"{Rows.Count} items · {blocked} online-only skipped"
+ : $"{Rows.Count} items";
+ }
+}
+
+public sealed partial class TagRowViewModel : ObservableObject
+{
+ private MediaTagFields _baseline = new();
+ private DateTimeOffset? _taken;
+
+ [ObservableProperty] private string _path = "";
+ [ObservableProperty] private string _fileName = "";
+ [ObservableProperty] private bool _isDirectory;
+ [ObservableProperty] private string _artist = "";
+ [ObservableProperty] private string _title = "";
+ [ObservableProperty] private string _album = "";
+ [ObservableProperty] private string _track = "";
+ [ObservableProperty] private string _year = "";
+ [ObservableProperty] private string _genre = "";
+ [ObservableProperty] private string _proposedName = "";
+ [ObservableProperty] private string? _status;
+ [ObservableProperty] private bool _hydrationBlocked;
+
+ public bool TagsDirty => !SameWritable(_baseline, ToFields());
+ public bool NameUnchanged => string.Equals(ProposedName, FileName, StringComparison.OrdinalIgnoreCase);
+
+ public static TagRowViewModel From(RenameSubject subject, MediaTagFields fields, string pattern)
+ {
+ var row = new TagRowViewModel
+ {
+ Path = subject.FullPath,
+ FileName = subject.Name,
+ IsDirectory = subject.IsDirectory
+ };
+ row.Load(fields, pattern);
+ return row;
+ }
+
+ public void Load(MediaTagFields fields, string pattern)
+ {
+ _baseline = fields;
+ _taken = fields.Taken;
+ Artist = fields.Artist ?? "";
+ Title = fields.Title ?? "";
+ Album = fields.Album ?? "";
+ Track = fields.Track?.ToString() ?? "";
+ Year = fields.Year?.ToString() ?? "";
+ Genre = fields.Genre ?? "";
+ RefreshProposedName(pattern);
+ }
+
+ protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ base.OnPropertyChanged(e);
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ public event EventHandler? Changed;
+
+ public void ApplyParsed(MediaTagFields parsed)
+ {
+ Artist = parsed.Artist ?? Artist;
+ Title = parsed.Title ?? Title;
+ Album = parsed.Album ?? Album;
+ if (parsed.Track is int track)
+ {
+ Track = track.ToString();
+ }
+
+ if (parsed.Year is int year)
+ {
+ Year = year.ToString();
+ }
+
+ Genre = parsed.Genre ?? Genre;
+ if (parsed.Taken is DateTimeOffset taken)
+ {
+ _taken = taken;
+ }
+ }
+
+ public void RefreshProposedName(string pattern)
+ {
+ var (stem, extension) = WindowsFileNames.Split(FileName);
+ var tags = ToFields() with { Stem = stem, Extension = extension };
+ var next = FilenamePattern.Expand(string.IsNullOrWhiteSpace(pattern) ? "{Name}" : pattern, tags);
+ ProposedName = WindowsFileNames.Join(next, IsDirectory ? "" : extension);
+ }
+
+ public MediaTagFields ToFields()
+ => _baseline with
+ {
+ Artist = Artist,
+ Title = Title,
+ Album = Album,
+ Track = int.TryParse(Track, out var track) ? track : null,
+ Year = int.TryParse(Year, out var year) ? year : null,
+ Genre = Genre,
+ Taken = _taken,
+ Stem = WindowsFileNames.Split(FileName).Stem,
+ Extension = WindowsFileNames.Split(FileName).Extension
+ };
+
+ private static bool SameWritable(MediaTagFields left, MediaTagFields right)
+ => string.Equals(left.Artist ?? "", right.Artist ?? "", StringComparison.Ordinal)
+ && string.Equals(left.Title ?? "", right.Title ?? "", StringComparison.Ordinal)
+ && string.Equals(left.Album ?? "", right.Album ?? "", StringComparison.Ordinal)
+ && left.Track == right.Track
+ && left.Year == right.Year
+ && left.Taken == right.Taken
+ && string.Equals(left.Genre ?? "", right.Genre ?? "", StringComparison.Ordinal);
+}
diff --git a/src/Explorer.Presentation/ViewModels/TransferQueueViewModel.cs b/src/Explorer.Presentation/ViewModels/TransferQueueViewModel.cs
index aa34952..5d4c412 100644
--- a/src/Explorer.Presentation/ViewModels/TransferQueueViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/TransferQueueViewModel.cs
@@ -55,7 +55,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
HasProgress = job.BytesTotal is > 0 && job.Status is TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling;
CanPause = job.Status is TransferStatus.Queued or TransferStatus.Running;
CanResume = job.Status == TransferStatus.Paused;
- CanRetry = job.Status == TransferStatus.Failed;
+ CanRetry = job.Status is TransferStatus.Failed or TransferStatus.Waiting;
CanRemove = job.Status is not TransferStatus.Cancelling;
CanMoveUp = canMoveUp && job.Status is not TransferStatus.Running and not TransferStatus.Cancelling;
CanMoveDown = canMoveDown && job.Status is not TransferStatus.Running and not TransferStatus.Cancelling;
@@ -89,6 +89,11 @@ public sealed partial class TransferJobViewModel : ObservableObject
return FileName(job.DestinationPath);
}
+ if (job.Op == TransferOp.WriteTags)
+ {
+ return FileName(job.SourcePath);
+ }
+
return FileName(job.SourcePath);
}
@@ -103,6 +108,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
TransferOp.AddToArchive => $"Add to {FileName(job.DestinationPath)}",
TransferOp.VerifyArchive => "Verify archive",
TransferOp.Convert => $"Convert to {FileName(job.DestinationPath)}",
+ TransferOp.WriteTags => "Write tags",
TransferOp.EmptyRecycleBin => "Empty Recycle Bin",
TransferOp.Delete => string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)
? "Delete permanently"
@@ -186,6 +192,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
TransferOp.AddToArchive => "Adding",
TransferOp.VerifyArchive => "Verifying",
TransferOp.Convert => "Converting",
+ TransferOp.WriteTags => "Writing tags",
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
_ => "Working"
};
@@ -196,7 +203,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
private readonly ITransferHost _queue;
private readonly UiPreferencesStore _preferences;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
- private readonly Dictionary _speed = [];
+ private readonly Dictionary _speed = [];
private bool _holdCollapsed;
@@ -426,14 +433,17 @@ public sealed partial class TransferQueueViewModel : ObservableObject
if (seconds >= 0.4 && job.BytesDone >= prev.Bytes)
{
var rate = (job.BytesDone - prev.Bytes) / seconds;
- _speed[job.Id] = (job.BytesDone, now);
- return rate > 0 ? $"{TransferJobViewModel.FormatBytes((long)rate)}/s" : null;
+ var text = rate > 0
+ ? $"{TransferJobViewModel.FormatBytes((long)rate)}/s"
+ : prev.Text;
+ _speed[job.Id] = (job.BytesDone, now, text);
+ return text;
}
- return null;
+ return prev.Text;
}
- _speed[job.Id] = (job.BytesDone, now);
+ _speed[job.Id] = (job.BytesDone, now, null);
return null;
}
@@ -483,6 +493,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
TransferOp.AddToArchive => "Adding",
TransferOp.VerifyArchive => "Verifying",
TransferOp.Convert => "Converting",
+ TransferOp.WriteTags => "Writing tags",
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
_ => op.ToString()
};
diff --git a/src/Explorer.Windows/Explorer.Windows.csproj b/src/Explorer.Windows/Explorer.Windows.csproj
index d0fb648..9bba0b8 100644
--- a/src/Explorer.Windows/Explorer.Windows.csproj
+++ b/src/Explorer.Windows/Explorer.Windows.csproj
@@ -6,6 +6,7 @@
+
diff --git a/src/Explorer.Windows/NativeMethods.cs b/src/Explorer.Windows/NativeMethods.cs
index 91b85c5..415129e 100644
--- a/src/Explorer.Windows/NativeMethods.cs
+++ b/src/Explorer.Windows/NativeMethods.cs
@@ -224,4 +224,35 @@ internal static partial class NativeMethods
[LibraryImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool GetSystemPowerStatus(out SystemPowerStatus lpSystemPowerStatus);
+
+ public const int ResourceTypeDisk = 1;
+ public const int ConnectTemporary = 4;
+ public const int ErrorSuccess = 0;
+ public const int ErrorAlreadyAssigned = 85;
+ public const int ErrorDeviceAlreadyRemembered = 1202;
+ public const int ErrorConnectionUnavailable = 1201;
+ public const int ErrorNotConnected = 2250;
+ public const int ErrorSessionCredentialConflict = 1219;
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ public struct NetResource
+ {
+ public int dwScope;
+ public int dwType;
+ public int dwDisplayType;
+ public int dwUsage;
+ public string? lpLocalName;
+ public string? lpRemoteName;
+ public string? lpComment;
+ public string? lpProvider;
+ }
+
+ [DllImport("mpr.dll", CharSet = CharSet.Unicode)]
+ public static extern int WNetAddConnection2(ref NetResource netResource, string? password, string? username, int flags);
+
+ [DllImport("mpr.dll", CharSet = CharSet.Unicode)]
+ public static extern int WNetGetConnection(string localName, StringBuilder remoteName, ref int length);
+
+ [DllImport("mpr.dll", CharSet = CharSet.Unicode)]
+ public static extern int WNetRestoreConnectionW(nint hwnd, string? localDrive, [MarshalAs(UnmanagedType.Bool)] bool force);
}
diff --git a/src/Explorer.Windows/TagLibMediaTagService.cs b/src/Explorer.Windows/TagLibMediaTagService.cs
new file mode 100644
index 0000000..d11994d
--- /dev/null
+++ b/src/Explorer.Windows/TagLibMediaTagService.cs
@@ -0,0 +1,172 @@
+using Explorer.Application;
+using Explorer.Domain;
+using TagFile = TagLib.File;
+
+namespace Explorer.Windows;
+
+public sealed class TagLibMediaTagService : IMediaTagService
+{
+ public bool TryRead(string path, out MediaTagFields fields)
+ {
+ fields = FilenamePattern.FromFile(path, Directory.Exists(PathRules.ToExtended(path)));
+ var disk = PathRules.ToExtended(path);
+ if (!File.Exists(disk))
+ {
+ return false;
+ }
+
+ try
+ {
+ using var file = TagFile.Create(PathRules.FromExtended(path));
+ var tag = file.Tag;
+ var props = file.Properties;
+ fields = fields with
+ {
+ Artist = First(tag.JoinedPerformers, tag.FirstPerformer) ?? Creator(file),
+ Title = EmptyToNull(tag.Title) ?? ImageTitle(file),
+ Album = EmptyToNull(tag.Album),
+ Track = tag.Track == 0 ? null : (int)tag.Track,
+ TrackCount = tag.TrackCount == 0 ? null : (int)tag.TrackCount,
+ Year = tag.Year == 0 ? TakenYear(file) : (int)tag.Year,
+ Genre = First(tag.JoinedGenres, tag.FirstGenre),
+ Comment = EmptyToNull(tag.Comment) ?? ImageComment(file),
+ Width = Dimension(props.PhotoWidth, props.VideoWidth),
+ Height = Dimension(props.PhotoHeight, props.VideoHeight),
+ Taken = ImageTaken(file)
+ };
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ public bool TryWrite(string path, MediaTagFields fields, out string? error)
+ {
+ error = null;
+ var disk = PathRules.FromExtended(path);
+ if (!File.Exists(PathRules.ToExtended(path)))
+ {
+ error = "File not found.";
+ return false;
+ }
+
+ try
+ {
+ using var file = TagFile.Create(disk);
+ if (fields.Artist is not null)
+ {
+ file.Tag.Performers = string.IsNullOrWhiteSpace(fields.Artist) ? [] : [fields.Artist];
+ }
+
+ if (fields.Title is not null)
+ {
+ file.Tag.Title = fields.Title;
+ }
+
+ if (fields.Album is not null)
+ {
+ file.Tag.Album = fields.Album;
+ }
+
+ if (fields.Genre is not null)
+ {
+ file.Tag.Genres = string.IsNullOrWhiteSpace(fields.Genre) ? [] : [fields.Genre];
+ }
+
+ if (fields.Comment is not null)
+ {
+ file.Tag.Comment = fields.Comment;
+ }
+
+ if (fields.Track is not null)
+ {
+ file.Tag.Track = (uint)Math.Max(0, fields.Track.Value);
+ }
+
+ if (fields.TrackCount is not null)
+ {
+ file.Tag.TrackCount = (uint)Math.Max(0, fields.TrackCount.Value);
+ }
+
+ if (fields.Year is not null)
+ {
+ file.Tag.Year = (uint)Math.Max(0, fields.Year.Value);
+ }
+
+ WriteImage(file, fields);
+ file.Save();
+ return true;
+ }
+ catch (Exception ex)
+ {
+ error = ex.Message;
+ return false;
+ }
+ }
+
+ private static string? First(string joined, string first)
+ => EmptyToNull(joined) ?? EmptyToNull(first);
+
+ private static string? EmptyToNull(string? value)
+ => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+
+ private static int? Dimension(int photo, int video)
+ => photo > 0 ? photo : video > 0 ? video : null;
+
+ private static DateTimeOffset? ImageTaken(TagFile file)
+ {
+ if (file is TagLib.Image.File image && image.ImageTag.DateTime is DateTime taken)
+ {
+ return taken.Kind == DateTimeKind.Unspecified
+ ? new DateTimeOffset(DateTime.SpecifyKind(taken, DateTimeKind.Local))
+ : new DateTimeOffset(taken);
+ }
+
+ return null;
+ }
+
+ private static int? TakenYear(TagFile file) => ImageTaken(file)?.Year;
+
+ private static string? ImageTitle(TagFile file)
+ => file is TagLib.Image.File image ? EmptyToNull(image.ImageTag.Title) : null;
+
+ private static string? ImageComment(TagFile file)
+ => file is TagLib.Image.File image ? EmptyToNull(image.ImageTag.Comment) : null;
+
+ private static string? Creator(TagFile file)
+ => file is TagLib.Image.File image ? EmptyToNull(image.ImageTag.Creator) : null;
+
+ private static void WriteImage(TagFile file, MediaTagFields fields)
+ {
+ if (file is not TagLib.Image.File image)
+ {
+ return;
+ }
+
+ if (fields.Title is not null)
+ {
+ image.ImageTag.Title = fields.Title;
+ }
+
+ if (fields.Comment is not null)
+ {
+ image.ImageTag.Comment = fields.Comment;
+ }
+
+ if (fields.Artist is not null)
+ {
+ image.ImageTag.Creator = fields.Artist;
+ }
+
+ if (fields.Taken is DateTimeOffset taken)
+ {
+ image.ImageTag.DateTime = taken.LocalDateTime;
+ }
+ else if (fields.Year is int year and > 0)
+ {
+ image.ImageTag.DateTime = new DateTime(year, 1, 1);
+ }
+ }
+}
diff --git a/src/Explorer.Windows/WindowsVolumeService.cs b/src/Explorer.Windows/WindowsVolumeService.cs
index f561734..5b964e8 100644
--- a/src/Explorer.Windows/WindowsVolumeService.cs
+++ b/src/Explorer.Windows/WindowsVolumeService.cs
@@ -228,6 +228,84 @@ public sealed class WindowsVolumeService : IVolumeService
}
}
+ public bool TryEnsureReachable(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path) || IsPathReachable(path))
+ {
+ return true;
+ }
+
+ try
+ {
+ BoundedWait.Try(
+ () =>
+ {
+ TryReconnect(path);
+ return true;
+ },
+ TimeSpan.FromSeconds(3));
+ _onlineCache = null;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Reconnect failed for {Path}", path);
+ }
+
+ return IsPathReachable(path);
+ }
+
+ private static void TryReconnect(string path)
+ {
+ if (PathRules.IsUnc(path))
+ {
+ ConnectShare(PathRules.CanonicalUncRoot(path), localName: null);
+ return;
+ }
+
+ var root = Path.GetPathRoot(PathRules.FromExtended(path));
+ if (string.IsNullOrWhiteSpace(root))
+ {
+ return;
+ }
+
+ var drive = root.TrimEnd('\\');
+ if (drive.Length != 2 || drive[1] != ':')
+ {
+ return;
+ }
+
+ NativeMethods.WNetRestoreConnectionW(0, drive, false);
+ var unc = MappedUnc(drive);
+ if (!string.IsNullOrWhiteSpace(unc))
+ {
+ ConnectShare(unc, drive);
+ }
+ }
+
+ private static void ConnectShare(string remote, string? localName)
+ {
+ var resource = new NativeMethods.NetResource
+ {
+ dwType = NativeMethods.ResourceTypeDisk,
+ lpRemoteName = remote,
+ lpLocalName = localName
+ };
+ var rc = NativeMethods.WNetAddConnection2(ref resource, null, null, NativeMethods.ConnectTemporary);
+ _ = rc is NativeMethods.ErrorSuccess
+ or NativeMethods.ErrorAlreadyAssigned
+ or NativeMethods.ErrorDeviceAlreadyRemembered
+ or NativeMethods.ErrorSessionCredentialConflict;
+ }
+
+ private static string? MappedUnc(string drive)
+ {
+ var buffer = new System.Text.StringBuilder(NativeMethods.MaxPath);
+ var length = buffer.Capacity;
+ return NativeMethods.WNetGetConnection(drive, buffer, ref length) == NativeMethods.ErrorSuccess
+ ? buffer.ToString()
+ : null;
+ }
+
private static bool IsRemote(string path)
{
if (PathRules.IsUnc(path))
diff --git a/tests/Explorer.Application.Tests/DestinationPatternTests.cs b/tests/Explorer.Application.Tests/DestinationPatternTests.cs
new file mode 100644
index 0000000..ab7d6f1
--- /dev/null
+++ b/tests/Explorer.Application.Tests/DestinationPatternTests.cs
@@ -0,0 +1,161 @@
+using Explorer.Application;
+using Explorer.Domain;
+
+namespace Explorer.Application.Tests;
+
+public class DestinationPatternTests
+{
+ private static RenameSubject File(string path, bool directory = false)
+ => new(path, PathRules.GetFileName(path), directory);
+
+ [Fact]
+ public void Plex_style_folder_gets_the_file_inside()
+ {
+ Assert.True(DestinationPattern.TryResolve(
+ @"\\10.0.0.31\media\movies\%filename_noext%",
+ File(@"D:\downloads\Inception.mkv"),
+ out var dest,
+ out var error));
+ Assert.Null(error);
+ Assert.Equal(@"\\10.0.0.31\media\movies\Inception\Inception.mkv", dest);
+ }
+
+ [Fact]
+ public void Explicit_filename_token_is_the_full_path()
+ {
+ Assert.True(DestinationPattern.TryResolve(
+ @"\\10.0.0.31\media\movies\%filename_noext%\%filename%",
+ File(@"D:\downloads\Inception.mkv"),
+ out var dest,
+ out _));
+ Assert.Equal(@"\\10.0.0.31\media\movies\Inception\Inception.mkv", dest);
+ }
+
+ [Fact]
+ public void Folder_moves_to_the_expanded_directory()
+ {
+ Assert.True(DestinationPattern.TryResolve(
+ @"\\10.0.0.31\media\movies\%filename_noext%",
+ File(@"D:\downloads\Inception", directory: true),
+ out var dest,
+ out _));
+ Assert.Equal(@"\\10.0.0.31\media\movies\Inception", dest);
+ }
+
+ [Fact]
+ public void Source_drive_year_and_parent_expand()
+ {
+ var fields = new MoveToFields(
+ "clip.mp4",
+ "clip",
+ "mp4",
+ "Vacation",
+ "E:",
+ new DateTimeOffset(new DateTime(2024, 8, 26, 0, 0, 0, DateTimeKind.Local)),
+ null);
+ Assert.True(DestinationPattern.TryExpand(
+ @"%source_drive%\sorted\%year%\%month%\%parent%",
+ fields,
+ out var expanded,
+ out _));
+ Assert.Equal(@"E:\sorted\2024\08\Vacation", expanded);
+ }
+
+ [Fact]
+ public void Brace_tokens_match_percent_tokens()
+ {
+ Assert.True(DestinationPattern.TryResolve(
+ @"{source_drive}\Media\{filename_noext}\{filename}",
+ File(@"C:\tmp\Photo.jpg"),
+ out var dest,
+ out _));
+ Assert.Equal(@"C:\Media\Photo\Photo.jpg", dest);
+ }
+
+ [Fact]
+ public void Relative_paths_are_rejected()
+ {
+ Assert.False(DestinationPattern.TryResolve(
+ @"%filename_noext%\%filename%",
+ File(@"C:\tmp\a.mkv"),
+ out _,
+ out var error));
+ Assert.Contains("full destination", error, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Unknown_placeholder_is_an_error()
+ {
+ Assert.False(DestinationPattern.TryExpand(
+ @"C:\out\%nope%",
+ new MoveToFields("a.txt", "a", "txt", "tmp", "C:", null, null),
+ out _,
+ out var error));
+ Assert.Contains("%nope%", error, StringComparison.OrdinalIgnoreCase);
+ }
+}
+
+public class DestinationPatternsTests
+{
+ [Fact]
+ public void Combine_puts_saved_ahead_of_built_in()
+ {
+ var combined = DestinationPatterns.Combine([@"\\10.0.0.31\media\movies\%filename_noext%"]);
+ Assert.Equal(@"\\10.0.0.31\media\movies\%filename_noext%", combined[0]);
+ Assert.Contains(DestinationPatterns.BuiltIn[0], combined);
+ }
+
+ [Fact]
+ public void Normalize_does_not_persist_built_in_examples()
+ {
+ Assert.Empty(DestinationPatterns.Normalize([@"\\host\share\movies\%filename_noext%"]));
+ }
+}
+
+public class MoveToPlannerTests
+{
+ [Fact]
+ public void Queues_a_move_into_the_named_folder()
+ {
+ var plan = new MoveToPlanner().Build(
+ [new RenameSubject(@"D:\downloads\Inception.mkv", "Inception.mkv", false)],
+ @"\\10.0.0.31\media\movies\%filename_noext%");
+ Assert.True(plan.CanEnqueue);
+ Assert.Equal(TransferOp.Move, plan.Operations[0].Op);
+ Assert.Equal(@"\\10.0.0.31\media\movies\Inception\Inception.mkv", plan.Operations[0].DestinationPath);
+ }
+
+ [Fact]
+ public void Collision_is_an_error()
+ {
+ var plan = new MoveToPlanner().Build(
+ [
+ new RenameSubject(@"D:\a\Inception.mkv", "Inception.mkv", false),
+ new RenameSubject(@"D:\b\Inception.mkv", "Inception.mkv", false)
+ ],
+ @"\\server\media\movies\%filename_noext%");
+ Assert.False(plan.CanEnqueue);
+ Assert.Contains(plan.Issues, i => i.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void Online_only_is_an_error()
+ {
+ var plan = new MoveToPlanner().Build(
+ [new RenameSubject(@"C:\cloud\clip.mp4", "clip.mp4", false)],
+ @"D:\out\%filename_noext%",
+ wouldHydrate: _ => true);
+ Assert.False(plan.CanEnqueue);
+ Assert.Contains(plan.Issues, i => i.Message.Contains("online-only", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void Example_host_is_rejected()
+ {
+ var plan = new MoveToPlanner().Build(
+ [new RenameSubject(@"D:\downloads\Inception.mkv", "Inception.mkv", false)],
+ @"\\host\share\movies\%filename_noext%");
+ Assert.False(plan.CanEnqueue);
+ Assert.Contains(plan.Issues, i => i.Message.Contains("Browse", StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/tests/Explorer.Application.Tests/FilenamePatternTests.cs b/tests/Explorer.Application.Tests/FilenamePatternTests.cs
new file mode 100644
index 0000000..15c5710
--- /dev/null
+++ b/tests/Explorer.Application.Tests/FilenamePatternTests.cs
@@ -0,0 +1,106 @@
+using Explorer.Application;
+using Explorer.Domain;
+
+namespace Explorer.Application.Tests;
+
+public class FilenamePatternTests
+{
+ [Fact]
+ public void Formats_artist_title_and_dates()
+ {
+ var tags = new MediaTagFields
+ {
+ Artist = "Pink Floyd",
+ Title = "Comfortably Numb",
+ Track = 6,
+ Created = new DateTimeOffset(new DateTime(2024, 8, 26, 12, 0, 0, DateTimeKind.Local)),
+ Stem = "clip",
+ Extension = "mp3",
+ Width = 1920
+ };
+ Assert.Equal(
+ "Pink Floyd - Comfortably Numb",
+ FilenamePattern.Expand("{Artist} - {Title}", tags));
+ Assert.Equal("06", FilenamePattern.Expand("{Track:00}", tags));
+ Assert.Equal("2024-08-26", FilenamePattern.Expand("{CreatedDate}", tags));
+ Assert.Equal("1920_clip", FilenamePattern.Expand("{Width}_{Name}", tags));
+ }
+
+ [Fact]
+ public void Parses_filename_into_tags()
+ {
+ Assert.True(FilenamePattern.TryParse(
+ "{Artist} - {Title}",
+ "AC/DC - Hells Bells",
+ out var tags,
+ out _));
+ Assert.Equal("AC/DC", tags.Artist);
+ Assert.Equal("Hells Bells", tags.Title);
+ }
+
+ [Fact]
+ public void Parse_fails_when_the_name_does_not_match()
+ {
+ Assert.False(FilenamePattern.TryParse("{Artist} - {Title}", "JustOneName", out _, out var error));
+ Assert.Contains("match", error, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Unknown_tokens_stay_in_the_name()
+ {
+ Assert.Equal("{Foo}_a", FilenamePattern.Expand("{Foo}_{Name}", new MediaTagFields { Stem = "a" }));
+ }
+
+ [Fact]
+ public void Sanitizes_illegal_filename_characters_from_tags()
+ {
+ var name = FilenamePattern.Expand("{Title}", new MediaTagFields { Title = @"a:b/c" });
+ Assert.Equal("a-b-c", name);
+ }
+
+ [Fact]
+ public void Taken_date_and_project_expand()
+ {
+ var tags = new MediaTagFields
+ {
+ Taken = new DateTimeOffset(new DateTime(2024, 8, 26, 15, 30, 0, DateTimeKind.Local)),
+ Created = new DateTimeOffset(new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Local)),
+ Stem = "DSC_001",
+ Project = "Explorer",
+ Parent = "photos"
+ };
+ Assert.Equal("20240826_DSC_001", FilenamePattern.Expand("{TakenDate:yyyyMMdd}_{Name}", tags));
+ Assert.Equal("2024-08-26", FilenamePattern.Expand("{CreatedDate}", tags));
+ Assert.Equal("Explorer_DSC_001", FilenamePattern.Expand("{Project}_{Name}", tags));
+ }
+
+ [Fact]
+ public void Parses_taken_date_from_filename()
+ {
+ Assert.True(FilenamePattern.TryParse(
+ "{TakenDate:yyyyMMdd}_{Name}",
+ "20240826_DSC_001",
+ out var tags,
+ out _));
+ Assert.Equal(2024, tags.Taken?.Year);
+ Assert.Equal(8, tags.Taken?.Month);
+ Assert.Equal(26, tags.Taken?.Day);
+ Assert.Equal(2024, tags.Year);
+ }
+
+ [Fact]
+ public void WithProject_uses_repo_folder_then_parent()
+ {
+ var fields = new MediaTagFields { Parent = "photos" };
+ Assert.Equal("Explorer", FilenamePattern.WithProject(fields, @"C:\src\Explorer").Project);
+ Assert.Equal("photos", FilenamePattern.WithProject(fields, null).Project);
+ }
+
+ [Fact]
+ public void TakenDate_is_media_content_CreatedDate_and_Project_are_not()
+ {
+ Assert.True(FilenamePattern.UsesMediaContent("{TakenDate}_{Name}"));
+ Assert.False(FilenamePattern.UsesMediaContent("{CreatedDate}_{Name}"));
+ Assert.False(FilenamePattern.UsesMediaContent("{Project}_{Name}"));
+ }
+}
diff --git a/tests/Explorer.Application.Tests/FilenamePatternsTests.cs b/tests/Explorer.Application.Tests/FilenamePatternsTests.cs
new file mode 100644
index 0000000..ee157b9
--- /dev/null
+++ b/tests/Explorer.Application.Tests/FilenamePatternsTests.cs
@@ -0,0 +1,23 @@
+using Explorer.Application;
+
+namespace Explorer.Application.Tests;
+
+public class FilenamePatternsTests
+{
+ [Fact]
+ public void Combine_lists_built_in_then_saved()
+ {
+ var combined = FilenamePatterns.Combine(["{Album} - {Title}", "{Artist} - {Title}"]);
+ Assert.Equal(FilenamePatterns.BuiltIn[0], combined[0]);
+ Assert.Contains("{Album} - {Title}", combined);
+ Assert.Equal(1, combined.Count(p => p.Equals("{Artist} - {Title}", StringComparison.OrdinalIgnoreCase)));
+ }
+
+ [Fact]
+ public void Add_puts_the_new_pattern_first_and_drops_built_ins_from_saved()
+ {
+ var saved = FilenamePatterns.Add(null, "{Album} - {Title}");
+ Assert.Equal(["{Album} - {Title}"], saved);
+ Assert.Empty(FilenamePatterns.Normalize(["{Artist} - {Title}", " "]));
+ }
+}
diff --git a/tests/Explorer.Application.Tests/RenamePlannerTests.cs b/tests/Explorer.Application.Tests/RenamePlannerTests.cs
index 9e8de02..2ffeb4e 100644
--- a/tests/Explorer.Application.Tests/RenamePlannerTests.cs
+++ b/tests/Explorer.Application.Tests/RenamePlannerTests.cs
@@ -52,9 +52,59 @@ public class RenamePlannerTests
[File(@"C:\a\clip.mp4")],
new RenameRuleSet { Prefix = "Clip_{Counter}_", UseCounter = true, CounterPadding = 3 });
Assert.Equal("Clip_001_clip.mp4", plan.Preview[0].NewName);
- Assert.Contains("{Width}", new RenamePlanner().Build(
+ }
+
+ [Fact]
+ public void Media_placeholders_use_supplied_tags()
+ {
+ var plan = new RenamePlanner().Build(
[File(@"C:\a\clip.mp4")],
- new RenameRuleSet { Prefix = "{Width}_" }).Preview[0].NewName);
+ new RenameRuleSet { Prefix = "{Width}_", NamePattern = "{Artist} - {Title}" },
+ tagsByPath: new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [@"C:\a\clip.mp4"] = new()
+ {
+ Width = 1920,
+ Artist = "Queen",
+ Title = "Bohemian Rhapsody",
+ Stem = "clip",
+ Extension = "mp4"
+ }
+ });
+ Assert.Equal("1920_Queen - Bohemian Rhapsody.mp4", plan.Preview[0].NewName);
+ }
+
+ [Fact]
+ public void Online_only_media_placeholder_is_an_error()
+ {
+ var plan = new RenamePlanner().Build(
+ [File(@"C:\cloud\clip.mp4")],
+ new RenameRuleSet { Prefix = "{Artist}_" },
+ tagsByPath: new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [@"C:\cloud\clip.mp4"] = new() { HydrationBlocked = true, Stem = "clip", Extension = "mp4" }
+ });
+ Assert.False(plan.CanEnqueue);
+ Assert.Contains(plan.Issues, i => i.Message.Contains("online-only", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void Project_and_taken_placeholders_expand()
+ {
+ var plan = new RenamePlanner().Build(
+ [File(@"C:\src\Explorer\photos\DSC_001.jpg")],
+ new RenameRuleSet { NamePattern = "{Project}_{TakenDate:yyyyMMdd}_{Name}" },
+ tagsByPath: new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [@"C:\src\Explorer\photos\DSC_001.jpg"] = new()
+ {
+ Project = "Explorer",
+ Taken = new DateTimeOffset(new DateTime(2024, 8, 26, 0, 0, 0, DateTimeKind.Local)),
+ Stem = "DSC_001",
+ Extension = "jpg"
+ }
+ });
+ Assert.Equal("Explorer_20240826_DSC_001.jpg", plan.Preview[0].NewName);
}
[Fact]
diff --git a/tests/Explorer.Application.Tests/TagRenamePlannerTests.cs b/tests/Explorer.Application.Tests/TagRenamePlannerTests.cs
new file mode 100644
index 0000000..665c6a8
--- /dev/null
+++ b/tests/Explorer.Application.Tests/TagRenamePlannerTests.cs
@@ -0,0 +1,36 @@
+using Explorer.Application;
+using Explorer.Domain;
+
+namespace Explorer.Application.Tests;
+
+public class TagRenamePlannerTests
+{
+ [Fact]
+ public void Filename_to_tags_queues_a_write()
+ {
+ var plan = new TagRenamePlanner().BuildWrite(
+ [new RenameSubject(@"C:\music\Pink Floyd - Time.mp3", "Pink Floyd - Time.mp3", false)],
+ "{Artist} - {Title}",
+ new Dictionary(StringComparer.OrdinalIgnoreCase));
+ Assert.True(plan.CanEnqueue);
+ Assert.Equal(TransferOp.WriteTags, plan.Operations[0].Op);
+ var tags = MediaTagFields.FromPayload(plan.Operations[0].DestinationPath);
+ Assert.Equal("Pink Floyd", tags.Artist);
+ Assert.Equal("Time", tags.Title);
+ }
+
+ [Fact]
+ public void Unchanged_tags_are_not_queued()
+ {
+ var existing = new MediaTagFields { Artist = "Pink Floyd", Title = "Time" };
+ var plan = new TagRenamePlanner().BuildWrite(
+ [new RenameSubject(@"C:\music\Pink Floyd - Time.mp3", "Pink Floyd - Time.mp3", false)],
+ "{Artist} - {Title}",
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [@"C:\music\Pink Floyd - Time.mp3"] = existing
+ });
+ Assert.False(plan.CanEnqueue);
+ Assert.True(plan.TagPreview[0].TagsUnchanged);
+ }
+}
diff --git a/tests/Explorer.Application.Tests/UiPreferencesStoreTests.cs b/tests/Explorer.Application.Tests/UiPreferencesStoreTests.cs
index aaedb44..7e2f766 100644
--- a/tests/Explorer.Application.Tests/UiPreferencesStoreTests.cs
+++ b/tests/Explorer.Application.Tests/UiPreferencesStoreTests.cs
@@ -87,6 +87,8 @@ public class UiPreferencesStoreTests
Assert.Null(prefs.FfmpegPath);
Assert.True(prefs.SessionTabs is null || prefs.SessionTabs.Count == 0);
Assert.True(prefs.FavoriteFolders is null || prefs.FavoriteFolders.Count == 0);
+ Assert.True(prefs.NamePatterns is null || prefs.NamePatterns.Count == 0);
+ Assert.True(prefs.MoveToPatterns is null || prefs.MoveToPatterns.Count == 0);
}
[Fact]
@@ -167,6 +169,31 @@ public class UiPreferencesStoreTests
Assert.Equal(@"C:\Users\Dominique\Documents", prefs.FavoriteFolders[1]);
}
+ [Fact]
+ public void Parse_reads_name_patterns_and_skips_built_ins()
+ {
+ var prefs = UiPreferencesStore.Parse(
+ [
+ "name-pattern={Album} - {Title}",
+ "name-pattern={Album} - {Title}",
+ "name-pattern={Artist} - {Title}",
+ "name-pattern="
+ ]);
+ Assert.NotNull(prefs.NamePatterns);
+ Assert.Equal(["{Album} - {Title}"], prefs.NamePatterns);
+ }
+
+ [Fact]
+ public void Parse_reads_move_to_patterns()
+ {
+ var prefs = UiPreferencesStore.Parse(
+ [
+ @"move-to=\\10.0.0.31\media\movies\%filename_noext%",
+ @"move-to=\\10.0.0.31\media\movies\%filename_noext%"
+ ]);
+ Assert.Equal([@"\\10.0.0.31\media\movies\%filename_noext%"], prefs.MoveToPatterns);
+ }
+
[Fact]
public void Session_tab_roundtrip_escapes_semicolons_in_paths()
{
@@ -199,7 +226,9 @@ public class UiPreferencesStoreTests
new SessionTabState(@"C:\Temp", @"D:\", true, 0.6, true)
],
SessionActiveTab: 0,
- PreferFavoritesInTree: true));
+ PreferFavoritesInTree: true,
+ NamePatterns: ["{Album} - {Title}"],
+ MoveToPatterns: [@"\\10.0.0.31\media\movies\%filename_noext%"]));
var loaded = store.Load();
Assert.Equal("Light", loaded.Theme);
Assert.True(loaded.GroupNetworkPlaces);
@@ -218,6 +247,8 @@ public class UiPreferencesStoreTests
Assert.Equal(720, loaded.WindowHeight);
Assert.Equal(300, loaded.TreeWidth);
Assert.Equal([@"D:\Photos", @"C:\Users\Dominique\Documents"], loaded.FavoriteFolders);
+ Assert.Equal(["{Album} - {Title}"], loaded.NamePatterns);
+ Assert.Equal([@"\\10.0.0.31\media\movies\%filename_noext%"], loaded.MoveToPatterns);
Assert.NotNull(loaded.SessionTabs);
var tab = Assert.Single(loaded.SessionTabs);
Assert.Equal(@"C:\Temp", tab.LeftPath);
diff --git a/tests/Explorer.Domain.Tests/DomainTests.cs b/tests/Explorer.Domain.Tests/DomainTests.cs
index 575926c..ce2e0aa 100644
--- a/tests/Explorer.Domain.Tests/DomainTests.cs
+++ b/tests/Explorer.Domain.Tests/DomainTests.cs
@@ -549,3 +549,17 @@ public class GitStatusBadgeTests
Assert.Equal("merging", status.OperationLabel);
}
}
+
+public class MediaTagFieldsTests
+{
+ [Fact]
+ public void Payload_roundtrips_taken_date()
+ {
+ var taken = new DateTimeOffset(2024, 8, 26, 15, 30, 0, TimeSpan.FromHours(2));
+ var payload = new MediaTagFields { Title = "Sunset", Taken = taken }.Payload();
+ var parsed = MediaTagFields.FromPayload(payload);
+ Assert.Equal("Sunset", parsed.Title);
+ Assert.Equal(taken, parsed.Taken);
+ Assert.True(parsed.HasWritableTags);
+ }
+}
diff --git a/tests/Explorer.FileOperations.Tests/TransferQueueTests.cs b/tests/Explorer.FileOperations.Tests/TransferQueueTests.cs
index 768a0dd..a5a34be 100644
--- a/tests/Explorer.FileOperations.Tests/TransferQueueTests.cs
+++ b/tests/Explorer.FileOperations.Tests/TransferQueueTests.cs
@@ -214,6 +214,22 @@ public class TransferQueueTests
await ctx.Queue.StopAsync(CancellationToken.None);
}
+ [Fact]
+ public async Task Retry_wakes_a_waiting_job_when_the_destination_is_back()
+ {
+ await using var ctx = await Harness.CreateAsync();
+ ctx.Volumes.Reachable = false;
+ await ctx.Queue.StartAsync(CancellationToken.None);
+ await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt")], ctx.Dest);
+ await WaitUntil(() => ctx.Queue.Snapshot().Any(j => j.Status == TransferStatus.Waiting));
+
+ ctx.Volumes.Reachable = true;
+ ctx.Queue.Retry(ctx.Queue.Snapshot()[0].Id);
+ await WaitUntil(() => ctx.Queue.Snapshot().Single().Status == TransferStatus.Done);
+ Assert.Equal(1, ctx.Queue.Snapshot()[0].RetryCount);
+ await ctx.Queue.StopAsync(CancellationToken.None);
+ }
+
[Fact]
public async Task Retry_reruns_a_failed_job()
{