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:
2026-08-26 18:29:17 +02:00
parent 66ea25993f
commit ff070beba4
62 changed files with 3172 additions and 92 deletions

View File

@@ -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

View File

@@ -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
--- ---

View File

@@ -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}"

View File

@@ -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/>

View File

@@ -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);

View 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>

View 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;
}
}

View File

@@ -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"/>

View File

@@ -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",

View File

@@ -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"/>

View File

@@ -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"/>

View File

@@ -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",

View File

@@ -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"/>

View File

@@ -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}"

View File

@@ -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"/>

View 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;
}

View File

@@ -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>

View File

@@ -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)

View 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>

View 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();
};
}
}

View 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;
}
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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);
}

View 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);
}
}

View File

@@ -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))

View 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);
}

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -99,7 +99,8 @@ public enum TransferOp
Compress, Compress,
AddToArchive, AddToArchive,
VerifyArchive, VerifyArchive,
Convert Convert,
WriteTags
} }
public enum ArchiveFormat public enum ArchiveFormat

View 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);

View File

@@ -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;

View File

@@ -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

View File

@@ -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('.');

View File

@@ -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('.');

View File

@@ -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

View File

@@ -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))

View File

@@ -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)
{ {

View File

@@ -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;

View File

@@ -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>());

View File

@@ -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);
} }

View File

@@ -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>());

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);
} }

View 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;
}
}
}

View 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);
}

View File

@@ -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()
}; };

View File

@@ -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" />

View File

@@ -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);
} }

View 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);
}
}
}

View File

@@ -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))

View 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));
}
}

View 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}"));
}
}

View 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}", " "]));
}
}

View File

@@ -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]

View 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);
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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()
{ {