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] Case conversion
- [x] Extension handling
- [ ] Metadata placeholders
- [x] Metadata placeholders
Potential placeholders:
- `{CreatedDate}`
- `{TakenDate}`
- `{ModifiedDate}`
- `{Counter}`
- `{Width}`
- `{Height}`
- `{Artist}`
- `{Title}`
- `{Project}`
- `{Project}` (Git repo folder, else parent)
- `{Extension}`
## Workflow
@@ -318,6 +319,25 @@ Potential placeholders:
- [x] Add Rename operations to File Operations Queue
- [x] Store `OldPath -> NewPath`
- [x] Support Undo for completed rename batches
- [x] Tags from filename
- [x] Filename from tags
- [x] Tag editor (Artist, Title, Album, Track, Year, Genre)
- [x] Queue tag writes
- [x] Saved name patterns
- [x] EXIF Date Taken on photos
- [x] `{Project}` from Git repo / parent folder
---
# Move to
Patterned move with per-file destination folders (Plex-style movie folders, dated backups, and similar).
- [x] Destination pattern popup
- [x] `%filename%` `%filename_noext%` `%ext%` `%year%` `%month%` `%parent%` `%source_drive%`
- [x] Create missing destination folders
- [x] History / saved patterns
- [x] Preview and queue through File Operations Queue
---
@@ -440,7 +460,7 @@ Goal: Understand what files and folders represent rather than relying only on ex
- [ ] MIME/content signature
- [x] Folder structure
- [x] Git metadata
- [ ] Media metadata
- [x] Media metadata
- [x] Known application structures (`node_modules`, `bin`, `obj`, `.vs`)
- [x] File age (old installers flagged in preview)
- [ ] File relationships

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\logs\` | Rolling logs |
| `%LocalAppData%\ExplorerWorkbench\ui-preferences.txt` | Theme, layout, tool paths, organize destinations, favorite folders |
| `%LocalAppData%\ExplorerWorkbench\ui-preferences.txt` | Theme, layout, tool paths, organize destinations, favorite folders, saved name patterns, Move to history |
---
@@ -226,9 +226,34 @@ Auto-clear when done is a setting. Failed items stay until you dismiss or retry
Workflow: configure → preview → validate → queue.
Rules: search/replace, regex, prefix, suffix, counter (with padding), case, extension. Collisions and illegal Windows names are caught before enqueue. Completed batches can be undone (**Tools → File Operations → Undo last rename batch**).
Rules: search/replace, regex, prefix, suffix, counter (with padding), case, extension. An optional **name pattern** can replace the current name using placeholders: `{Artist}`, `{Title}`, `{Album}`, `{Track}`, `{Year}`, `{Genre}`, `{CreatedDate}`, `{TakenDate}`, `{ModifiedDate}`, `{Width}`, `{Height}`, `{Name}`, `{Extension}`, `{Parent}`, `{Project}`, `{Counter}`. Dates accept a format (`{TakenDate:yyyyMMdd}`). `{CreatedDate}` uses EXIF Date Taken when that was already read; `{TakenDate}` is the token that reads photo metadata. `{Project}` is the Git repository folder, or the parent folder if the file is not in a repo. **Save pattern** stores a custom pattern in `ui-preferences.txt` (built-in patterns stay in the list: `{Artist} - {Title}`, `{Track:00} - {Title}`, `{TakenDate}_{Name}`, `{CreatedDate}_{Name}`, `{Project}_{Name}`). Collisions and illegal Windows names are caught before enqueue. Completed batches can be undone (**Tools → File Operations → Undo last rename batch**).
Metadata placeholders such as `{CreatedDate}` or `{Width}` are not implemented yet. `{Counter}` and `{Extension}` work.
**Tags…** (select files first) is the Tag&Rename-style editor. **Filename → tags** fills Artist/Title/… from the current names using the same pattern. **Tags → filename** builds new names from tags. **Queue tags** writes ID3 on audio and EXIF (title, comment, creator, date taken) on photos through the File Operations Queue; **Queue rename** queues the new names. Online-only cloud files are skipped so they are not downloaded.
---
## Move to
**Tools → File Operations → Move to…** or **Move to…** on the item context menu (select items first).
Not a plain “move into this folder”. The destination is a **pattern**. Placeholders expand per file, missing folders are created, then the move is queued.
| Token | Meaning |
| --- | --- |
| `%filename%` | Name including extension |
| `%filename_noext%` | Name without extension |
| `%ext%` | Extension without the dot |
| `%year%` / `%month%` | Last-write time (`2024` / `08`) |
| `%parent%` | Parent folder name |
| `%source_drive%` | Drive (`D:`) or UNC share (`\\10.0.0.31\media`) |
`{filename_noext}` and the other `{…}` forms work the same.
Plex-style movies: `\\10.0.0.31\media\movies\%filename_noext%` creates `movies\Inception\` and places `Inception.mkv` inside it. If the last segment is `%filename%` or `%ext%`, the pattern is the full destination file path.
Type the path in the text box (or **Browse…**), then click a token to insert it at the caret. **Recent** lists saved patterns; the `\\host\share\…` row is only an example and is rejected if you queue it. **Queue** and **Save** store real patterns in `ui-preferences.txt` (`move-to=`).
If a UNC share or mapped drive is disconnected, Workbench tries to reconnect it (same credentials, no extra prompt). Jobs that still cannot reach the destination wait in the queue; **Retry** re-probes and reconnects. Online-only cloud files are skipped.
---
@@ -387,7 +412,6 @@ Left open on purpose:
- Scheduled profiles and folder-watcher triggers
- MIME/EXIF classification
- Duplicate “backup copy” auto-tagging
- Rename placeholders from EXIF or dates
---

View File

@@ -68,7 +68,14 @@
<TextBlock Text="Extension" FontWeight="SemiBold" Margin="0,0,0,8"/>
<CheckBox Content="Change extension" IsChecked="{Binding ChangeExtension}" Margin="0,0,0,8"/>
<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>
</ScrollViewer>
<ListView Grid.Column="2" ItemsSource="{Binding Rows}"

View File

@@ -34,6 +34,10 @@
<MenuItem Header="Rename" Click="OnCtxRename"/>
<MenuItem Header="Batch rename…" Click="OnBatchRename"
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"
IsEnabled="{Binding ShowRunProfile}">
<MenuItem Header="No profiles yet" IsEnabled="False"/>
@@ -149,6 +153,10 @@
</MenuItem>
<MenuItem Header="_File Operations">
<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}"
IsEnabled="{Binding CanUndoRenameBatch}"/>
<Separator/>

View File

@@ -22,6 +22,10 @@ public partial class MainWindow : Window
private bool _dragPending;
private MouseButton _dragButton;
private FolderItemViewModel? _dragItem;
private ListView? _dragList;
private FolderItemViewModel[] _dragSelection = [];
private bool _dragFromMultiSelect;
private bool _syncingSelection;
private readonly ListMarquee _marquee = new();
private bool _suppressItemContextMenu;
private bool _incomingRightDrag;
@@ -577,12 +581,17 @@ public partial class MainWindow : Window
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not ListView { IsVisible: true } list)
if (_syncingSelection || sender is not ListView { IsVisible: true } list)
{
return;
}
ActivatePaneFromList(list);
if (_dragFromMultiSelect && list == _dragList && _dragSelection.Length > 1)
{
RestoreListSelection(list, _dragSelection);
}
if (_clickRenameItem is not null && !list.SelectedItems.Contains(_clickRenameItem))
{
CancelClickRename();
@@ -597,6 +606,26 @@ public partial class MainWindow : Window
Vm.RefreshCloudActions();
}
private void RestoreListSelection(ListView list, IReadOnlyList<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)
{
if (sender is ListView list)
@@ -620,11 +649,35 @@ public partial class MainWindow : Window
private void OnListMouseDown(object sender, MouseButtonEventArgs e)
{
if (IsInsideInlineRenameBox(e.OriginalSource as DependencyObject))
{
_dragPending = false;
_marquee.Disarm();
CancelClickRename();
return;
}
_suppressItemContextMenu = false;
_dragStart = e.GetPosition(null);
_dragPending = true;
_dragButton = e.ChangedButton;
_dragList = sender as ListView;
_dragItem = HitTestFolderItem(sender as DependencyObject, e.GetPosition((IInputElement)sender));
_dragSelection = _dragList is not null
? _dragList.SelectedItems.OfType<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
&& _dragItem is null
&& ListMarquee.IsBackground(e.OriginalSource as DependencyObject))
@@ -800,6 +853,13 @@ public partial class MainWindow : Window
return;
}
if (IsInsideInlineRenameBox(e.OriginalSource as DependencyObject)
|| _dragItem is { IsRenaming: true })
{
_dragPending = false;
return;
}
var pos = e.GetPosition(null);
if (Math.Abs(pos.X - _dragStart.X) < SystemParameters.MinimumHorizontalDragDistance
&& Math.Abs(pos.Y - _dragStart.Y) < SystemParameters.MinimumVerticalDragDistance)
@@ -809,18 +869,13 @@ public partial class MainWindow : Window
_dragPending = false;
CancelClickRename();
var selected = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
IReadOnlyList<string> paths;
if (_dragItem is not null && (selected.Count == 0
|| selected.TrueForAll(p => !p.Equals(_dragItem.FullPath, StringComparison.OrdinalIgnoreCase))))
if (_dragList is { IsMouseCaptured: true })
{
paths = [_dragItem.FullPath];
}
else
{
paths = selected;
_dragList.ReleaseMouseCapture();
}
var paths = DragPaths();
_dragFromMultiSelect = false;
if (paths.Count == 0)
{
return;
@@ -878,11 +933,42 @@ public partial class MainWindow : Window
}
base.OnPreviewMouseUp(e);
if (e.ChangedButton == _dragButton)
if (e.ChangedButton != _dragButton)
{
_dragPending = false;
_marquee.Disarm();
return;
}
if (_dragFromMultiSelect && _dragPending && _dragList is not null && _dragItem is not null)
{
_dragFromMultiSelect = false;
_dragList.SelectedItems.Clear();
_dragList.SelectedItem = _dragItem;
}
_dragPending = false;
_dragFromMultiSelect = false;
_marquee.Disarm();
if (_dragList is { IsMouseCaptured: true })
{
_dragList.ReleaseMouseCapture();
}
}
private IReadOnlyList<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)
@@ -1293,6 +1379,36 @@ public partial class MainWindow : Window
}
}
private void OnTags(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateTagRenameViewModel();
if (vm is null)
{
return;
}
var dlg = new TagRenameWindow(vm) { Owner = this };
if (dlg.ShowDialog() == true)
{
Vm.Footer = "Tag or rename work queued.";
}
}
private void OnMoveTo(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateMoveToViewModel();
if (vm is null)
{
return;
}
var dlg = new MoveToWindow(vm) { Owner = this };
if (dlg.ShowDialog() == true)
{
Vm.Footer = "Move queued.";
}
}
private async void OnExtractHere(object sender, RoutedEventArgs e)
=> await Vm.ExtractSelectedAsync(null).ConfigureAwait(true);

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"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StackPanel>
<TextBlock Text="Advanced" Style="{StaticResource SettingsHeading}"
AutomationProperties.HeadingLevel="Level1"/>

View File

@@ -10,7 +10,7 @@ public partial class AdvancedSettingsPage : UserControl
private void OnBrowseGit(object sender, RoutedEventArgs e)
{
if (DataContext is SettingsDraft draft
if (SettingsPageContext.DraftOf(this) is { } draft
&& SettingsPathBrowse.TryPick(
Window.GetWindow(this),
"Git executable",

View File

@@ -1,7 +1,6 @@
<UserControl x:Class="Explorer.App.Settings.Pages.AppearanceSettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StackPanel>
<TextBlock Text="Appearance" Style="{StaticResource SettingsHeading}"
AutomationProperties.HeadingLevel="Level1"/>

View File

@@ -1,7 +1,6 @@
<UserControl x:Class="Explorer.App.Settings.Pages.FileOperationsSettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StackPanel>
<TextBlock Text="File Operations" Style="{StaticResource SettingsHeading}"
AutomationProperties.HeadingLevel="Level1"/>

View File

@@ -10,7 +10,7 @@ public partial class FileOperationsSettingsPage : UserControl
private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
{
if (DataContext is SettingsDraft draft
if (SettingsPageContext.DraftOf(this) is { } draft
&& SettingsPathBrowse.TryPick(
Window.GetWindow(this),
"7-Zip executable",
@@ -24,7 +24,7 @@ public partial class FileOperationsSettingsPage : UserControl
private void OnBrowseFfmpeg(object sender, RoutedEventArgs e)
{
if (DataContext is SettingsDraft draft
if (SettingsPageContext.DraftOf(this) is { } draft
&& SettingsPathBrowse.TryPick(
Window.GetWindow(this),
"FFmpeg executable",

View File

@@ -1,7 +1,6 @@
<UserControl x:Class="Explorer.App.Settings.Pages.GeneralSettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StackPanel>
<TextBlock Text="General" Style="{StaticResource SettingsHeading}"
AutomationProperties.HeadingLevel="Level1"/>

View File

@@ -1,7 +1,7 @@
<UserControl x:Class="Explorer.App.Settings.Pages.IndexingSettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
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>
<TextBlock Text="Indexing" Style="{StaticResource SettingsHeading}"
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."/>
<TextBlock Text="Idle for" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8"
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"/>
<DockPanel LastChildFill="False" Margin="0,0,0,14">
<ComboBox Width="80" HorizontalAlignment="Left"
ItemsSource="{Binding IdleThresholdChoices}"
SelectedItem="{Binding IdleMaintenanceMinutes}"
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
<ComboBox MinWidth="88" Width="88"
SelectedItem="{Binding IdleMaintenanceMinutes, Mode=TwoWay}"
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"
Foreground="{DynamicResource FgMuted}"
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"/>
</DockPanel>
</StackPanel>
<CheckBox Margin="0,0,0,6"
Content="Only run expensive maintenance on AC power"
IsChecked="{Binding IdleMaintenanceAcOnly}"

View File

@@ -1,7 +1,6 @@
<UserControl x:Class="Explorer.App.Settings.Pages.NavigationSettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StackPanel>
<TextBlock Text="Navigation" Style="{StaticResource SettingsHeading}"
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">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
KeyboardNavigation.TabIndex="1">
<ContentControl Content="{Binding SelectedCategory.Page}"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Top"/>
<Grid x:Name="PageHost" HorizontalAlignment="Stretch" VerticalAlignment="Top"/>
</ScrollViewer>
</Border>
</Grid>

View File

@@ -12,16 +12,46 @@ public partial class SettingsWindow : Window
private readonly SettingsDraft _draft;
private readonly string _originalTheme;
public SettingsDraft Draft => _draft;
public SettingsWindow(MainViewModel vm)
{
InitializeComponent();
_vm = vm;
var prefs = vm.CurrentPreferences();
_originalTheme = prefs.Theme;
_draft = SettingsDraft.From(prefs);
_draft.PropertyChanged += OnDraftChanged;
DataContext = new SettingsShell(_draft, SettingsCatalog.Create());
Closed += (_, _) => _draft.PropertyChanged -= OnDraftChanged;
var shell = new SettingsShell(_draft, SettingsCatalog.Create());
DataContext = shell;
InitializeComponent();
shell.PropertyChanged += OnShellChanged;
ShowSelectedPage();
Closed += (_, _) =>
{
_draft.PropertyChanged -= OnDraftChanged;
shell.PropertyChanged -= OnShellChanged;
};
}
private void OnShellChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(SettingsShell.SelectedCategory) or null)
{
ShowSelectedPage();
}
}
private void ShowSelectedPage()
{
PageHost.Children.Clear();
if (DataContext is not SettingsShell shell)
{
return;
}
var page = shell.SelectedCategory.Page;
PageHost.Children.Add(page);
page.DataContext = _draft;
}
private void OnDraftChanged(object? sender, PropertyChangedEventArgs e)

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(
IReadOnlyList<RenameSubject> subjects,
RenameRuleSet rules,
Func<string, bool>? pathExists = null)
Func<string, bool>? pathExists = null,
IReadOnlyDictionary<string, MediaTagFields>? tagsByPath = null)
{
var issues = new List<PlanIssue>();
Regex? regex = null;
@@ -42,7 +43,21 @@ public sealed class RenamePlanner
string newName;
try
{
newName = Apply(subject.Name, rules, index, regex);
var tags = tagsByPath is not null && tagsByPath.TryGetValue(subject.FullPath, out var found)
? found
: FilenamePattern.FromFile(subject.FullPath, subject.IsDirectory);
if (NeedsMedia(rules) && tags.HydrationBlocked)
{
issues.Add(new PlanIssue(
PlanIssueSeverity.Error,
"This file is online-only. Reading tags would download it.",
subject.FullPath));
rows.Add((subject, subject.Name, subject.FullPath, true, "Online-only"));
index++;
continue;
}
newName = Apply(subject.Name, rules, index, regex, tags);
}
catch (RegexMatchTimeoutException)
{
@@ -186,27 +201,38 @@ public sealed class RenamePlanner
return new OperationPlan { Operations = operations };
}
internal static string Apply(string name, RenameRuleSet rules, int index, Regex? regex)
internal static string Apply(string name, RenameRuleSet rules, int index, Regex? regex, MediaTagFields? tags = null)
{
var (stem, extension) = WindowsFileNames.Split(name);
var text = rules.IncludeExtensionInSearch ? name : stem;
text = Replace(text, rules, regex);
text = (rules.Prefix ?? "") + text + (rules.Suffix ?? "");
tags ??= new MediaTagFields { Stem = stem, Extension = extension };
var counter = rules.UseCounter
? WindowsFileNames.FormatCounter(rules.CounterStart + index * Math.Max(1, rules.CounterStep), rules.CounterPadding)
: "";
string text;
if (!string.IsNullOrWhiteSpace(rules.NamePattern))
{
text = FilenamePattern.Expand(rules.NamePattern, tags, counter);
}
else
{
text = rules.IncludeExtensionInSearch ? name : stem;
text = Replace(text, rules, regex);
}
text = (rules.Prefix ?? "") + text + (rules.Suffix ?? "");
if (text.Contains("{Counter}", StringComparison.OrdinalIgnoreCase))
{
text = Regex.Replace(text, "\\{Counter\\}", counter, RegexOptions.IgnoreCase);
}
else if (rules.UseCounter)
else if (rules.UseCounter && string.IsNullOrWhiteSpace(rules.NamePattern))
{
text += counter;
}
text = FilenamePattern.Expand(text, tags, counter);
text = WindowsFileNames.ApplyCase(text, rules.CaseMode);
text = text.Replace("{Extension}", extension, StringComparison.OrdinalIgnoreCase);
if (rules.IncludeExtensionInSearch && !rules.ChangeExtension)
if (rules.IncludeExtensionInSearch && !rules.ChangeExtension && string.IsNullOrWhiteSpace(rules.NamePattern))
{
return text;
}
@@ -215,6 +241,13 @@ public sealed class RenamePlanner
return WindowsFileNames.Join(text, newExt);
}
private static bool NeedsMedia(RenameRuleSet rules)
=> FilenamePattern.UsesMediaContent(rules.NamePattern)
|| FilenamePattern.UsesMediaContent(rules.Prefix)
|| FilenamePattern.UsesMediaContent(rules.Suffix)
|| FilenamePattern.UsesMediaContent(rules.Replace)
|| FilenamePattern.UsesMediaContent(rules.Search);
private static string Replace(string text, RenameRuleSet rules, Regex? regex)
{
if (string.IsNullOrEmpty(rules.Search))

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 BackgroundMaintenanceWhenIdle = true,
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);
}
@@ -99,6 +101,8 @@ public sealed class UiPreferencesStore
.. OrganizeLines(preferences),
.. LayoutLines(preferences),
.. FavoriteLines(preferences),
.. NamePatternLines(preferences),
.. MoveToPatternLines(preferences),
.. SessionLines(preferences)
]);
}
@@ -142,6 +146,8 @@ public sealed class UiPreferencesStore
var sessionTabs = new List<SessionTabState>();
var sessionActiveTab = 0;
var favorites = new List<string>();
var namePatterns = new List<string>();
var moveToPatterns = new List<string>();
foreach (var raw in lines)
{
var line = raw.Trim();
@@ -280,6 +286,16 @@ public sealed class UiPreferencesStore
{
favorites.Add(value);
}
else if (key.Equals("name-pattern", StringComparison.OrdinalIgnoreCase)
&& namePatterns.Count < FilenamePatterns.MaxCount)
{
namePatterns.Add(value);
}
else if (key.Equals("move-to", StringComparison.OrdinalIgnoreCase)
&& moveToPatterns.Count < DestinationPatterns.MaxCount)
{
moveToPatterns.Add(value);
}
else if (key.Equals("session-active-tab", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var activeTab)
&& activeTab >= 0)
@@ -305,7 +321,9 @@ public sealed class UiPreferencesStore
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon,
FavoriteFolders.Normalize(favorites), sessionTabs, sessionActiveTab, preferFavoritesInTree,
backgroundMaintenanceWhenIdle, idleMaintenanceMinutes, idleMaintenanceAcOnly);
backgroundMaintenanceWhenIdle, idleMaintenanceMinutes, idleMaintenanceAcOnly,
FilenamePatterns.Normalize(namePatterns),
DestinationPatterns.Normalize(moveToPatterns));
}
private static IEnumerable<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)
{
foreach (var path in FavoriteFolders.Normalize(preferences.FavoriteFolders))

View File

@@ -57,6 +57,10 @@ public interface ITransferHost
=> Task.CompletedTask;
Task EnqueueConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueWriteTagsAsync(string path, string payload, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueMoveToAsync(string source, string destinationPath, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
public interface ISourceHost

View File

@@ -23,6 +23,7 @@ public interface IVolumeService
VolumeFingerprint? Probe(string path);
VolumeSpace GetSpace(string path);
bool IsPathReachable(string path);
bool TryEnsureReachable(string path) => IsPathReachable(path);
}
public interface IFileSystemEnumerator

View File

@@ -99,7 +99,8 @@ public enum TransferOp
Compress,
AddToArchive,
VerifyArchive,
Convert
Convert,
WriteTags
}
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<ProfilePreviewRow> ProfilePreview { 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 CanEnqueue => !HasErrors && Operations.Count > 0;

View File

@@ -26,6 +26,7 @@ public sealed class RenameRuleSet
public RenameCaseMode CaseMode { get; init; }
public bool ChangeExtension { get; init; }
public string NewExtension { get; init; } = "";
public string NamePattern { get; init; } = "";
}
public sealed class RenameBatch

View File

@@ -61,6 +61,27 @@ public static class WindowsFileNames
return true;
}
public static string SanitizeForFileName(string value)
{
if (string.IsNullOrEmpty(value))
{
return "";
}
var chars = value.Trim().ToCharArray();
for (var i = 0; i < chars.Length; i++)
{
var c = chars[i];
if (c is '<' or '>' or ':' or '"' or '/' or '\\' or '|' or '?' or '*' || c < 32)
{
chars[i] = '-';
}
}
var text = new string(chars).Trim().TrimEnd('.');
return text.Length == 0 ? "" : text;
}
public static (string Stem, string Extension) Split(string name)
{
var dot = name.LastIndexOf('.');

View File

@@ -31,6 +31,14 @@ public sealed class FileOperationService
public Task MoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> _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)
=> _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)
{
extension = extension.Trim().TrimStart('.');

View File

@@ -11,25 +11,29 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
private readonly IArchiveExecutor? _archives;
private readonly IHydrationGuard? _hydration;
private readonly IMediaConversionProvider? _conversion;
private readonly IMediaTagService? _tags;
public NativeFileOperationExecutor(
IShellFileOperations shell,
IFileSystemEnumerator enumerator,
IArchiveExecutor? archives = null,
IHydrationGuard? hydration = null,
IMediaConversionProvider? conversion = null)
IMediaConversionProvider? conversion = null,
IMediaTagService? tags = null)
{
_shell = shell;
_enumerator = enumerator;
_archives = archives;
_hydration = hydration;
_conversion = conversion;
_tags = tags;
}
public bool CanExecute(TransferOp op)
=> op is TransferOp.Copy or TransferOp.Move or TransferOp.Delete or TransferOp.Rename
or TransferOp.EmptyRecycleBin or TransferOp.Extract or TransferOp.Compress
or TransferOp.AddToArchive or TransferOp.VerifyArchive or TransferOp.Convert;
or TransferOp.AddToArchive or TransferOp.VerifyArchive or TransferOp.Convert
or TransferOp.WriteTags;
public async Task ExecuteAsync(TransferJob job, Func<bool> pauseRequested, Action? reportProgress, CancellationToken cancellationToken)
{
@@ -59,6 +63,9 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
case TransferOp.Convert:
await ConvertAsync(job, reportProgress, cancellationToken).ConfigureAwait(false);
break;
case TransferOp.WriteTags:
await WriteTagsAsync(job, cancellationToken).ConfigureAwait(false);
break;
default:
job.Status = TransferStatus.Failed;
job.Error = $"Unsupported operation {job.Op}";
@@ -96,6 +103,11 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
job.FilesTotal = Math.Max(job.FilesTotal, 1);
job.CurrentPath = src;
if (!EnsureParent(dst, job))
{
return;
}
var resume = File.Exists(PathRules.ToExtended(dst));
if (!TransferFile(src, dst, move, resume, job, committed: 0, pauseRequested, reportProgress, stoppingToken, out var error))
{
@@ -109,6 +121,28 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
await Task.CompletedTask.ConfigureAwait(false);
}
private static bool EnsureParent(string destination, TransferJob job)
{
try
{
var parent = PathRules.Parent(destination);
if (string.IsNullOrWhiteSpace(parent)
|| parent.Equals(destination, StringComparison.OrdinalIgnoreCase))
{
return true;
}
Directory.CreateDirectory(PathRules.ToExtended(parent));
return true;
}
catch (Exception ex)
{
job.Status = TransferStatus.Failed;
job.Error = ex.Message;
return false;
}
}
private async Task CopyDirectory(
string src,
string dst,
@@ -462,6 +496,36 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
}
}
private async Task WriteTagsAsync(TransferJob job, CancellationToken cancellationToken)
{
if (_tags is null)
{
job.Status = TransferStatus.Failed;
job.Error = "Tag editing is not available.";
return;
}
if (await WouldHydrateAsync(job.SourcePath, cancellationToken).ConfigureAwait(false))
{
FailHydration(job);
return;
}
job.FilesTotal = Math.Max(job.FilesTotal, 1);
job.CurrentPath = job.SourcePath;
var patch = MediaTagFields.FromPayload(job.DestinationPath);
if (!_tags.TryWrite(job.SourcePath, patch, out var error))
{
job.Status = TransferStatus.Failed;
job.Error = string.IsNullOrWhiteSpace(error) ? FileOperationErrors.FileInUse : error;
return;
}
job.FilesDone = 1;
job.CurrentPath = null;
await Task.CompletedTask.ConfigureAwait(false);
}
private static ConversionKind ConversionKindOf(TransferJob job)
{
if (job.AdditionalSources.Count == 1

View File

@@ -51,6 +51,9 @@ internal static class OperationAvailability
yield return PathRules.Parent(job.DestinationPath);
}
yield break;
case TransferOp.WriteTags:
yield return job.SourcePath;
yield break;
case TransferOp.VerifyArchive:
yield return job.SourcePath;
@@ -60,6 +63,14 @@ internal static class OperationAvailability
}
}
public static void TryPrepare(IVolumeService volumes, TransferJob job)
{
foreach (var path in PathsToCheck(job))
{
volumes.TryEnsureReachable(path);
}
}
public static bool IsVolumeReachable(IVolumeService volumes, string path)
{
if (string.IsNullOrWhiteSpace(path))

View File

@@ -26,8 +26,11 @@ public sealed class RenameBatchService
return File.Exists(ext) || Directory.Exists(ext);
}
public OperationPlan Preview(IReadOnlyList<RenameSubject> subjects, RenameRuleSet rules)
=> _planner.Build(subjects, rules, PathExists);
public OperationPlan Preview(
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)
{

View File

@@ -80,16 +80,19 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
foreach (var src in sources)
{
var dest = Path.Combine(destinationDirectory, PathRules.GetFileName(src));
await EnqueueAsync(new TransferJob
await EnqueueMoveToAsync(src, dest, cancellationToken).ConfigureAwait(false);
}
}
public Task EnqueueMoveToAsync(string source, string destinationPath, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.Move,
SourcePath = src,
DestinationPath = dest,
SourcePath = source,
DestinationPath = destinationPath,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken).ConfigureAwait(false);
}
}
}, cancellationToken);
public async Task EnqueueDeleteAsync(IReadOnlyList<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
{
@@ -178,6 +181,16 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
AdditionalSources = [kind.ToString()]
}, cancellationToken);
public Task EnqueueWriteTagsAsync(string path, string payload, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.WriteTags,
SourcePath = path,
DestinationPath = payload,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken);
public void PauseAll()
{
_queuePaused = true;
@@ -256,18 +269,38 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
public void Retry(long jobId)
{
TransferJob? job;
lock (_gate)
{
var job = Find(jobId);
if (job is null || job.Status != TransferStatus.Failed)
job = Find(jobId);
if (job is null || job.Status is not (TransferStatus.Failed or TransferStatus.Waiting))
{
return;
}
}
OperationAvailability.TryPrepare(_volumes, job);
lock (_gate)
{
if (job.Status is not (TransferStatus.Failed or TransferStatus.Waiting))
{
return;
}
job.RetryCount++;
job.Status = TransferStatus.Queued;
job.Error = null;
if (OperationAvailability.IsReady(_volumes, job))
{
job.Status = TransferStatus.Queued;
job.WaitReason = null;
}
else
{
job.Status = TransferStatus.Waiting;
job.WaitReason = FileOperationErrors.DestinationUnavailable;
}
QueuePersist(job);
}
@@ -392,12 +425,23 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
public void NotifyAvailability()
{
List<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;
lock (_gate)
{
foreach (var job in _jobs.Where(j => j.Status == TransferStatus.Waiting))
foreach (var job in waiting)
{
if (!OperationAvailability.IsReady(_volumes, job))
if (job.Status != TransferStatus.Waiting || !OperationAvailability.IsReady(_volumes, job))
{
continue;
}
@@ -418,6 +462,11 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
private async Task EnqueueAsync(TransferJob job, CancellationToken cancellationToken)
{
if (!OperationAvailability.IsReady(_volumes, job))
{
OperationAvailability.TryPrepare(_volumes, job);
}
if (!OperationAvailability.IsReady(_volumes, job))
{
job.Status = TransferStatus.Waiting;
@@ -462,6 +511,11 @@ public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroun
continue;
}
if (!OperationAvailability.IsReady(_volumes, job))
{
OperationAvailability.TryPrepare(_volumes, job);
}
if (!OperationAvailability.IsReady(_volumes, job))
{
job.Status = TransferStatus.Waiting;

View File

@@ -57,6 +57,7 @@ public static class ExplorerHostClientServices
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
services.AddSingleton<IMediaConversionProvider, FfmpegConversionExecutor>();
services.AddSingleton<IMediaTagService, TagLibMediaTagService>();
services.AddSingleton<WindowsGitStatusProvider>();
services.AddSingleton<IGitStatusProvider>(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());
public Task EnqueueMoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueMove", cancellationToken, dest: destinationDirectory, paths: sources.ToArray());
public Task EnqueueMoveToAsync(string source, string destinationPath, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueMoveTo", cancellationToken, s: source, dest: destinationPath);
public Task EnqueueDeleteAsync(IReadOnlyList<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueDelete", cancellationToken, paths: paths.ToArray(), flag: permanent);
public Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default)
@@ -520,6 +522,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
=> _client.CallAsync("Transfers.EnqueueVerifyArchive", cancellationToken, s: archivePath);
public Task EnqueueConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueConvert", cancellationToken, s: kind.ToString(), dest: destinationPath, paths: [sourcePath]);
public Task EnqueueWriteTagsAsync(string path, string payload, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueWriteTags", cancellationToken, s: path, dest: payload);
public void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
public void RaiseFinished(TransferJob job) => JobFinished?.Invoke(this, job);
}

View File

@@ -51,6 +51,7 @@ public static class ExplorerHostServices
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
services.AddSingleton<IMediaConversionProvider, FfmpegConversionExecutor>();
services.AddSingleton<IMediaTagService, TagLibMediaTagService>();
services.AddSingleton<WindowsGitStatusProvider>();
services.AddSingleton<IGitStatusProvider>(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":
await Workbench.Transfers.EnqueueMoveAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
return reply;
case "Transfers.EnqueueMoveTo":
await Workbench.Transfers.EnqueueMoveToAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
return reply;
case "Transfers.EnqueueDelete":
await Workbench.Transfers.EnqueueDeleteAsync(request.Paths ?? [], request.Flag == true).ConfigureAwait(false);
return reply;
@@ -411,6 +414,9 @@ public sealed class WorkbenchPipeServer : BackgroundService
var convertSource = request.Paths is { Length: > 0 } paths ? paths[0] : "";
await Workbench.Transfers.EnqueueConvertAsync(convertSource, request.Dest ?? "", convertKind).ConfigureAwait(false);
return reply;
case "Transfers.EnqueueWriteTags":
await Workbench.Transfers.EnqueueWriteTagsAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
return reply;
case "Sources.Refresh":
await Workbench.Sources.RefreshAsync().ConfigureAwait(false);
return reply;

View File

@@ -12,6 +12,11 @@ public sealed partial class BatchRenameViewModel : ObservableObject
private readonly RenamePlanner _planner;
private readonly RenameBatchService _batches;
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 _replace = "";
@@ -27,22 +32,40 @@ public sealed partial class BatchRenameViewModel : ObservableObject
[ObservableProperty] private RenameCaseMode _caseMode = RenameCaseMode.Unchanged;
[ObservableProperty] private bool _changeExtension;
[ObservableProperty] private string _newExtension = "";
[ObservableProperty] private string _namePattern = "";
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _canQueue;
public BatchRenameViewModel(
IReadOnlyList<RenameSubject> subjects,
RenamePlanner planner,
RenameBatchService batches)
RenameBatchService batches,
IMediaTagService? tags = null,
IHydrationGuard? hydration = null,
UiPreferencesStore? preferences = null,
IGitStatusProvider? git = null)
{
_subjects = subjects;
_planner = planner;
_batches = batches;
_tags = tags;
_hydration = hydration;
_preferences = preferences;
_git = git;
Rows = [];
PatternChoices = new ObservableCollection<string>(
FilenamePatterns.Combine(preferences?.Load().NamePatterns));
foreach (var subject in _subjects)
{
_metadata[subject.FullPath] = Seed(subject);
}
Rebuild();
_ = LoadMediaAsync();
}
public ObservableCollection<RenamePreviewRow> Rows { get; }
public ObservableCollection<string> PatternChoices { get; }
public IReadOnlyList<RenameCaseOption> CaseOptions { get; } =
[
new("Leave case", RenameCaseMode.Unchanged),
@@ -68,13 +91,14 @@ public sealed partial class BatchRenameViewModel : ObservableObject
CounterPadding = Math.Max(0, CounterPadding),
CaseMode = CaseMode,
ChangeExtension = ChangeExtension,
NewExtension = NewExtension
NewExtension = NewExtension,
NamePattern = NamePattern
};
[RelayCommand]
public async Task QueueAsync()
{
var plan = _batches.Preview(_subjects, Rules);
var plan = _batches.Preview(_subjects, Rules, _metadata);
if (!plan.CanEnqueue)
{
Status = plan.Issues.FirstOrDefault()?.Message ?? "Nothing to rename.";
@@ -85,6 +109,27 @@ public sealed partial class BatchRenameViewModel : ObservableObject
CloseRequested?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
public void SavePattern()
{
if (_preferences is null || !FilenamePatterns.TryNormalize(NamePattern, out var pattern))
{
return;
}
var stored = _preferences.Load();
var next = FilenamePatterns.Add(stored.NamePatterns, pattern);
_preferences.Save(stored with { NamePatterns = next });
PatternChoices.Clear();
foreach (var item in FilenamePatterns.Combine(next))
{
PatternChoices.Add(item);
}
NamePattern = pattern;
Status = "Pattern saved.";
}
partial void OnSearchChanged(string value) => Rebuild();
partial void OnReplaceChanged(string value) => Rebuild();
partial void OnUseRegexChanged(bool value) => Rebuild();
@@ -99,10 +144,39 @@ public sealed partial class BatchRenameViewModel : ObservableObject
partial void OnCaseModeChanged(RenameCaseMode value) => Rebuild();
partial void OnChangeExtensionChanged(bool value) => Rebuild();
partial void OnNewExtensionChanged(string value) => Rebuild();
partial void OnNamePatternChanged(string value) => Rebuild();
private async Task LoadMediaAsync()
{
foreach (var subject in _subjects)
{
if (subject.IsDirectory)
{
continue;
}
var current = _metadata.GetValueOrDefault(subject.FullPath) ?? Seed(subject);
if (_hydration is not null && await _hydration.WouldHydrateOnReadAsync(subject.FullPath).ConfigureAwait(true))
{
_metadata[subject.FullPath] = current with { HydrationBlocked = true };
continue;
}
await Task.Run(() =>
{
if (_tags is not null && _tags.TryRead(subject.FullPath, out var media))
{
_metadata[subject.FullPath] = current.Merge(media);
}
}).ConfigureAwait(true);
}
Rebuild();
}
private void Rebuild()
{
var plan = _planner.Build(_subjects, Rules, RenameBatchService.PathExists);
var plan = _planner.Build(_subjects, Rules, RenameBatchService.PathExists, _metadata);
Rows.Clear();
foreach (var row in plan.Preview)
{
@@ -118,6 +192,11 @@ public sealed partial class BatchRenameViewModel : ObservableObject
? $"{_subjects.Count} items · nothing to rename"
: $"{plan.Operations.Count} will be queued · {unchanged} unchanged";
}
private MediaTagFields Seed(RenameSubject subject)
=> FilenamePattern.WithProject(
FilenamePattern.FromFile(subject.FullPath, subject.IsDirectory),
_git?.FindRepoRoot(subject.FullPath));
}
public sealed record RenameCaseOption(string Label, RenameCaseMode Mode);

View File

@@ -38,6 +38,7 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IHostConnection? _host;
private readonly IBackgroundMaintenance? _maintenance;
private readonly IShellContextMenu? _shellMenu;
private readonly IMediaTagService? _tags;
private bool _hostStopped;
private List<string> _clipboard = [];
private bool _clipboardIsCut;
@@ -53,6 +54,8 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private bool _showEmptyRecycleBin;
[ObservableProperty] private bool _showImportWindowsLocation;
[ObservableProperty] private bool _showBatchRename;
[ObservableProperty] private bool _showMoveTo;
[ObservableProperty] private bool _showTags;
[ObservableProperty] private bool _showRunProfile;
[ObservableProperty] private bool _showOrganizeFolder;
[ObservableProperty] private bool _canUndoRenameBatch;
@@ -101,7 +104,8 @@ public sealed partial class MainViewModel : ObservableObject
IThumbnailService? thumbnails = null,
IHostConnection? hostConnection = null,
IBackgroundMaintenance? maintenance = null,
IShellContextMenu? shellMenu = null)
IShellContextMenu? shellMenu = null,
IMediaTagService? tags = null)
{
_browse = browse;
_ops = ops;
@@ -127,6 +131,7 @@ public sealed partial class MainViewModel : ObservableObject
_host = hostConnection;
_maintenance = maintenance;
_shellMenu = shellMenu;
_tags = tags;
if (_host is not null)
{
_host.StatusChanged += (_, status) =>
@@ -597,7 +602,43 @@ public sealed partial class MainViewModel : ObservableObject
return null;
}
return new BatchRenameViewModel(items, _renamePlanner, _renameBatches);
return new BatchRenameViewModel(items, _renamePlanner, _renameBatches, _tags, _hydration, _preferences, _git);
}
public TagRenameViewModel? CreateTagRenameViewModel()
{
var items = ActivePane.SelectedItems
.Where(i => !LocationRoots.IsVirtual(i.FullPath) && !i.IsDirectory)
.Select(i => new RenameSubject(i.FullPath, i.Item.Name, false))
.ToList();
if (items.Count == 0)
{
Footer = "Select files to edit tags.";
return null;
}
if (_tags is null)
{
Footer = "Tag editing is not available.";
return null;
}
return new TagRenameViewModel(items, _renamePlanner, _renameBatches, _ops, _tags, _hydration, _preferences, _git);
}
public MoveToViewModel? CreateMoveToViewModel()
{
var items = ActivePane.SelectedItems
.Where(i => !LocationRoots.IsVirtual(i.FullPath))
.Select(i => new RenameSubject(i.FullPath, i.Item.Name, i.IsDirectory))
.ToList();
if (items.Count == 0)
{
Footer = "Select files or folders to move.";
return null;
}
return new MoveToViewModel(items, _ops, _hydration, _enumerator, _preferences);
}
public async Task RefreshUndoRenameAsync()
@@ -875,6 +916,9 @@ public sealed partial class MainViewModel : ObservableObject
&& ActivePane.SelectedItems[0].Item.AvailableToImport;
ShowBatchRename = ActivePane.SelectedItems.Count > 0
&& ActivePane.SelectedItems.All(i => !LocationRoots.IsVirtual(i.FullPath));
ShowMoveTo = ShowBatchRename;
ShowTags = ActivePane.SelectedItems.Count > 0
&& ActivePane.SelectedItems.Any(i => !i.IsDirectory && !LocationRoots.IsVirtual(i.FullPath));
ShowRunProfile = ShowBatchRename;
ShowOrganizeFolder = OrganizeSourcePath() is not null;
var real = ActivePane.SelectedItems.Where(IsRealFileSystemItem).ToList();
@@ -1529,7 +1573,7 @@ public sealed partial class MainViewModel : ObservableObject
}
Add(job.SourcePath);
if (job.Op != TransferOp.Delete && job.Op != TransferOp.EmptyRecycleBin)
if (job.Op is not TransferOp.Delete and not TransferOp.EmptyRecycleBin and not TransferOp.WriteTags)
{
Add(job.DestinationPath);
}

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;
CanPause = job.Status is TransferStatus.Queued or TransferStatus.Running;
CanResume = job.Status == TransferStatus.Paused;
CanRetry = job.Status == TransferStatus.Failed;
CanRetry = job.Status is TransferStatus.Failed or TransferStatus.Waiting;
CanRemove = job.Status is not TransferStatus.Cancelling;
CanMoveUp = canMoveUp && job.Status is not TransferStatus.Running and not TransferStatus.Cancelling;
CanMoveDown = canMoveDown && job.Status is not TransferStatus.Running and not TransferStatus.Cancelling;
@@ -89,6 +89,11 @@ public sealed partial class TransferJobViewModel : ObservableObject
return FileName(job.DestinationPath);
}
if (job.Op == TransferOp.WriteTags)
{
return FileName(job.SourcePath);
}
return FileName(job.SourcePath);
}
@@ -103,6 +108,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
TransferOp.AddToArchive => $"Add to {FileName(job.DestinationPath)}",
TransferOp.VerifyArchive => "Verify archive",
TransferOp.Convert => $"Convert to {FileName(job.DestinationPath)}",
TransferOp.WriteTags => "Write tags",
TransferOp.EmptyRecycleBin => "Empty Recycle Bin",
TransferOp.Delete => string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)
? "Delete permanently"
@@ -186,6 +192,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
TransferOp.AddToArchive => "Adding",
TransferOp.VerifyArchive => "Verifying",
TransferOp.Convert => "Converting",
TransferOp.WriteTags => "Writing tags",
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
_ => "Working"
};
@@ -196,7 +203,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
private readonly ITransferHost _queue;
private readonly UiPreferencesStore _preferences;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
private readonly Dictionary<long, (long Bytes, DateTime Utc)> _speed = [];
private readonly Dictionary<long, (long Bytes, DateTime Utc, string? Text)> _speed = [];
private bool _holdCollapsed;
@@ -426,14 +433,17 @@ public sealed partial class TransferQueueViewModel : ObservableObject
if (seconds >= 0.4 && job.BytesDone >= prev.Bytes)
{
var rate = (job.BytesDone - prev.Bytes) / seconds;
_speed[job.Id] = (job.BytesDone, now);
return rate > 0 ? $"{TransferJobViewModel.FormatBytes((long)rate)}/s" : null;
var text = rate > 0
? $"{TransferJobViewModel.FormatBytes((long)rate)}/s"
: prev.Text;
_speed[job.Id] = (job.BytesDone, now, text);
return text;
}
return null;
return prev.Text;
}
_speed[job.Id] = (job.BytesDone, now);
_speed[job.Id] = (job.BytesDone, now, null);
return null;
}
@@ -483,6 +493,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
TransferOp.AddToArchive => "Adding",
TransferOp.VerifyArchive => "Verifying",
TransferOp.Convert => "Converting",
TransferOp.WriteTags => "Writing tags",
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
_ => op.ToString()
};

View File

@@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />

View File

@@ -224,4 +224,35 @@ internal static partial class NativeMethods
[LibraryImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool GetSystemPowerStatus(out SystemPowerStatus lpSystemPowerStatus);
public const int ResourceTypeDisk = 1;
public const int ConnectTemporary = 4;
public const int ErrorSuccess = 0;
public const int ErrorAlreadyAssigned = 85;
public const int ErrorDeviceAlreadyRemembered = 1202;
public const int ErrorConnectionUnavailable = 1201;
public const int ErrorNotConnected = 2250;
public const int ErrorSessionCredentialConflict = 1219;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct NetResource
{
public int dwScope;
public int dwType;
public int dwDisplayType;
public int dwUsage;
public string? lpLocalName;
public string? lpRemoteName;
public string? lpComment;
public string? lpProvider;
}
[DllImport("mpr.dll", CharSet = CharSet.Unicode)]
public static extern int WNetAddConnection2(ref NetResource netResource, string? password, string? username, int flags);
[DllImport("mpr.dll", CharSet = CharSet.Unicode)]
public static extern int WNetGetConnection(string localName, StringBuilder remoteName, ref int length);
[DllImport("mpr.dll", CharSet = CharSet.Unicode)]
public static extern int WNetRestoreConnectionW(nint hwnd, string? localDrive, [MarshalAs(UnmanagedType.Bool)] bool force);
}

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)
{
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")],
new RenameRuleSet { Prefix = "Clip_{Counter}_", UseCounter = true, CounterPadding = 3 });
Assert.Equal("Clip_001_clip.mp4", plan.Preview[0].NewName);
Assert.Contains("{Width}", new RenamePlanner().Build(
}
[Fact]
public void Media_placeholders_use_supplied_tags()
{
var plan = new RenamePlanner().Build(
[File(@"C:\a\clip.mp4")],
new RenameRuleSet { Prefix = "{Width}_" }).Preview[0].NewName);
new RenameRuleSet { Prefix = "{Width}_", NamePattern = "{Artist} - {Title}" },
tagsByPath: new Dictionary<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]

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.True(prefs.SessionTabs is null || prefs.SessionTabs.Count == 0);
Assert.True(prefs.FavoriteFolders is null || prefs.FavoriteFolders.Count == 0);
Assert.True(prefs.NamePatterns is null || prefs.NamePatterns.Count == 0);
Assert.True(prefs.MoveToPatterns is null || prefs.MoveToPatterns.Count == 0);
}
[Fact]
@@ -167,6 +169,31 @@ public class UiPreferencesStoreTests
Assert.Equal(@"C:\Users\Dominique\Documents", prefs.FavoriteFolders[1]);
}
[Fact]
public void Parse_reads_name_patterns_and_skips_built_ins()
{
var prefs = UiPreferencesStore.Parse(
[
"name-pattern={Album} - {Title}",
"name-pattern={Album} - {Title}",
"name-pattern={Artist} - {Title}",
"name-pattern="
]);
Assert.NotNull(prefs.NamePatterns);
Assert.Equal(["{Album} - {Title}"], prefs.NamePatterns);
}
[Fact]
public void Parse_reads_move_to_patterns()
{
var prefs = UiPreferencesStore.Parse(
[
@"move-to=\\10.0.0.31\media\movies\%filename_noext%",
@"move-to=\\10.0.0.31\media\movies\%filename_noext%"
]);
Assert.Equal([@"\\10.0.0.31\media\movies\%filename_noext%"], prefs.MoveToPatterns);
}
[Fact]
public void Session_tab_roundtrip_escapes_semicolons_in_paths()
{
@@ -199,7 +226,9 @@ public class UiPreferencesStoreTests
new SessionTabState(@"C:\Temp", @"D:\", true, 0.6, true)
],
SessionActiveTab: 0,
PreferFavoritesInTree: true));
PreferFavoritesInTree: true,
NamePatterns: ["{Album} - {Title}"],
MoveToPatterns: [@"\\10.0.0.31\media\movies\%filename_noext%"]));
var loaded = store.Load();
Assert.Equal("Light", loaded.Theme);
Assert.True(loaded.GroupNetworkPlaces);
@@ -218,6 +247,8 @@ public class UiPreferencesStoreTests
Assert.Equal(720, loaded.WindowHeight);
Assert.Equal(300, loaded.TreeWidth);
Assert.Equal([@"D:\Photos", @"C:\Users\Dominique\Documents"], loaded.FavoriteFolders);
Assert.Equal(["{Album} - {Title}"], loaded.NamePatterns);
Assert.Equal([@"\\10.0.0.31\media\movies\%filename_noext%"], loaded.MoveToPatterns);
Assert.NotNull(loaded.SessionTabs);
var tab = Assert.Single(loaded.SessionTabs);
Assert.Equal(@"C:\Temp", tab.LeftPath);

View File

@@ -549,3 +549,17 @@ public class GitStatusBadgeTests
Assert.Equal("merging", status.OperationLabel);
}
}
public class MediaTagFieldsTests
{
[Fact]
public void Payload_roundtrips_taken_date()
{
var taken = new DateTimeOffset(2024, 8, 26, 15, 30, 0, TimeSpan.FromHours(2));
var payload = new MediaTagFields { Title = "Sunset", Taken = taken }.Payload();
var parsed = MediaTagFields.FromPayload(payload);
Assert.Equal("Sunset", parsed.Title);
Assert.Equal(taken, parsed.Taken);
Assert.True(parsed.HasWritableTags);
}
}

View File

@@ -214,6 +214,22 @@ public class TransferQueueTests
await ctx.Queue.StopAsync(CancellationToken.None);
}
[Fact]
public async Task Retry_wakes_a_waiting_job_when_the_destination_is_back()
{
await using var ctx = await Harness.CreateAsync();
ctx.Volumes.Reachable = false;
await ctx.Queue.StartAsync(CancellationToken.None);
await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt")], ctx.Dest);
await WaitUntil(() => ctx.Queue.Snapshot().Any(j => j.Status == TransferStatus.Waiting));
ctx.Volumes.Reachable = true;
ctx.Queue.Retry(ctx.Queue.Snapshot()[0].Id);
await WaitUntil(() => ctx.Queue.Snapshot().Single().Status == TransferStatus.Done);
Assert.Equal(1, ctx.Queue.Snapshot()[0].RetryCount);
await ctx.Queue.StopAsync(CancellationToken.None);
}
[Fact]
public async Task Retry_reruns_a_failed_job()
{