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

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