Add patterned rename and Move to, and keep settings, selection, and queue speed from resetting.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
26
Backlog.md
26
Backlog.md
@@ -294,18 +294,19 @@ Goal: Replace FileRenamer workflows.
|
|||||||
- [x] Counter padding
|
- [x] Counter padding
|
||||||
- [x] Case conversion
|
- [x] Case conversion
|
||||||
- [x] Extension handling
|
- [x] Extension handling
|
||||||
- [ ] Metadata placeholders
|
- [x] Metadata placeholders
|
||||||
|
|
||||||
Potential placeholders:
|
Potential placeholders:
|
||||||
|
|
||||||
- `{CreatedDate}`
|
- `{CreatedDate}`
|
||||||
|
- `{TakenDate}`
|
||||||
- `{ModifiedDate}`
|
- `{ModifiedDate}`
|
||||||
- `{Counter}`
|
- `{Counter}`
|
||||||
- `{Width}`
|
- `{Width}`
|
||||||
- `{Height}`
|
- `{Height}`
|
||||||
- `{Artist}`
|
- `{Artist}`
|
||||||
- `{Title}`
|
- `{Title}`
|
||||||
- `{Project}`
|
- `{Project}` (Git repo folder, else parent)
|
||||||
- `{Extension}`
|
- `{Extension}`
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
@@ -318,6 +319,25 @@ Potential placeholders:
|
|||||||
- [x] Add Rename operations to File Operations Queue
|
- [x] Add Rename operations to File Operations Queue
|
||||||
- [x] Store `OldPath -> NewPath`
|
- [x] Store `OldPath -> NewPath`
|
||||||
- [x] Support Undo for completed rename batches
|
- [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
|
- [ ] MIME/content signature
|
||||||
- [x] Folder structure
|
- [x] Folder structure
|
||||||
- [x] Git metadata
|
- [x] Git metadata
|
||||||
- [ ] Media metadata
|
- [x] Media metadata
|
||||||
- [x] Known application structures (`node_modules`, `bin`, `obj`, `.vs`)
|
- [x] Known application structures (`node_modules`, `bin`, `obj`, `.vs`)
|
||||||
- [x] File age (old installers flagged in preview)
|
- [x] File age (old installers flagged in preview)
|
||||||
- [ ] File relationships
|
- [ ] File relationships
|
||||||
|
|||||||
@@ -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\index.db` | Index, queue, profiles |
|
||||||
| `%LocalAppData%\ExplorerWorkbench\logs\` | Rolling logs |
|
| `%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.
|
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
|
- Scheduled profiles and folder-watcher triggers
|
||||||
- MIME/EXIF classification
|
- MIME/EXIF classification
|
||||||
- Duplicate “backup copy” auto-tagging
|
- Duplicate “backup copy” auto-tagging
|
||||||
- Rename placeholders from EXIF or dates
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,14 @@
|
|||||||
<TextBlock Text="Extension" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
<TextBlock Text="Extension" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||||
<CheckBox Content="Change extension" IsChecked="{Binding ChangeExtension}" Margin="0,0,0,8"/>
|
<CheckBox Content="Change extension" IsChecked="{Binding ChangeExtension}" Margin="0,0,0,8"/>
|
||||||
<TextBox Text="{Binding NewExtension, UpdateSourceTrigger=PropertyChanged}"
|
<TextBox Text="{Binding NewExtension, UpdateSourceTrigger=PropertyChanged}"
|
||||||
IsEnabled="{Binding ChangeExtension}"/>
|
IsEnabled="{Binding ChangeExtension}" Margin="0,0,0,16"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Name pattern" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||||
|
<ComboBox IsEditable="True" ItemsSource="{Binding PatternChoices}"
|
||||||
|
Text="{Binding NamePattern, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8"/>
|
||||||
|
<Button Content="Save pattern" Height="28" Command="{Binding SavePatternCommand}" Margin="0,0,0,8"/>
|
||||||
|
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12"
|
||||||
|
Text="Optional. Replaces the current name. Placeholders: {Artist} {Title} {Album} {Track} {Year} {Genre} {CreatedDate} {TakenDate} {ModifiedDate} {Width} {Height} {Name} {Extension} {Parent} {Project} {Counter}. Dates accept {TakenDate:yyyyMMdd}."/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
<ListView Grid.Column="2" ItemsSource="{Binding Rows}"
|
<ListView Grid.Column="2" ItemsSource="{Binding Rows}"
|
||||||
|
|||||||
@@ -34,6 +34,10 @@
|
|||||||
<MenuItem Header="Rename" Click="OnCtxRename"/>
|
<MenuItem Header="Rename" Click="OnCtxRename"/>
|
||||||
<MenuItem Header="Batch rename…" Click="OnBatchRename"
|
<MenuItem Header="Batch rename…" Click="OnBatchRename"
|
||||||
Visibility="{Binding ShowBatchRename, Converter={StaticResource BoolVis}}"/>
|
Visibility="{Binding ShowBatchRename, Converter={StaticResource BoolVis}}"/>
|
||||||
|
<MenuItem Header="Tags…" Click="OnTags"
|
||||||
|
Visibility="{Binding ShowTags, Converter={StaticResource BoolVis}}"/>
|
||||||
|
<MenuItem Header="Move to…" Click="OnMoveTo"
|
||||||
|
Visibility="{Binding ShowMoveTo, Converter={StaticResource BoolVis}}"/>
|
||||||
<MenuItem Header="Run profile" Tag="RunProfileMenu"
|
<MenuItem Header="Run profile" Tag="RunProfileMenu"
|
||||||
IsEnabled="{Binding ShowRunProfile}">
|
IsEnabled="{Binding ShowRunProfile}">
|
||||||
<MenuItem Header="No profiles yet" IsEnabled="False"/>
|
<MenuItem Header="No profiles yet" IsEnabled="False"/>
|
||||||
@@ -149,6 +153,10 @@
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem Header="_File Operations">
|
<MenuItem Header="_File Operations">
|
||||||
<MenuItem Header="_Batch rename…" Click="OnBatchRename"/>
|
<MenuItem Header="_Batch rename…" Click="OnBatchRename"/>
|
||||||
|
<MenuItem Header="_Tags…" Click="OnTags"
|
||||||
|
IsEnabled="{Binding ShowTags}"/>
|
||||||
|
<MenuItem Header="_Move to…" Click="OnMoveTo"
|
||||||
|
IsEnabled="{Binding ShowMoveTo}"/>
|
||||||
<MenuItem Header="_Undo last rename batch" Command="{Binding UndoRenameBatchCommand}"
|
<MenuItem Header="_Undo last rename batch" Command="{Binding UndoRenameBatchCommand}"
|
||||||
IsEnabled="{Binding CanUndoRenameBatch}"/>
|
IsEnabled="{Binding CanUndoRenameBatch}"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public partial class MainWindow : Window
|
|||||||
private bool _dragPending;
|
private bool _dragPending;
|
||||||
private MouseButton _dragButton;
|
private MouseButton _dragButton;
|
||||||
private FolderItemViewModel? _dragItem;
|
private FolderItemViewModel? _dragItem;
|
||||||
|
private ListView? _dragList;
|
||||||
|
private FolderItemViewModel[] _dragSelection = [];
|
||||||
|
private bool _dragFromMultiSelect;
|
||||||
|
private bool _syncingSelection;
|
||||||
private readonly ListMarquee _marquee = new();
|
private readonly ListMarquee _marquee = new();
|
||||||
private bool _suppressItemContextMenu;
|
private bool _suppressItemContextMenu;
|
||||||
private bool _incomingRightDrag;
|
private bool _incomingRightDrag;
|
||||||
@@ -577,12 +581,17 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ActivatePaneFromList(list);
|
ActivatePaneFromList(list);
|
||||||
|
if (_dragFromMultiSelect && list == _dragList && _dragSelection.Length > 1)
|
||||||
|
{
|
||||||
|
RestoreListSelection(list, _dragSelection);
|
||||||
|
}
|
||||||
|
|
||||||
if (_clickRenameItem is not null && !list.SelectedItems.Contains(_clickRenameItem))
|
if (_clickRenameItem is not null && !list.SelectedItems.Contains(_clickRenameItem))
|
||||||
{
|
{
|
||||||
CancelClickRename();
|
CancelClickRename();
|
||||||
@@ -597,6 +606,26 @@ public partial class MainWindow : Window
|
|||||||
Vm.RefreshCloudActions();
|
Vm.RefreshCloudActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RestoreListSelection(ListView list, IReadOnlyList<FolderItemViewModel> 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)
|
private void OnPaneFocus(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is ListView list)
|
if (sender is ListView list)
|
||||||
@@ -620,11 +649,35 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void OnListMouseDown(object sender, MouseButtonEventArgs e)
|
private void OnListMouseDown(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (IsInsideInlineRenameBox(e.OriginalSource as DependencyObject))
|
||||||
|
{
|
||||||
|
_dragPending = false;
|
||||||
|
_marquee.Disarm();
|
||||||
|
CancelClickRename();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_suppressItemContextMenu = false;
|
_suppressItemContextMenu = false;
|
||||||
_dragStart = e.GetPosition(null);
|
_dragStart = e.GetPosition(null);
|
||||||
_dragPending = true;
|
_dragPending = true;
|
||||||
_dragButton = e.ChangedButton;
|
_dragButton = e.ChangedButton;
|
||||||
|
_dragList = sender as ListView;
|
||||||
_dragItem = HitTestFolderItem(sender as DependencyObject, e.GetPosition((IInputElement)sender));
|
_dragItem = HitTestFolderItem(sender as DependencyObject, e.GetPosition((IInputElement)sender));
|
||||||
|
_dragSelection = _dragList is not null
|
||||||
|
? _dragList.SelectedItems.OfType<FolderItemViewModel>().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
|
if (sender is ListView list
|
||||||
&& _dragItem is null
|
&& _dragItem is null
|
||||||
&& ListMarquee.IsBackground(e.OriginalSource as DependencyObject))
|
&& ListMarquee.IsBackground(e.OriginalSource as DependencyObject))
|
||||||
@@ -800,6 +853,13 @@ public partial class MainWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsInsideInlineRenameBox(e.OriginalSource as DependencyObject)
|
||||||
|
|| _dragItem is { IsRenaming: true })
|
||||||
|
{
|
||||||
|
_dragPending = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var pos = e.GetPosition(null);
|
var pos = e.GetPosition(null);
|
||||||
if (Math.Abs(pos.X - _dragStart.X) < SystemParameters.MinimumHorizontalDragDistance
|
if (Math.Abs(pos.X - _dragStart.X) < SystemParameters.MinimumHorizontalDragDistance
|
||||||
&& Math.Abs(pos.Y - _dragStart.Y) < SystemParameters.MinimumVerticalDragDistance)
|
&& Math.Abs(pos.Y - _dragStart.Y) < SystemParameters.MinimumVerticalDragDistance)
|
||||||
@@ -809,18 +869,13 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
_dragPending = false;
|
_dragPending = false;
|
||||||
CancelClickRename();
|
CancelClickRename();
|
||||||
var selected = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
|
if (_dragList is { IsMouseCaptured: true })
|
||||||
IReadOnlyList<string> paths;
|
|
||||||
if (_dragItem is not null && (selected.Count == 0
|
|
||||||
|| selected.TrueForAll(p => !p.Equals(_dragItem.FullPath, StringComparison.OrdinalIgnoreCase))))
|
|
||||||
{
|
{
|
||||||
paths = [_dragItem.FullPath];
|
_dragList.ReleaseMouseCapture();
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
paths = selected;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var paths = DragPaths();
|
||||||
|
_dragFromMultiSelect = false;
|
||||||
if (paths.Count == 0)
|
if (paths.Count == 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -878,11 +933,42 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
base.OnPreviewMouseUp(e);
|
base.OnPreviewMouseUp(e);
|
||||||
if (e.ChangedButton == _dragButton)
|
if (e.ChangedButton != _dragButton)
|
||||||
{
|
{
|
||||||
_dragPending = false;
|
return;
|
||||||
_marquee.Disarm();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<string> 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)
|
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)
|
private async void OnExtractHere(object sender, RoutedEventArgs e)
|
||||||
=> await Vm.ExtractSelectedAsync(null).ConfigureAwait(true);
|
=> await Vm.ExtractSelectedAsync(null).ConfigureAwait(true);
|
||||||
|
|
||||||
|
|||||||
66
src/Explorer.App/MoveToWindow.xaml
Normal file
66
src/Explorer.App/MoveToWindow.xaml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<Window x:Class="Explorer.App.MoveToWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="Move to"
|
||||||
|
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
|
||||||
|
Height="560" Width="820"
|
||||||
|
MinHeight="420" MinWidth="640"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
|
||||||
|
<DockPanel Margin="16">
|
||||||
|
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
|
||||||
|
<Button DockPanel.Dock="Right" Content="Cancel" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
|
||||||
|
<Button DockPanel.Dock="Right" Content="Queue" MinWidth="88" Height="32" IsDefault="True"
|
||||||
|
Command="{Binding QueueCommand}" IsEnabled="{Binding CanQueue}"/>
|
||||||
|
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"/>
|
||||||
|
</DockPanel>
|
||||||
|
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,12">
|
||||||
|
<TextBlock Text="Destination pattern" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||||
|
<Grid Margin="0,0,0,8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox x:Name="PatternBox" MinHeight="32" VerticalContentAlignment="Center" Padding="8,4"
|
||||||
|
Text="{Binding Pattern, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
<Button Grid.Column="1" Content="Browse…" MinWidth="88" Height="32" Click="OnBrowse" Margin="8,0,0,0"/>
|
||||||
|
<Button Grid.Column="2" Content="Save" MinWidth="72" Height="32" Command="{Binding SavePatternCommand}" Margin="8,0,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
<Grid Margin="0,0,0,8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="Recent" VerticalAlignment="Center" Margin="0,0,8,0" Foreground="{DynamicResource FgMuted}"/>
|
||||||
|
<ComboBox Grid.Column="1" MinHeight="28" ItemsSource="{Binding PatternChoices}"
|
||||||
|
SelectedItem="{Binding SelectedChoice}" SelectedIndex="-1"/>
|
||||||
|
</Grid>
|
||||||
|
<ItemsControl ItemsSource="{Binding Tokens}" Margin="0,0,0,8">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Button Content="{Binding}" MinWidth="0" Height="26" Padding="8,0" Margin="0,0,8,8"
|
||||||
|
Click="OnInsertToken" Tag="{Binding}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12"
|
||||||
|
Text="Type a full UNC or drive path, then click a token to insert it at the caret. Example: \\10.0.0.31\media\movies\%filename_noext% creates a folder named after the file and places the file inside it. %year% / %month% use last-write time. Online-only cloud files are skipped."/>
|
||||||
|
</StackPanel>
|
||||||
|
<ListView ItemsSource="{Binding Rows}"
|
||||||
|
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
|
||||||
|
<ListView.View>
|
||||||
|
<GridView>
|
||||||
|
<GridViewColumn Header="Action" Width="70" DisplayMemberBinding="{Binding Action}"/>
|
||||||
|
<GridViewColumn Header="Source" Width="200" DisplayMemberBinding="{Binding Detail}"/>
|
||||||
|
<GridViewColumn Header="Destination" Width="460" DisplayMemberBinding="{Binding Path}"/>
|
||||||
|
</GridView>
|
||||||
|
</ListView.View>
|
||||||
|
</ListView>
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
69
src/Explorer.App/MoveToWindow.xaml.cs
Normal file
69
src/Explorer.App/MoveToWindow.xaml.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<UserControl x:Class="Explorer.App.Settings.Pages.AdvancedSettingsPage"
|
<UserControl x:Class="Explorer.App.Settings.Pages.AdvancedSettingsPage"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
|
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="Advanced" Style="{StaticResource SettingsHeading}"
|
<TextBlock Text="Advanced" Style="{StaticResource SettingsHeading}"
|
||||||
AutomationProperties.HeadingLevel="Level1"/>
|
AutomationProperties.HeadingLevel="Level1"/>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public partial class AdvancedSettingsPage : UserControl
|
|||||||
|
|
||||||
private void OnBrowseGit(object sender, RoutedEventArgs e)
|
private void OnBrowseGit(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is SettingsDraft draft
|
if (SettingsPageContext.DraftOf(this) is { } draft
|
||||||
&& SettingsPathBrowse.TryPick(
|
&& SettingsPathBrowse.TryPick(
|
||||||
Window.GetWindow(this),
|
Window.GetWindow(this),
|
||||||
"Git executable",
|
"Git executable",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<UserControl x:Class="Explorer.App.Settings.Pages.AppearanceSettingsPage"
|
<UserControl x:Class="Explorer.App.Settings.Pages.AppearanceSettingsPage"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
|
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="Appearance" Style="{StaticResource SettingsHeading}"
|
<TextBlock Text="Appearance" Style="{StaticResource SettingsHeading}"
|
||||||
AutomationProperties.HeadingLevel="Level1"/>
|
AutomationProperties.HeadingLevel="Level1"/>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<UserControl x:Class="Explorer.App.Settings.Pages.FileOperationsSettingsPage"
|
<UserControl x:Class="Explorer.App.Settings.Pages.FileOperationsSettingsPage"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
|
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="File Operations" Style="{StaticResource SettingsHeading}"
|
<TextBlock Text="File Operations" Style="{StaticResource SettingsHeading}"
|
||||||
AutomationProperties.HeadingLevel="Level1"/>
|
AutomationProperties.HeadingLevel="Level1"/>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public partial class FileOperationsSettingsPage : UserControl
|
|||||||
|
|
||||||
private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
|
private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is SettingsDraft draft
|
if (SettingsPageContext.DraftOf(this) is { } draft
|
||||||
&& SettingsPathBrowse.TryPick(
|
&& SettingsPathBrowse.TryPick(
|
||||||
Window.GetWindow(this),
|
Window.GetWindow(this),
|
||||||
"7-Zip executable",
|
"7-Zip executable",
|
||||||
@@ -24,7 +24,7 @@ public partial class FileOperationsSettingsPage : UserControl
|
|||||||
|
|
||||||
private void OnBrowseFfmpeg(object sender, RoutedEventArgs e)
|
private void OnBrowseFfmpeg(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is SettingsDraft draft
|
if (SettingsPageContext.DraftOf(this) is { } draft
|
||||||
&& SettingsPathBrowse.TryPick(
|
&& SettingsPathBrowse.TryPick(
|
||||||
Window.GetWindow(this),
|
Window.GetWindow(this),
|
||||||
"FFmpeg executable",
|
"FFmpeg executable",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<UserControl x:Class="Explorer.App.Settings.Pages.GeneralSettingsPage"
|
<UserControl x:Class="Explorer.App.Settings.Pages.GeneralSettingsPage"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
|
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="General" Style="{StaticResource SettingsHeading}"
|
<TextBlock Text="General" Style="{StaticResource SettingsHeading}"
|
||||||
AutomationProperties.HeadingLevel="Level1"/>
|
AutomationProperties.HeadingLevel="Level1"/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<UserControl x:Class="Explorer.App.Settings.Pages.IndexingSettingsPage"
|
<UserControl x:Class="Explorer.App.Settings.Pages.IndexingSettingsPage"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
|
xmlns:sys="clr-namespace:System;assembly=System.Runtime">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="Indexing" Style="{StaticResource SettingsHeading}"
|
<TextBlock Text="Indexing" Style="{StaticResource SettingsHeading}"
|
||||||
AutomationProperties.HeadingLevel="Level1"/>
|
AutomationProperties.HeadingLevel="Level1"/>
|
||||||
@@ -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."/>
|
Text="The host watches Windows idle time even if this window is closed. Conservative defaults wait 10 minutes and prefer AC power."/>
|
||||||
<TextBlock Text="Idle for" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8"
|
<TextBlock Text="Idle for" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8"
|
||||||
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"/>
|
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"/>
|
||||||
<DockPanel LastChildFill="False" Margin="0,0,0,14">
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
|
||||||
<ComboBox Width="80" HorizontalAlignment="Left"
|
<ComboBox MinWidth="88" Width="88"
|
||||||
ItemsSource="{Binding IdleThresholdChoices}"
|
SelectedItem="{Binding IdleMaintenanceMinutes, Mode=TwoWay}"
|
||||||
SelectedItem="{Binding IdleMaintenanceMinutes}"
|
|
||||||
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"
|
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"
|
||||||
AutomationProperties.Name="Idle threshold in minutes"/>
|
AutomationProperties.Name="Idle threshold in minutes">
|
||||||
|
<ComboBox.ItemsSource>
|
||||||
|
<x:Array Type="sys:Int32">
|
||||||
|
<sys:Int32>5</sys:Int32>
|
||||||
|
<sys:Int32>10</sys:Int32>
|
||||||
|
<sys:Int32>30</sys:Int32>
|
||||||
|
</x:Array>
|
||||||
|
</ComboBox.ItemsSource>
|
||||||
|
</ComboBox>
|
||||||
<TextBlock Text="minutes" VerticalAlignment="Center" Margin="10,0,0,0"
|
<TextBlock Text="minutes" VerticalAlignment="Center" Margin="10,0,0,0"
|
||||||
Foreground="{DynamicResource FgMuted}"
|
Foreground="{DynamicResource FgMuted}"
|
||||||
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"/>
|
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"/>
|
||||||
</DockPanel>
|
</StackPanel>
|
||||||
<CheckBox Margin="0,0,0,6"
|
<CheckBox Margin="0,0,0,6"
|
||||||
Content="Only run expensive maintenance on AC power"
|
Content="Only run expensive maintenance on AC power"
|
||||||
IsChecked="{Binding IdleMaintenanceAcOnly}"
|
IsChecked="{Binding IdleMaintenanceAcOnly}"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<UserControl x:Class="Explorer.App.Settings.Pages.NavigationSettingsPage"
|
<UserControl x:Class="Explorer.App.Settings.Pages.NavigationSettingsPage"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
|
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="Navigation" Style="{StaticResource SettingsHeading}"
|
<TextBlock Text="Navigation" Style="{StaticResource SettingsHeading}"
|
||||||
AutomationProperties.HeadingLevel="Level1"/>
|
AutomationProperties.HeadingLevel="Level1"/>
|
||||||
|
|||||||
11
src/Explorer.App/Settings/SettingsPageContext.cs
Normal file
11
src/Explorer.App/Settings/SettingsPageContext.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -63,9 +63,7 @@
|
|||||||
BorderThickness="1" Padding="16">
|
BorderThickness="1" Padding="16">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
|
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
|
||||||
KeyboardNavigation.TabIndex="1">
|
KeyboardNavigation.TabIndex="1">
|
||||||
<ContentControl Content="{Binding SelectedCategory.Page}"
|
<Grid x:Name="PageHost" HorizontalAlignment="Stretch" VerticalAlignment="Top"/>
|
||||||
HorizontalContentAlignment="Stretch"
|
|
||||||
VerticalContentAlignment="Top"/>
|
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -12,16 +12,46 @@ public partial class SettingsWindow : Window
|
|||||||
private readonly SettingsDraft _draft;
|
private readonly SettingsDraft _draft;
|
||||||
private readonly string _originalTheme;
|
private readonly string _originalTheme;
|
||||||
|
|
||||||
|
public SettingsDraft Draft => _draft;
|
||||||
|
|
||||||
public SettingsWindow(MainViewModel vm)
|
public SettingsWindow(MainViewModel vm)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
|
||||||
_vm = vm;
|
_vm = vm;
|
||||||
var prefs = vm.CurrentPreferences();
|
var prefs = vm.CurrentPreferences();
|
||||||
_originalTheme = prefs.Theme;
|
_originalTheme = prefs.Theme;
|
||||||
_draft = SettingsDraft.From(prefs);
|
_draft = SettingsDraft.From(prefs);
|
||||||
_draft.PropertyChanged += OnDraftChanged;
|
_draft.PropertyChanged += OnDraftChanged;
|
||||||
DataContext = new SettingsShell(_draft, SettingsCatalog.Create());
|
var shell = new SettingsShell(_draft, SettingsCatalog.Create());
|
||||||
Closed += (_, _) => _draft.PropertyChanged -= OnDraftChanged;
|
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)
|
private void OnDraftChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
|||||||
50
src/Explorer.App/TagRenameWindow.xaml
Normal file
50
src/Explorer.App/TagRenameWindow.xaml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<Window x:Class="Explorer.App.TagRenameWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="Tags"
|
||||||
|
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
|
||||||
|
Height="640" Width="980"
|
||||||
|
MinHeight="480" MinWidth="760"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
|
||||||
|
<DockPanel Margin="16">
|
||||||
|
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
|
||||||
|
<Button DockPanel.Dock="Right" Content="Cancel" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
|
||||||
|
<Button DockPanel.Dock="Right" Content="Queue rename" MinWidth="120" Height="32"
|
||||||
|
Command="{Binding QueueRenameCommand}" IsEnabled="{Binding CanQueueRename}" Margin="8,0,0,0"/>
|
||||||
|
<Button DockPanel.Dock="Right" Content="Queue tags" MinWidth="110" Height="32" IsDefault="True"
|
||||||
|
Command="{Binding QueueTagsCommand}" IsEnabled="{Binding CanQueueTags}"/>
|
||||||
|
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"/>
|
||||||
|
</DockPanel>
|
||||||
|
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,12">
|
||||||
|
<TextBlock DockPanel.Dock="Top" Text="Filename pattern" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||||
|
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="8,0,0,0">
|
||||||
|
<Button Content="Save pattern" MinWidth="110" Height="28" Command="{Binding SavePatternCommand}" Margin="0,0,8,0"/>
|
||||||
|
<Button Content="Filename → tags" MinWidth="130" Height="28" Command="{Binding ApplyFilenameToTagsCommand}" Margin="0,0,8,0"/>
|
||||||
|
<Button Content="Tags → filename" MinWidth="130" Height="28" Command="{Binding ApplyTagsToNamesCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<ComboBox IsEditable="True" ItemsSource="{Binding PatternChoices}"
|
||||||
|
Text="{Binding Pattern, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
</DockPanel>
|
||||||
|
<TextBlock DockPanel.Dock="Top" Margin="0,0,0,12" TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12"
|
||||||
|
Text="Edit tags, then Queue tags to write them into the files (ID3 on audio, EXIF on photos). Tags → filename fills New name from the pattern; Queue rename applies those names. Online-only cloud files are skipped. {Project} is the Git repo or parent folder."/>
|
||||||
|
<DataGrid ItemsSource="{Binding Rows}" AutoGenerateColumns="False" CanUserAddRows="False" CanUserDeleteRows="False"
|
||||||
|
HeadersVisibility="Column" GridLinesVisibility="Horizontal"
|
||||||
|
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"
|
||||||
|
BorderBrush="{DynamicResource Stroke}" RowBackground="{DynamicResource Panel}"
|
||||||
|
AlternatingRowBackground="{DynamicResource Panel}"
|
||||||
|
HorizontalGridLinesBrush="{DynamicResource Stroke}">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="File" Binding="{Binding FileName}" Width="180" IsReadOnly="True"/>
|
||||||
|
<DataGridTextColumn Header="Artist" Binding="{Binding Artist, UpdateSourceTrigger=PropertyChanged}" Width="120"/>
|
||||||
|
<DataGridTextColumn Header="Title" Binding="{Binding Title, UpdateSourceTrigger=PropertyChanged}" Width="160"/>
|
||||||
|
<DataGridTextColumn Header="Album" Binding="{Binding Album, UpdateSourceTrigger=PropertyChanged}" Width="140"/>
|
||||||
|
<DataGridTextColumn Header="Track" Binding="{Binding Track, UpdateSourceTrigger=PropertyChanged}" Width="60"/>
|
||||||
|
<DataGridTextColumn Header="Year" Binding="{Binding Year, UpdateSourceTrigger=PropertyChanged}" Width="60"/>
|
||||||
|
<DataGridTextColumn Header="Genre" Binding="{Binding Genre, UpdateSourceTrigger=PropertyChanged}" Width="100"/>
|
||||||
|
<DataGridTextColumn Header="New name" Binding="{Binding ProposedName}" Width="180" IsReadOnly="True"/>
|
||||||
|
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="120" IsReadOnly="True"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
26
src/Explorer.App/TagRenameWindow.xaml.cs
Normal file
26
src/Explorer.App/TagRenameWindow.xaml.cs
Normal file
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
193
src/Explorer.Application/DestinationPattern.cs
Normal file
193
src/Explorer.Application/DestinationPattern.cs
Normal file
@@ -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<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/Explorer.Application/DestinationPatterns.cs
Normal file
88
src/Explorer.Application/DestinationPatterns.cs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
namespace Explorer.Application;
|
||||||
|
|
||||||
|
public static class DestinationPatterns
|
||||||
|
{
|
||||||
|
public const int MaxCount = 24;
|
||||||
|
public const int MaxLength = 500;
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> BuiltIn { get; } =
|
||||||
|
[
|
||||||
|
@"\\host\share\movies\%filename_noext%",
|
||||||
|
@"%source_drive%\sorted\%year%\%month%"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> Combine(IEnumerable<string>? saved)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
var seen = new HashSet<string>(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<string> Add(IEnumerable<string>? saved, string pattern)
|
||||||
|
{
|
||||||
|
if (!TryNormalize(pattern, out var text))
|
||||||
|
{
|
||||||
|
return Normalize(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = new List<string> { text };
|
||||||
|
var seen = new HashSet<string>(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<string> Normalize(IEnumerable<string>? saved)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
var seen = new HashSet<string>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
314
src/Explorer.Application/FilenamePattern.cs
Normal file
314
src/Explorer.Application/FilenamePattern.cs
Normal file
@@ -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<string>();
|
||||||
|
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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/Explorer.Application/FilenamePatterns.cs
Normal file
88
src/Explorer.Application/FilenamePatterns.cs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
namespace Explorer.Application;
|
||||||
|
|
||||||
|
public static class FilenamePatterns
|
||||||
|
{
|
||||||
|
public const int MaxCount = 24;
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> BuiltIn { get; } =
|
||||||
|
[
|
||||||
|
"{Artist} - {Title}",
|
||||||
|
"{Track:00} - {Title}",
|
||||||
|
"{TakenDate}_{Name}",
|
||||||
|
"{CreatedDate}_{Name}",
|
||||||
|
"{Project}_{Name}"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> Combine(IEnumerable<string>? saved)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
var seen = new HashSet<string>(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<string> Add(IEnumerable<string>? saved, string pattern)
|
||||||
|
{
|
||||||
|
if (!TryNormalize(pattern, out var text))
|
||||||
|
{
|
||||||
|
return Normalize(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = new List<string> { text };
|
||||||
|
var seen = new HashSet<string>(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<string> Normalize(IEnumerable<string>? saved)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
var seen = new HashSet<string>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/Explorer.Application/IMediaTagService.cs
Normal file
9
src/Explorer.Application/IMediaTagService.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
116
src/Explorer.Application/MoveToPlanner.cs
Normal file
116
src/Explorer.Application/MoveToPlanner.cs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
|
|
||||||
|
namespace Explorer.Application;
|
||||||
|
|
||||||
|
public sealed class MoveToPlanner
|
||||||
|
{
|
||||||
|
public OperationPlan Build(
|
||||||
|
IReadOnlyList<RenameSubject> subjects,
|
||||||
|
string pattern,
|
||||||
|
Func<string, bool>? pathExists = null,
|
||||||
|
Func<string, bool>? wouldHydrate = null)
|
||||||
|
{
|
||||||
|
if (subjects.Count == 0)
|
||||||
|
{
|
||||||
|
return new OperationPlan
|
||||||
|
{
|
||||||
|
Issues = [new PlanIssue(PlanIssueSeverity.Error, "Select files or folders to move.")]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues = new List<PlanIssue>();
|
||||||
|
var operations = new List<PlannedOperation>();
|
||||||
|
var preview = new List<ProfilePreviewRow>();
|
||||||
|
var taken = new HashSet<string>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ public sealed class RenamePlanner
|
|||||||
public OperationPlan Build(
|
public OperationPlan Build(
|
||||||
IReadOnlyList<RenameSubject> subjects,
|
IReadOnlyList<RenameSubject> subjects,
|
||||||
RenameRuleSet rules,
|
RenameRuleSet rules,
|
||||||
Func<string, bool>? pathExists = null)
|
Func<string, bool>? pathExists = null,
|
||||||
|
IReadOnlyDictionary<string, MediaTagFields>? tagsByPath = null)
|
||||||
{
|
{
|
||||||
var issues = new List<PlanIssue>();
|
var issues = new List<PlanIssue>();
|
||||||
Regex? regex = null;
|
Regex? regex = null;
|
||||||
@@ -42,7 +43,21 @@ public sealed class RenamePlanner
|
|||||||
string newName;
|
string newName;
|
||||||
try
|
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)
|
catch (RegexMatchTimeoutException)
|
||||||
{
|
{
|
||||||
@@ -186,27 +201,38 @@ public sealed class RenamePlanner
|
|||||||
return new OperationPlan { Operations = operations };
|
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 (stem, extension) = WindowsFileNames.Split(name);
|
||||||
var text = rules.IncludeExtensionInSearch ? name : stem;
|
tags ??= new MediaTagFields { Stem = stem, Extension = extension };
|
||||||
text = Replace(text, rules, regex);
|
|
||||||
text = (rules.Prefix ?? "") + text + (rules.Suffix ?? "");
|
|
||||||
var counter = rules.UseCounter
|
var counter = rules.UseCounter
|
||||||
? WindowsFileNames.FormatCounter(rules.CounterStart + index * Math.Max(1, rules.CounterStep), rules.CounterPadding)
|
? 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))
|
if (text.Contains("{Counter}", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
text = Regex.Replace(text, "\\{Counter\\}", counter, RegexOptions.IgnoreCase);
|
text = Regex.Replace(text, "\\{Counter\\}", counter, RegexOptions.IgnoreCase);
|
||||||
}
|
}
|
||||||
else if (rules.UseCounter)
|
else if (rules.UseCounter && string.IsNullOrWhiteSpace(rules.NamePattern))
|
||||||
{
|
{
|
||||||
text += counter;
|
text += counter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
text = FilenamePattern.Expand(text, tags, counter);
|
||||||
text = WindowsFileNames.ApplyCase(text, rules.CaseMode);
|
text = WindowsFileNames.ApplyCase(text, rules.CaseMode);
|
||||||
text = text.Replace("{Extension}", extension, StringComparison.OrdinalIgnoreCase);
|
text = text.Replace("{Extension}", extension, StringComparison.OrdinalIgnoreCase);
|
||||||
if (rules.IncludeExtensionInSearch && !rules.ChangeExtension)
|
if (rules.IncludeExtensionInSearch && !rules.ChangeExtension && string.IsNullOrWhiteSpace(rules.NamePattern))
|
||||||
{
|
{
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
@@ -215,6 +241,13 @@ public sealed class RenamePlanner
|
|||||||
return WindowsFileNames.Join(text, newExt);
|
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)
|
private static string Replace(string text, RenameRuleSet rules, Regex? regex)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(rules.Search))
|
if (string.IsNullOrEmpty(rules.Search))
|
||||||
|
|||||||
123
src/Explorer.Application/TagRenamePlanner.cs
Normal file
123
src/Explorer.Application/TagRenamePlanner.cs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
|
|
||||||
|
namespace Explorer.Application;
|
||||||
|
|
||||||
|
public sealed class TagRenamePlanner
|
||||||
|
{
|
||||||
|
public OperationPlan BuildWrite(
|
||||||
|
IReadOnlyList<RenameSubject> subjects,
|
||||||
|
string pattern,
|
||||||
|
IReadOnlyDictionary<string, MediaTagFields> current,
|
||||||
|
Func<string, bool>? wouldHydrate = null)
|
||||||
|
{
|
||||||
|
var issues = new List<PlanIssue>();
|
||||||
|
var preview = new List<TagPreviewRow>();
|
||||||
|
var operations = new List<PlannedOperation>();
|
||||||
|
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<RenameSubject> subjects,
|
||||||
|
string pattern,
|
||||||
|
IReadOnlyDictionary<string, MediaTagFields> current,
|
||||||
|
RenamePlanner rename,
|
||||||
|
Func<string, bool>? pathExists = null,
|
||||||
|
Func<string, bool>? wouldHydrate = null)
|
||||||
|
{
|
||||||
|
var issues = new List<PlanIssue>();
|
||||||
|
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<string, MediaTagFields> 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);
|
||||||
|
}
|
||||||
@@ -42,7 +42,9 @@ public sealed record UiPreferences(
|
|||||||
bool PreferFavoritesInTree = false,
|
bool PreferFavoritesInTree = false,
|
||||||
bool BackgroundMaintenanceWhenIdle = true,
|
bool BackgroundMaintenanceWhenIdle = true,
|
||||||
int IdleMaintenanceMinutes = 10,
|
int IdleMaintenanceMinutes = 10,
|
||||||
bool IdleMaintenanceAcOnly = true)
|
bool IdleMaintenanceAcOnly = true,
|
||||||
|
IReadOnlyList<string>? NamePatterns = null,
|
||||||
|
IReadOnlyList<string>? MoveToPatterns = null)
|
||||||
{
|
{
|
||||||
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
|
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
|
||||||
}
|
}
|
||||||
@@ -99,6 +101,8 @@ public sealed class UiPreferencesStore
|
|||||||
.. OrganizeLines(preferences),
|
.. OrganizeLines(preferences),
|
||||||
.. LayoutLines(preferences),
|
.. LayoutLines(preferences),
|
||||||
.. FavoriteLines(preferences),
|
.. FavoriteLines(preferences),
|
||||||
|
.. NamePatternLines(preferences),
|
||||||
|
.. MoveToPatternLines(preferences),
|
||||||
.. SessionLines(preferences)
|
.. SessionLines(preferences)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -142,6 +146,8 @@ public sealed class UiPreferencesStore
|
|||||||
var sessionTabs = new List<SessionTabState>();
|
var sessionTabs = new List<SessionTabState>();
|
||||||
var sessionActiveTab = 0;
|
var sessionActiveTab = 0;
|
||||||
var favorites = new List<string>();
|
var favorites = new List<string>();
|
||||||
|
var namePatterns = new List<string>();
|
||||||
|
var moveToPatterns = new List<string>();
|
||||||
foreach (var raw in lines)
|
foreach (var raw in lines)
|
||||||
{
|
{
|
||||||
var line = raw.Trim();
|
var line = raw.Trim();
|
||||||
@@ -280,6 +286,16 @@ public sealed class UiPreferencesStore
|
|||||||
{
|
{
|
||||||
favorites.Add(value);
|
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)
|
else if (key.Equals("session-active-tab", StringComparison.OrdinalIgnoreCase)
|
||||||
&& int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var activeTab)
|
&& int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var activeTab)
|
||||||
&& activeTab >= 0)
|
&& activeTab >= 0)
|
||||||
@@ -305,7 +321,9 @@ public sealed class UiPreferencesStore
|
|||||||
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
|
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
|
||||||
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon,
|
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon,
|
||||||
FavoriteFolders.Normalize(favorites), sessionTabs, sessionActiveTab, preferFavoritesInTree,
|
FavoriteFolders.Normalize(favorites), sessionTabs, sessionActiveTab, preferFavoritesInTree,
|
||||||
backgroundMaintenanceWhenIdle, idleMaintenanceMinutes, idleMaintenanceAcOnly);
|
backgroundMaintenanceWhenIdle, idleMaintenanceMinutes, idleMaintenanceAcOnly,
|
||||||
|
FilenamePatterns.Normalize(namePatterns),
|
||||||
|
DestinationPatterns.Normalize(moveToPatterns));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<string> SevenZipLines(UiPreferences preferences)
|
private static IEnumerable<string> SevenZipLines(UiPreferences preferences)
|
||||||
@@ -403,6 +421,22 @@ public sealed class UiPreferencesStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> NamePatternLines(UiPreferences preferences)
|
||||||
|
{
|
||||||
|
foreach (var pattern in FilenamePatterns.Normalize(preferences.NamePatterns))
|
||||||
|
{
|
||||||
|
yield return "name-pattern=" + pattern;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> MoveToPatternLines(UiPreferences preferences)
|
||||||
|
{
|
||||||
|
foreach (var pattern in DestinationPatterns.Normalize(preferences.MoveToPatterns))
|
||||||
|
{
|
||||||
|
yield return "move-to=" + pattern;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static IEnumerable<string> FavoriteLines(UiPreferences preferences)
|
private static IEnumerable<string> FavoriteLines(UiPreferences preferences)
|
||||||
{
|
{
|
||||||
foreach (var path in FavoriteFolders.Normalize(preferences.FavoriteFolders))
|
foreach (var path in FavoriteFolders.Normalize(preferences.FavoriteFolders))
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ public interface ITransferHost
|
|||||||
=> Task.CompletedTask;
|
=> Task.CompletedTask;
|
||||||
Task EnqueueConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default)
|
Task EnqueueConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default)
|
||||||
=> Task.CompletedTask;
|
=> 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
|
public interface ISourceHost
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public interface IVolumeService
|
|||||||
VolumeFingerprint? Probe(string path);
|
VolumeFingerprint? Probe(string path);
|
||||||
VolumeSpace GetSpace(string path);
|
VolumeSpace GetSpace(string path);
|
||||||
bool IsPathReachable(string path);
|
bool IsPathReachable(string path);
|
||||||
|
bool TryEnsureReachable(string path) => IsPathReachable(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IFileSystemEnumerator
|
public interface IFileSystemEnumerator
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ public enum TransferOp
|
|||||||
Compress,
|
Compress,
|
||||||
AddToArchive,
|
AddToArchive,
|
||||||
VerifyArchive,
|
VerifyArchive,
|
||||||
Convert
|
Convert,
|
||||||
|
WriteTags
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ArchiveFormat
|
public enum ArchiveFormat
|
||||||
|
|||||||
148
src/Explorer.Domain/MediaTags.cs
Normal file
148
src/Explorer.Domain/MediaTags.cs
Normal file
@@ -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<string>();
|
||||||
|
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<string> 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);
|
||||||
@@ -22,6 +22,7 @@ public sealed class OperationPlan
|
|||||||
public IReadOnlyList<SyncPreviewRow> SyncPreview { get; init; } = [];
|
public IReadOnlyList<SyncPreviewRow> SyncPreview { get; init; } = [];
|
||||||
public IReadOnlyList<ProfilePreviewRow> ProfilePreview { get; init; } = [];
|
public IReadOnlyList<ProfilePreviewRow> ProfilePreview { get; init; } = [];
|
||||||
public IReadOnlyList<OrganizePreviewRow> OrganizePreview { get; init; } = [];
|
public IReadOnlyList<OrganizePreviewRow> OrganizePreview { get; init; } = [];
|
||||||
|
public IReadOnlyList<TagPreviewRow> TagPreview { get; init; } = [];
|
||||||
|
|
||||||
public bool HasErrors => Issues.Any(i => i.Severity == PlanIssueSeverity.Error);
|
public bool HasErrors => Issues.Any(i => i.Severity == PlanIssueSeverity.Error);
|
||||||
public bool CanEnqueue => !HasErrors && Operations.Count > 0;
|
public bool CanEnqueue => !HasErrors && Operations.Count > 0;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public sealed class RenameRuleSet
|
|||||||
public RenameCaseMode CaseMode { get; init; }
|
public RenameCaseMode CaseMode { get; init; }
|
||||||
public bool ChangeExtension { get; init; }
|
public bool ChangeExtension { get; init; }
|
||||||
public string NewExtension { get; init; } = "";
|
public string NewExtension { get; init; } = "";
|
||||||
|
public string NamePattern { get; init; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RenameBatch
|
public sealed class RenameBatch
|
||||||
|
|||||||
@@ -61,6 +61,27 @@ public static class WindowsFileNames
|
|||||||
return true;
|
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)
|
public static (string Stem, string Extension) Split(string name)
|
||||||
{
|
{
|
||||||
var dot = name.LastIndexOf('.');
|
var dot = name.LastIndexOf('.');
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ public sealed class FileOperationService
|
|||||||
public Task MoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
|
public Task MoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
|
||||||
=> _queue.EnqueueMoveAsync(sources, destinationDirectory, cancellationToken);
|
=> _queue.EnqueueMoveAsync(sources, destinationDirectory, cancellationToken);
|
||||||
|
|
||||||
|
public async Task MoveToAsync(IReadOnlyList<PlannedOperation> 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<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
|
public Task DeleteAsync(IReadOnlyList<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
|
||||||
=> _queue.EnqueueDeleteAsync(paths, permanent, cancellationToken);
|
=> _queue.EnqueueDeleteAsync(paths, permanent, cancellationToken);
|
||||||
|
|
||||||
@@ -102,6 +110,14 @@ public sealed class FileOperationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task EnqueueWriteTagsAsync(IReadOnlyList<PlannedOperation> 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)
|
public static string UniqueArchivePath(string directory, string stem, string extension)
|
||||||
{
|
{
|
||||||
extension = extension.Trim().TrimStart('.');
|
extension = extension.Trim().TrimStart('.');
|
||||||
|
|||||||
@@ -11,25 +11,29 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
|
|||||||
private readonly IArchiveExecutor? _archives;
|
private readonly IArchiveExecutor? _archives;
|
||||||
private readonly IHydrationGuard? _hydration;
|
private readonly IHydrationGuard? _hydration;
|
||||||
private readonly IMediaConversionProvider? _conversion;
|
private readonly IMediaConversionProvider? _conversion;
|
||||||
|
private readonly IMediaTagService? _tags;
|
||||||
|
|
||||||
public NativeFileOperationExecutor(
|
public NativeFileOperationExecutor(
|
||||||
IShellFileOperations shell,
|
IShellFileOperations shell,
|
||||||
IFileSystemEnumerator enumerator,
|
IFileSystemEnumerator enumerator,
|
||||||
IArchiveExecutor? archives = null,
|
IArchiveExecutor? archives = null,
|
||||||
IHydrationGuard? hydration = null,
|
IHydrationGuard? hydration = null,
|
||||||
IMediaConversionProvider? conversion = null)
|
IMediaConversionProvider? conversion = null,
|
||||||
|
IMediaTagService? tags = null)
|
||||||
{
|
{
|
||||||
_shell = shell;
|
_shell = shell;
|
||||||
_enumerator = enumerator;
|
_enumerator = enumerator;
|
||||||
_archives = archives;
|
_archives = archives;
|
||||||
_hydration = hydration;
|
_hydration = hydration;
|
||||||
_conversion = conversion;
|
_conversion = conversion;
|
||||||
|
_tags = tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool CanExecute(TransferOp op)
|
public bool CanExecute(TransferOp op)
|
||||||
=> op is TransferOp.Copy or TransferOp.Move or TransferOp.Delete or TransferOp.Rename
|
=> op is TransferOp.Copy or TransferOp.Move or TransferOp.Delete or TransferOp.Rename
|
||||||
or TransferOp.EmptyRecycleBin or TransferOp.Extract or TransferOp.Compress
|
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<bool> pauseRequested, Action? reportProgress, CancellationToken cancellationToken)
|
public async Task ExecuteAsync(TransferJob job, Func<bool> pauseRequested, Action? reportProgress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -59,6 +63,9 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
|
|||||||
case TransferOp.Convert:
|
case TransferOp.Convert:
|
||||||
await ConvertAsync(job, reportProgress, cancellationToken).ConfigureAwait(false);
|
await ConvertAsync(job, reportProgress, cancellationToken).ConfigureAwait(false);
|
||||||
break;
|
break;
|
||||||
|
case TransferOp.WriteTags:
|
||||||
|
await WriteTagsAsync(job, cancellationToken).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
job.Status = TransferStatus.Failed;
|
job.Status = TransferStatus.Failed;
|
||||||
job.Error = $"Unsupported operation {job.Op}";
|
job.Error = $"Unsupported operation {job.Op}";
|
||||||
@@ -96,6 +103,11 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
|
|||||||
|
|
||||||
job.FilesTotal = Math.Max(job.FilesTotal, 1);
|
job.FilesTotal = Math.Max(job.FilesTotal, 1);
|
||||||
job.CurrentPath = src;
|
job.CurrentPath = src;
|
||||||
|
if (!EnsureParent(dst, job))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var resume = File.Exists(PathRules.ToExtended(dst));
|
var resume = File.Exists(PathRules.ToExtended(dst));
|
||||||
if (!TransferFile(src, dst, move, resume, job, committed: 0, pauseRequested, reportProgress, stoppingToken, out var error))
|
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);
|
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(
|
private async Task CopyDirectory(
|
||||||
string src,
|
string src,
|
||||||
string dst,
|
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)
|
private static ConversionKind ConversionKindOf(TransferJob job)
|
||||||
{
|
{
|
||||||
if (job.AdditionalSources.Count == 1
|
if (job.AdditionalSources.Count == 1
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ internal static class OperationAvailability
|
|||||||
yield return PathRules.Parent(job.DestinationPath);
|
yield return PathRules.Parent(job.DestinationPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
yield break;
|
||||||
|
case TransferOp.WriteTags:
|
||||||
|
yield return job.SourcePath;
|
||||||
yield break;
|
yield break;
|
||||||
case TransferOp.VerifyArchive:
|
case TransferOp.VerifyArchive:
|
||||||
yield return job.SourcePath;
|
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)
|
public static bool IsVolumeReachable(IVolumeService volumes, string path)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(path))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ public sealed class RenameBatchService
|
|||||||
return File.Exists(ext) || Directory.Exists(ext);
|
return File.Exists(ext) || Directory.Exists(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
public OperationPlan Preview(IReadOnlyList<RenameSubject> subjects, RenameRuleSet rules)
|
public OperationPlan Preview(
|
||||||
=> _planner.Build(subjects, rules, PathExists);
|
IReadOnlyList<RenameSubject> subjects,
|
||||||
|
RenameRuleSet rules,
|
||||||
|
IReadOnlyDictionary<string, MediaTagFields>? tagsByPath = null)
|
||||||
|
=> _planner.Build(subjects, rules, PathExists, tagsByPath);
|
||||||
|
|
||||||
public async Task<long> EnqueueAsync(OperationPlan plan, CancellationToken cancellationToken = default)
|
public async Task<long> EnqueueAsync(OperationPlan plan, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -80,17 +80,20 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
|
|||||||
foreach (var src in sources)
|
foreach (var src in sources)
|
||||||
{
|
{
|
||||||
var dest = Path.Combine(destinationDirectory, PathRules.GetFileName(src));
|
var dest = Path.Combine(destinationDirectory, PathRules.GetFileName(src));
|
||||||
await EnqueueAsync(new TransferJob
|
await EnqueueMoveToAsync(src, dest, cancellationToken).ConfigureAwait(false);
|
||||||
{
|
|
||||||
Op = TransferOp.Move,
|
|
||||||
SourcePath = src,
|
|
||||||
DestinationPath = dest,
|
|
||||||
Status = TransferStatus.Queued,
|
|
||||||
CreatedUtc = DateTimeOffset.UtcNow
|
|
||||||
}, 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<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
|
public async Task EnqueueDeleteAsync(IReadOnlyList<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await EnqueueAsync(new TransferJob
|
await EnqueueAsync(new TransferJob
|
||||||
@@ -178,6 +181,16 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
|
|||||||
AdditionalSources = [kind.ToString()]
|
AdditionalSources = [kind.ToString()]
|
||||||
}, cancellationToken);
|
}, 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()
|
public void PauseAll()
|
||||||
{
|
{
|
||||||
_queuePaused = true;
|
_queuePaused = true;
|
||||||
@@ -256,18 +269,38 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
|
|||||||
|
|
||||||
public void Retry(long jobId)
|
public void Retry(long jobId)
|
||||||
{
|
{
|
||||||
|
TransferJob? job;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
var job = Find(jobId);
|
job = Find(jobId);
|
||||||
if (job is null || job.Status != TransferStatus.Failed)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
job.RetryCount++;
|
job.RetryCount++;
|
||||||
job.Status = TransferStatus.Queued;
|
|
||||||
job.Error = null;
|
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);
|
QueuePersist(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,12 +425,23 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
|
|||||||
|
|
||||||
public void NotifyAvailability()
|
public void NotifyAvailability()
|
||||||
{
|
{
|
||||||
|
List<TransferJob> waiting;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
waiting = _jobs.Where(j => j.Status == TransferStatus.Waiting).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var job in waiting)
|
||||||
|
{
|
||||||
|
OperationAvailability.TryPrepare(_volumes, job);
|
||||||
|
}
|
||||||
|
|
||||||
var resumed = false;
|
var resumed = false;
|
||||||
lock (_gate)
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -418,6 +462,11 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
|
|||||||
|
|
||||||
private async Task EnqueueAsync(TransferJob job, CancellationToken cancellationToken)
|
private async Task EnqueueAsync(TransferJob job, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (!OperationAvailability.IsReady(_volumes, job))
|
||||||
|
{
|
||||||
|
OperationAvailability.TryPrepare(_volumes, job);
|
||||||
|
}
|
||||||
|
|
||||||
if (!OperationAvailability.IsReady(_volumes, job))
|
if (!OperationAvailability.IsReady(_volumes, job))
|
||||||
{
|
{
|
||||||
job.Status = TransferStatus.Waiting;
|
job.Status = TransferStatus.Waiting;
|
||||||
@@ -462,6 +511,11 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!OperationAvailability.IsReady(_volumes, job))
|
||||||
|
{
|
||||||
|
OperationAvailability.TryPrepare(_volumes, job);
|
||||||
|
}
|
||||||
|
|
||||||
if (!OperationAvailability.IsReady(_volumes, job))
|
if (!OperationAvailability.IsReady(_volumes, job))
|
||||||
{
|
{
|
||||||
job.Status = TransferStatus.Waiting;
|
job.Status = TransferStatus.Waiting;
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ public static class ExplorerHostClientServices
|
|||||||
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
||||||
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
|
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
|
||||||
services.AddSingleton<IMediaConversionProvider, FfmpegConversionExecutor>();
|
services.AddSingleton<IMediaConversionProvider, FfmpegConversionExecutor>();
|
||||||
|
services.AddSingleton<IMediaTagService, TagLibMediaTagService>();
|
||||||
services.AddSingleton<WindowsGitStatusProvider>();
|
services.AddSingleton<WindowsGitStatusProvider>();
|
||||||
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
||||||
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
||||||
|
|||||||
@@ -504,6 +504,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
|
|||||||
=> _client.CallAsync("Transfers.EnqueueCopy", cancellationToken, dest: destinationDirectory, paths: sources.ToArray());
|
=> _client.CallAsync("Transfers.EnqueueCopy", cancellationToken, dest: destinationDirectory, paths: sources.ToArray());
|
||||||
public Task EnqueueMoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
|
public Task EnqueueMoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
|
||||||
=> _client.CallAsync("Transfers.EnqueueMove", cancellationToken, dest: destinationDirectory, paths: sources.ToArray());
|
=> _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<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
|
public Task EnqueueDeleteAsync(IReadOnlyList<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
|
||||||
=> _client.CallAsync("Transfers.EnqueueDelete", cancellationToken, paths: paths.ToArray(), flag: permanent);
|
=> _client.CallAsync("Transfers.EnqueueDelete", cancellationToken, paths: paths.ToArray(), flag: permanent);
|
||||||
public Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default)
|
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);
|
=> _client.CallAsync("Transfers.EnqueueVerifyArchive", cancellationToken, s: archivePath);
|
||||||
public Task EnqueueConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default)
|
public Task EnqueueConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default)
|
||||||
=> _client.CallAsync("Transfers.EnqueueConvert", cancellationToken, s: kind.ToString(), dest: destinationPath, paths: [sourcePath]);
|
=> _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 RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
|
||||||
public void RaiseFinished(TransferJob job) => JobFinished?.Invoke(this, job);
|
public void RaiseFinished(TransferJob job) => JobFinished?.Invoke(this, job);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ public static class ExplorerHostServices
|
|||||||
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
||||||
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
|
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
|
||||||
services.AddSingleton<IMediaConversionProvider, FfmpegConversionExecutor>();
|
services.AddSingleton<IMediaConversionProvider, FfmpegConversionExecutor>();
|
||||||
|
services.AddSingleton<IMediaTagService, TagLibMediaTagService>();
|
||||||
services.AddSingleton<WindowsGitStatusProvider>();
|
services.AddSingleton<WindowsGitStatusProvider>();
|
||||||
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
||||||
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
||||||
|
|||||||
@@ -383,6 +383,9 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
|||||||
case "Transfers.EnqueueMove":
|
case "Transfers.EnqueueMove":
|
||||||
await Workbench.Transfers.EnqueueMoveAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
|
await Workbench.Transfers.EnqueueMoveAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
|
case "Transfers.EnqueueMoveTo":
|
||||||
|
await Workbench.Transfers.EnqueueMoveToAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
|
||||||
|
return reply;
|
||||||
case "Transfers.EnqueueDelete":
|
case "Transfers.EnqueueDelete":
|
||||||
await Workbench.Transfers.EnqueueDeleteAsync(request.Paths ?? [], request.Flag == true).ConfigureAwait(false);
|
await Workbench.Transfers.EnqueueDeleteAsync(request.Paths ?? [], request.Flag == true).ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
@@ -411,6 +414,9 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
|||||||
var convertSource = request.Paths is { Length: > 0 } paths ? paths[0] : "";
|
var convertSource = request.Paths is { Length: > 0 } paths ? paths[0] : "";
|
||||||
await Workbench.Transfers.EnqueueConvertAsync(convertSource, request.Dest ?? "", convertKind).ConfigureAwait(false);
|
await Workbench.Transfers.EnqueueConvertAsync(convertSource, request.Dest ?? "", convertKind).ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
|
case "Transfers.EnqueueWriteTags":
|
||||||
|
await Workbench.Transfers.EnqueueWriteTagsAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
|
||||||
|
return reply;
|
||||||
case "Sources.Refresh":
|
case "Sources.Refresh":
|
||||||
await Workbench.Sources.RefreshAsync().ConfigureAwait(false);
|
await Workbench.Sources.RefreshAsync().ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ public sealed partial class BatchRenameViewModel : ObservableObject
|
|||||||
private readonly RenamePlanner _planner;
|
private readonly RenamePlanner _planner;
|
||||||
private readonly RenameBatchService _batches;
|
private readonly RenameBatchService _batches;
|
||||||
private readonly IReadOnlyList<RenameSubject> _subjects;
|
private readonly IReadOnlyList<RenameSubject> _subjects;
|
||||||
|
private readonly IMediaTagService? _tags;
|
||||||
|
private readonly IHydrationGuard? _hydration;
|
||||||
|
private readonly UiPreferencesStore? _preferences;
|
||||||
|
private readonly IGitStatusProvider? _git;
|
||||||
|
private readonly Dictionary<string, MediaTagFields> _metadata = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
[ObservableProperty] private string _search = "";
|
[ObservableProperty] private string _search = "";
|
||||||
[ObservableProperty] private string _replace = "";
|
[ObservableProperty] private string _replace = "";
|
||||||
@@ -27,22 +32,40 @@ public sealed partial class BatchRenameViewModel : ObservableObject
|
|||||||
[ObservableProperty] private RenameCaseMode _caseMode = RenameCaseMode.Unchanged;
|
[ObservableProperty] private RenameCaseMode _caseMode = RenameCaseMode.Unchanged;
|
||||||
[ObservableProperty] private bool _changeExtension;
|
[ObservableProperty] private bool _changeExtension;
|
||||||
[ObservableProperty] private string _newExtension = "";
|
[ObservableProperty] private string _newExtension = "";
|
||||||
|
[ObservableProperty] private string _namePattern = "";
|
||||||
[ObservableProperty] private string _status = "";
|
[ObservableProperty] private string _status = "";
|
||||||
[ObservableProperty] private bool _canQueue;
|
[ObservableProperty] private bool _canQueue;
|
||||||
|
|
||||||
public BatchRenameViewModel(
|
public BatchRenameViewModel(
|
||||||
IReadOnlyList<RenameSubject> subjects,
|
IReadOnlyList<RenameSubject> subjects,
|
||||||
RenamePlanner planner,
|
RenamePlanner planner,
|
||||||
RenameBatchService batches)
|
RenameBatchService batches,
|
||||||
|
IMediaTagService? tags = null,
|
||||||
|
IHydrationGuard? hydration = null,
|
||||||
|
UiPreferencesStore? preferences = null,
|
||||||
|
IGitStatusProvider? git = null)
|
||||||
{
|
{
|
||||||
_subjects = subjects;
|
_subjects = subjects;
|
||||||
_planner = planner;
|
_planner = planner;
|
||||||
_batches = batches;
|
_batches = batches;
|
||||||
|
_tags = tags;
|
||||||
|
_hydration = hydration;
|
||||||
|
_preferences = preferences;
|
||||||
|
_git = git;
|
||||||
Rows = [];
|
Rows = [];
|
||||||
|
PatternChoices = new ObservableCollection<string>(
|
||||||
|
FilenamePatterns.Combine(preferences?.Load().NamePatterns));
|
||||||
|
foreach (var subject in _subjects)
|
||||||
|
{
|
||||||
|
_metadata[subject.FullPath] = Seed(subject);
|
||||||
|
}
|
||||||
|
|
||||||
Rebuild();
|
Rebuild();
|
||||||
|
_ = LoadMediaAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObservableCollection<RenamePreviewRow> Rows { get; }
|
public ObservableCollection<RenamePreviewRow> Rows { get; }
|
||||||
|
public ObservableCollection<string> PatternChoices { get; }
|
||||||
public IReadOnlyList<RenameCaseOption> CaseOptions { get; } =
|
public IReadOnlyList<RenameCaseOption> CaseOptions { get; } =
|
||||||
[
|
[
|
||||||
new("Leave case", RenameCaseMode.Unchanged),
|
new("Leave case", RenameCaseMode.Unchanged),
|
||||||
@@ -68,13 +91,14 @@ public sealed partial class BatchRenameViewModel : ObservableObject
|
|||||||
CounterPadding = Math.Max(0, CounterPadding),
|
CounterPadding = Math.Max(0, CounterPadding),
|
||||||
CaseMode = CaseMode,
|
CaseMode = CaseMode,
|
||||||
ChangeExtension = ChangeExtension,
|
ChangeExtension = ChangeExtension,
|
||||||
NewExtension = NewExtension
|
NewExtension = NewExtension,
|
||||||
|
NamePattern = NamePattern
|
||||||
};
|
};
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public async Task QueueAsync()
|
public async Task QueueAsync()
|
||||||
{
|
{
|
||||||
var plan = _batches.Preview(_subjects, Rules);
|
var plan = _batches.Preview(_subjects, Rules, _metadata);
|
||||||
if (!plan.CanEnqueue)
|
if (!plan.CanEnqueue)
|
||||||
{
|
{
|
||||||
Status = plan.Issues.FirstOrDefault()?.Message ?? "Nothing to rename.";
|
Status = plan.Issues.FirstOrDefault()?.Message ?? "Nothing to rename.";
|
||||||
@@ -85,6 +109,27 @@ public sealed partial class BatchRenameViewModel : ObservableObject
|
|||||||
CloseRequested?.Invoke(this, EventArgs.Empty);
|
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 OnSearchChanged(string value) => Rebuild();
|
||||||
partial void OnReplaceChanged(string value) => Rebuild();
|
partial void OnReplaceChanged(string value) => Rebuild();
|
||||||
partial void OnUseRegexChanged(bool 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 OnCaseModeChanged(RenameCaseMode value) => Rebuild();
|
||||||
partial void OnChangeExtensionChanged(bool value) => Rebuild();
|
partial void OnChangeExtensionChanged(bool value) => Rebuild();
|
||||||
partial void OnNewExtensionChanged(string 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()
|
private void Rebuild()
|
||||||
{
|
{
|
||||||
var plan = _planner.Build(_subjects, Rules, RenameBatchService.PathExists);
|
var plan = _planner.Build(_subjects, Rules, RenameBatchService.PathExists, _metadata);
|
||||||
Rows.Clear();
|
Rows.Clear();
|
||||||
foreach (var row in plan.Preview)
|
foreach (var row in plan.Preview)
|
||||||
{
|
{
|
||||||
@@ -118,6 +192,11 @@ public sealed partial class BatchRenameViewModel : ObservableObject
|
|||||||
? $"{_subjects.Count} items · nothing to rename"
|
? $"{_subjects.Count} items · nothing to rename"
|
||||||
: $"{plan.Operations.Count} will be queued · {unchanged} unchanged";
|
: $"{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);
|
public sealed record RenameCaseOption(string Label, RenameCaseMode Mode);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private readonly IHostConnection? _host;
|
private readonly IHostConnection? _host;
|
||||||
private readonly IBackgroundMaintenance? _maintenance;
|
private readonly IBackgroundMaintenance? _maintenance;
|
||||||
private readonly IShellContextMenu? _shellMenu;
|
private readonly IShellContextMenu? _shellMenu;
|
||||||
|
private readonly IMediaTagService? _tags;
|
||||||
private bool _hostStopped;
|
private bool _hostStopped;
|
||||||
private List<string> _clipboard = [];
|
private List<string> _clipboard = [];
|
||||||
private bool _clipboardIsCut;
|
private bool _clipboardIsCut;
|
||||||
@@ -53,6 +54,8 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _showEmptyRecycleBin;
|
[ObservableProperty] private bool _showEmptyRecycleBin;
|
||||||
[ObservableProperty] private bool _showImportWindowsLocation;
|
[ObservableProperty] private bool _showImportWindowsLocation;
|
||||||
[ObservableProperty] private bool _showBatchRename;
|
[ObservableProperty] private bool _showBatchRename;
|
||||||
|
[ObservableProperty] private bool _showMoveTo;
|
||||||
|
[ObservableProperty] private bool _showTags;
|
||||||
[ObservableProperty] private bool _showRunProfile;
|
[ObservableProperty] private bool _showRunProfile;
|
||||||
[ObservableProperty] private bool _showOrganizeFolder;
|
[ObservableProperty] private bool _showOrganizeFolder;
|
||||||
[ObservableProperty] private bool _canUndoRenameBatch;
|
[ObservableProperty] private bool _canUndoRenameBatch;
|
||||||
@@ -101,7 +104,8 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
IThumbnailService? thumbnails = null,
|
IThumbnailService? thumbnails = null,
|
||||||
IHostConnection? hostConnection = null,
|
IHostConnection? hostConnection = null,
|
||||||
IBackgroundMaintenance? maintenance = null,
|
IBackgroundMaintenance? maintenance = null,
|
||||||
IShellContextMenu? shellMenu = null)
|
IShellContextMenu? shellMenu = null,
|
||||||
|
IMediaTagService? tags = null)
|
||||||
{
|
{
|
||||||
_browse = browse;
|
_browse = browse;
|
||||||
_ops = ops;
|
_ops = ops;
|
||||||
@@ -127,6 +131,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_host = hostConnection;
|
_host = hostConnection;
|
||||||
_maintenance = maintenance;
|
_maintenance = maintenance;
|
||||||
_shellMenu = shellMenu;
|
_shellMenu = shellMenu;
|
||||||
|
_tags = tags;
|
||||||
if (_host is not null)
|
if (_host is not null)
|
||||||
{
|
{
|
||||||
_host.StatusChanged += (_, status) =>
|
_host.StatusChanged += (_, status) =>
|
||||||
@@ -597,7 +602,43 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
return null;
|
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()
|
public async Task RefreshUndoRenameAsync()
|
||||||
@@ -875,6 +916,9 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
&& ActivePane.SelectedItems[0].Item.AvailableToImport;
|
&& ActivePane.SelectedItems[0].Item.AvailableToImport;
|
||||||
ShowBatchRename = ActivePane.SelectedItems.Count > 0
|
ShowBatchRename = ActivePane.SelectedItems.Count > 0
|
||||||
&& ActivePane.SelectedItems.All(i => !LocationRoots.IsVirtual(i.FullPath));
|
&& 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;
|
ShowRunProfile = ShowBatchRename;
|
||||||
ShowOrganizeFolder = OrganizeSourcePath() is not null;
|
ShowOrganizeFolder = OrganizeSourcePath() is not null;
|
||||||
var real = ActivePane.SelectedItems.Where(IsRealFileSystemItem).ToList();
|
var real = ActivePane.SelectedItems.Where(IsRealFileSystemItem).ToList();
|
||||||
@@ -1529,7 +1573,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
Add(job.SourcePath);
|
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);
|
Add(job.DestinationPath);
|
||||||
}
|
}
|
||||||
|
|||||||
141
src/Explorer.Presentation/ViewModels/MoveToViewModel.cs
Normal file
141
src/Explorer.Presentation/ViewModels/MoveToViewModel.cs
Normal file
@@ -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<RenameSubject> _subjects;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _pattern = "";
|
||||||
|
[ObservableProperty] private string? _selectedChoice;
|
||||||
|
[ObservableProperty] private string _status = "";
|
||||||
|
[ObservableProperty] private bool _canQueue;
|
||||||
|
|
||||||
|
public MoveToViewModel(
|
||||||
|
IReadOnlyList<RenameSubject> subjects,
|
||||||
|
FileOperationService ops,
|
||||||
|
IHydrationGuard hydration,
|
||||||
|
IFileSystemEnumerator enumerator,
|
||||||
|
UiPreferencesStore preferences)
|
||||||
|
{
|
||||||
|
_subjects = subjects;
|
||||||
|
_ops = ops;
|
||||||
|
_hydration = hydration;
|
||||||
|
_enumerator = enumerator;
|
||||||
|
_preferences = preferences;
|
||||||
|
Rows = [];
|
||||||
|
PatternChoices = new ObservableCollection<string>(
|
||||||
|
DestinationPatterns.Combine(preferences.Load().MoveToPatterns));
|
||||||
|
Pattern = DestinationPatterns.Normalize(preferences.Load().MoveToPatterns).FirstOrDefault() ?? "";
|
||||||
|
Rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<ProfilePreviewRow> Rows { get; }
|
||||||
|
public ObservableCollection<string> PatternChoices { get; }
|
||||||
|
public IReadOnlyList<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
324
src/Explorer.Presentation/ViewModels/TagRenameViewModel.cs
Normal file
324
src/Explorer.Presentation/ViewModels/TagRenameViewModel.cs
Normal file
@@ -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<RenameSubject> _subjects;
|
||||||
|
private readonly Dictionary<string, MediaTagFields> _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<RenameSubject> 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<string>(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<TagRowViewModel> Rows { get; }
|
||||||
|
public ObservableCollection<string> 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);
|
||||||
|
}
|
||||||
@@ -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;
|
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;
|
CanPause = job.Status is TransferStatus.Queued or TransferStatus.Running;
|
||||||
CanResume = job.Status == TransferStatus.Paused;
|
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;
|
CanRemove = job.Status is not TransferStatus.Cancelling;
|
||||||
CanMoveUp = canMoveUp && job.Status is not TransferStatus.Running and 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;
|
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);
|
return FileName(job.DestinationPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (job.Op == TransferOp.WriteTags)
|
||||||
|
{
|
||||||
|
return FileName(job.SourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
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.AddToArchive => $"Add to {FileName(job.DestinationPath)}",
|
||||||
TransferOp.VerifyArchive => "Verify archive",
|
TransferOp.VerifyArchive => "Verify archive",
|
||||||
TransferOp.Convert => $"Convert to {FileName(job.DestinationPath)}",
|
TransferOp.Convert => $"Convert to {FileName(job.DestinationPath)}",
|
||||||
|
TransferOp.WriteTags => "Write tags",
|
||||||
TransferOp.EmptyRecycleBin => "Empty Recycle Bin",
|
TransferOp.EmptyRecycleBin => "Empty Recycle Bin",
|
||||||
TransferOp.Delete => string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)
|
TransferOp.Delete => string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)
|
||||||
? "Delete permanently"
|
? "Delete permanently"
|
||||||
@@ -186,6 +192,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
|
|||||||
TransferOp.AddToArchive => "Adding",
|
TransferOp.AddToArchive => "Adding",
|
||||||
TransferOp.VerifyArchive => "Verifying",
|
TransferOp.VerifyArchive => "Verifying",
|
||||||
TransferOp.Convert => "Converting",
|
TransferOp.Convert => "Converting",
|
||||||
|
TransferOp.WriteTags => "Writing tags",
|
||||||
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
|
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
|
||||||
_ => "Working"
|
_ => "Working"
|
||||||
};
|
};
|
||||||
@@ -196,7 +203,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
|
|||||||
private readonly ITransferHost _queue;
|
private readonly ITransferHost _queue;
|
||||||
private readonly UiPreferencesStore _preferences;
|
private readonly UiPreferencesStore _preferences;
|
||||||
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
|
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
|
||||||
private readonly Dictionary<long, (long Bytes, DateTime Utc)> _speed = [];
|
private readonly Dictionary<long, (long Bytes, DateTime Utc, string? Text)> _speed = [];
|
||||||
|
|
||||||
private bool _holdCollapsed;
|
private bool _holdCollapsed;
|
||||||
|
|
||||||
@@ -426,14 +433,17 @@ public sealed partial class TransferQueueViewModel : ObservableObject
|
|||||||
if (seconds >= 0.4 && job.BytesDone >= prev.Bytes)
|
if (seconds >= 0.4 && job.BytesDone >= prev.Bytes)
|
||||||
{
|
{
|
||||||
var rate = (job.BytesDone - prev.Bytes) / seconds;
|
var rate = (job.BytesDone - prev.Bytes) / seconds;
|
||||||
_speed[job.Id] = (job.BytesDone, now);
|
var text = rate > 0
|
||||||
return rate > 0 ? $"{TransferJobViewModel.FormatBytes((long)rate)}/s" : null;
|
? $"{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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,6 +493,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
|
|||||||
TransferOp.AddToArchive => "Adding",
|
TransferOp.AddToArchive => "Adding",
|
||||||
TransferOp.VerifyArchive => "Verifying",
|
TransferOp.VerifyArchive => "Verifying",
|
||||||
TransferOp.Convert => "Converting",
|
TransferOp.Convert => "Converting",
|
||||||
|
TransferOp.WriteTags => "Writing tags",
|
||||||
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
|
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
|
||||||
_ => op.ToString()
|
_ => op.ToString()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="TagLibSharp" Version="2.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
||||||
|
|||||||
@@ -224,4 +224,35 @@ internal static partial class NativeMethods
|
|||||||
[LibraryImport("kernel32.dll")]
|
[LibraryImport("kernel32.dll")]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
public static partial bool GetSystemPowerStatus(out SystemPowerStatus lpSystemPowerStatus);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
172
src/Explorer.Windows/TagLibMediaTagService.cs
Normal file
172
src/Explorer.Windows/TagLibMediaTagService.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
private static bool IsRemote(string path)
|
||||||
{
|
{
|
||||||
if (PathRules.IsUnc(path))
|
if (PathRules.IsUnc(path))
|
||||||
|
|||||||
161
tests/Explorer.Application.Tests/DestinationPatternTests.cs
Normal file
161
tests/Explorer.Application.Tests/DestinationPatternTests.cs
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
106
tests/Explorer.Application.Tests/FilenamePatternTests.cs
Normal file
106
tests/Explorer.Application.Tests/FilenamePatternTests.cs
Normal file
@@ -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}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
23
tests/Explorer.Application.Tests/FilenamePatternsTests.cs
Normal file
23
tests/Explorer.Application.Tests/FilenamePatternsTests.cs
Normal file
@@ -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}", " "]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,9 +52,59 @@ public class RenamePlannerTests
|
|||||||
[File(@"C:\a\clip.mp4")],
|
[File(@"C:\a\clip.mp4")],
|
||||||
new RenameRuleSet { Prefix = "Clip_{Counter}_", UseCounter = true, CounterPadding = 3 });
|
new RenameRuleSet { Prefix = "Clip_{Counter}_", UseCounter = true, CounterPadding = 3 });
|
||||||
Assert.Equal("Clip_001_clip.mp4", plan.Preview[0].NewName);
|
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")],
|
[File(@"C:\a\clip.mp4")],
|
||||||
new RenameRuleSet { Prefix = "{Width}_" }).Preview[0].NewName);
|
new RenameRuleSet { Prefix = "{Width}_", NamePattern = "{Artist} - {Title}" },
|
||||||
|
tagsByPath: new Dictionary<string, MediaTagFields>(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<string, MediaTagFields>(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<string, MediaTagFields>(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]
|
[Fact]
|
||||||
|
|||||||
36
tests/Explorer.Application.Tests/TagRenamePlannerTests.cs
Normal file
36
tests/Explorer.Application.Tests/TagRenamePlannerTests.cs
Normal file
@@ -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<string, MediaTagFields>(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<string, MediaTagFields>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[@"C:\music\Pink Floyd - Time.mp3"] = existing
|
||||||
|
});
|
||||||
|
Assert.False(plan.CanEnqueue);
|
||||||
|
Assert.True(plan.TagPreview[0].TagsUnchanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,8 @@ public class UiPreferencesStoreTests
|
|||||||
Assert.Null(prefs.FfmpegPath);
|
Assert.Null(prefs.FfmpegPath);
|
||||||
Assert.True(prefs.SessionTabs is null || prefs.SessionTabs.Count == 0);
|
Assert.True(prefs.SessionTabs is null || prefs.SessionTabs.Count == 0);
|
||||||
Assert.True(prefs.FavoriteFolders is null || prefs.FavoriteFolders.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]
|
[Fact]
|
||||||
@@ -167,6 +169,31 @@ public class UiPreferencesStoreTests
|
|||||||
Assert.Equal(@"C:\Users\Dominique\Documents", prefs.FavoriteFolders[1]);
|
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]
|
[Fact]
|
||||||
public void Session_tab_roundtrip_escapes_semicolons_in_paths()
|
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)
|
new SessionTabState(@"C:\Temp", @"D:\", true, 0.6, true)
|
||||||
],
|
],
|
||||||
SessionActiveTab: 0,
|
SessionActiveTab: 0,
|
||||||
PreferFavoritesInTree: true));
|
PreferFavoritesInTree: true,
|
||||||
|
NamePatterns: ["{Album} - {Title}"],
|
||||||
|
MoveToPatterns: [@"\\10.0.0.31\media\movies\%filename_noext%"]));
|
||||||
var loaded = store.Load();
|
var loaded = store.Load();
|
||||||
Assert.Equal("Light", loaded.Theme);
|
Assert.Equal("Light", loaded.Theme);
|
||||||
Assert.True(loaded.GroupNetworkPlaces);
|
Assert.True(loaded.GroupNetworkPlaces);
|
||||||
@@ -218,6 +247,8 @@ public class UiPreferencesStoreTests
|
|||||||
Assert.Equal(720, loaded.WindowHeight);
|
Assert.Equal(720, loaded.WindowHeight);
|
||||||
Assert.Equal(300, loaded.TreeWidth);
|
Assert.Equal(300, loaded.TreeWidth);
|
||||||
Assert.Equal([@"D:\Photos", @"C:\Users\Dominique\Documents"], loaded.FavoriteFolders);
|
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);
|
Assert.NotNull(loaded.SessionTabs);
|
||||||
var tab = Assert.Single(loaded.SessionTabs);
|
var tab = Assert.Single(loaded.SessionTabs);
|
||||||
Assert.Equal(@"C:\Temp", tab.LeftPath);
|
Assert.Equal(@"C:\Temp", tab.LeftPath);
|
||||||
|
|||||||
@@ -549,3 +549,17 @@ public class GitStatusBadgeTests
|
|||||||
Assert.Equal("merging", status.OperationLabel);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -214,6 +214,22 @@ public class TransferQueueTests
|
|||||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
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]
|
[Fact]
|
||||||
public async Task Retry_reruns_a_failed_job()
|
public async Task Retry_reruns_a_failed_job()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user