diff --git a/.gitignore b/.gitignore index a4fe18b..781af30 100644 --- a/.gitignore +++ b/.gitignore @@ -398,3 +398,6 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml + +# Visual Studio debug run options +**/Properties/launchSettings.json diff --git a/Mass Renamer.sln b/Mass Renamer.sln new file mode 100644 index 0000000..6600b93 --- /dev/null +++ b/Mass Renamer.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35527.113 d17.12 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mass Renamer", "Mass Renamer\Mass Renamer.csproj", "{B1045D4C-1BF5-4406-BE78-8A284CFD2DB6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B1045D4C-1BF5-4406-BE78-8A284CFD2DB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1045D4C-1BF5-4406-BE78-8A284CFD2DB6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1045D4C-1BF5-4406-BE78-8A284CFD2DB6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1045D4C-1BF5-4406-BE78-8A284CFD2DB6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/Mass Renamer/Mass Renamer.cs b/Mass Renamer/Mass Renamer.cs new file mode 100644 index 0000000..6ec47b3 --- /dev/null +++ b/Mass Renamer/Mass Renamer.cs @@ -0,0 +1,321 @@ +// Ignore Spelling: Renamer + +using System.CommandLine; +using System.Text.RegularExpressions; + +namespace Mass_Renamer +{ + static class Program + { + static int Main(string[] args) + { + // Define options + var applyOption = new Option( + ["--apply", "-y"], + "Apply changes. Dry run otherwise."); + var recursiveOption = new Option( + ["--recursive", "-r"], + "Process files recursively (with sub-folders).\n" + + "If specified - filename relative to TargetFolder will be used.\n" + + "You SHOULD do a dry run (without '-y'), as logic is a bit different."); + var isRegexOption = new Option( + ["--pattern", "-p"], + "Treat SourceMask and RenamePattern as regex PATTERNS directly. '-p' to avoid confusion with recursive."); + var overwriteOption = new Option( + ["--overwrite", "-w"], + "Overwrite files during renaming, if target already exists.\n" + + "CARE !!! DESTRUCTIVE !!!"); + // Define arguments + var targetFolderArgument = new Argument( + "TargetFolder", + "The target folder where to rename files. Relative and absolute paths could be used."); + var sourceMaskArgument = new Argument( + "SourceMask", + "The source mask for matching files.\n" + + "In glob-like mode (default) pattern must match full filename. " + + "Pattern supports named matches in form of %A ... %Z for any text, %0 ... %9 for numeric matches, %% for % escaping. " + + "You can also use '*' and '?' as wildcards, but those will be omitted in the result.\n" + + "Alternatively you can use '-p' flag and use C# regex pattern directly."); + var renamePatternArgument = new Argument( + "RenamePattern", + "The pattern to rename files to.\n" + + "Glob-like pattern (default) allows to use named matches from SourceMask in form of %A ... %Z, %0 ... %9. " + + "You can use %% for % escaping in this mode.\n" + + "Alternatively you can use '-p' flag and use C# regex substitutions directly."); + // Assemble the root command + var rootCommand = new RootCommand("Mass Renamer - a tool to rename files in bulk using either glob-like or regex patterns.") + { + applyOption, + recursiveOption, + isRegexOption, + overwriteOption, + targetFolderArgument, + sourceMaskArgument, + renamePatternArgument + }; + + // Set actual handler and run the command + rootCommand.SetHandler(Act, + applyOption, recursiveOption, isRegexOption, overwriteOption, targetFolderArgument, sourceMaskArgument, renamePatternArgument); + return rootCommand.Invoke(args); + } + + /// Convert a sourceMask pattern string to a regex string + [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1192:Unnecessary usage of verbatim string literal", Justification = "")] + static string SourceToRegexPattern(string pattern) + { + // HACK: This is dumb and ugly, but we do this only once and I don't know how to do it better currently. + // TODO: Think of a better solution. + return Regex.Escape(pattern) + .Replace(@"%", @"\%") + .Replace(@"\%\%", "%") + .Replace(@"\%A", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%B", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%C", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%D", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%E", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%F", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%G", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%H", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%I", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%J", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%K", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%L", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%M", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%N", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%O", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%P", @"(?

.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%Q", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%R", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%S", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%T", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%U", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%V", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%W", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%X", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%Y", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%Z", @"(?.*?)", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%0", @"(?\d+)") + .Replace(@"\%1", @"(?\d+)") + .Replace(@"\%2", @"(?\d+)") + .Replace(@"\%3", @"(?\d+)") + .Replace(@"\%4", @"(?\d+)") + .Replace(@"\%5", @"(?\d+)") + .Replace(@"\%6", @"(?\d+)") + .Replace(@"\%7", @"(?\d+)") + .Replace(@"\%8", @"(?\d+)") + .Replace(@"\%9", @"(?\d+)") + .Replace(@"\*", @".*?") + .Replace(@"\?", @"."); + } + + ///

Convert a renamePattern string to a regex string + [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1192:Unnecessary usage of verbatim string literal", Justification = "")] + static string TargetToRegexPattern(string pattern) + { + // HACK: This is dumb and ugly, but we do this only once and I don't know how to do it better currently. + // TODO: Think of a better solution. + return pattern + .Replace(@"$", @"$$") + .Replace(@"\", @"\\") + .Replace(@"%", @"\%") + .Replace(@"\%\%", "%") + .Replace(@"\%A", @"${A}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%B", @"${B}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%C", @"${C}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%D", @"${D}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%E", @"${E}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%F", @"${F}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%G", @"${G}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%H", @"${H}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%I", @"${I}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%J", @"${J}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%K", @"${K}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%L", @"${L}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%M", @"${M}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%N", @"${N}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%O", @"${O}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%P", @"${P}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%Q", @"${Q}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%R", @"${R}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%S", @"${S}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%T", @"${T}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%U", @"${U}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%V", @"${V}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%W", @"${W}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%X", @"${X}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%Y", @"${Y}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%Z", @"${Z}", StringComparison.CurrentCultureIgnoreCase) + .Replace(@"\%0", @"${d0}") + .Replace(@"\%1", @"${d1}") + .Replace(@"\%2", @"${d2}") + .Replace(@"\%3", @"${d3}") + .Replace(@"\%4", @"${d4}") + .Replace(@"\%5", @"${d5}") + .Replace(@"\%6", @"${d6}") + .Replace(@"\%7", @"${d7}") + .Replace(@"\%8", @"${d8}") + .Replace(@"\%9", @"${d9}") + .Replace(@"\%", @"%"); + } + + /// Take action with the given arguments +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + async static Task Act(bool apply, bool recursive, bool isRegex, bool overwrite, DirectoryInfo targetFolder, string sourceMask, string renamePattern) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + { + if (!targetFolder.Exists) + { + Console.WriteLine($"Folder \"{targetFolder}\" was not found."); + return 3; + } + + HashSet createdFolders = []; + HashSet renamedFiles = []; + + var renamePatternRegexString = isRegex ? renamePattern : TargetToRegexPattern(renamePattern); + var sourceMaskRegexString = isRegex ? sourceMask : SourceToRegexPattern(sourceMask); + // TODO: Add RegexOptions flags control to CommandLine Options + var sourceMaskRegex = new Regex($"^{sourceMaskRegexString}$", RegexOptions.IgnoreCase); + + Console.Write($"Scanning for files in \"{targetFolder}\""); + if (recursive) + Console.Write(" recursively"); + Console.WriteLine($", using patterns \"{sourceMask}\" ---> \"{renamePattern}\"."); + + bool firstMatch = true; + int maxLenSource = 0; + int maxLenNew = 0; + int filesRenamed = 0; + int filesMatched = 0; + int fileErrors = 0; + int fileDuplicates = 0; + + var files = Directory.GetFiles(targetFolder.FullName, "*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); + foreach (var file in files) + { + var relativePath = Path.GetRelativePath(targetFolder.FullName, file); + var match = sourceMaskRegex.Match(relativePath); + if (match.Success) + { + filesMatched++; + + var relativePathDisplay = $"\"{relativePath}\""; + maxLenSource = Math.Max(maxLenSource, relativePathDisplay.Length); + + // TODO: Ensure substitution groups used in renamePattern are present in sourceMask + // Currently they are just printed as "${C}", for example, if not present in the sourceMask + var newFileName = sourceMaskRegex.Replace(relativePath, renamePatternRegexString); + var newFilePath = Path.Combine(targetFolder.FullName, newFileName); + + var newFileNameDisplay = $"\"{newFileName}\""; + maxLenNew = Math.Max(maxLenNew, newFileNameDisplay.Length); + + var isDuplicate = renamedFiles.Contains(newFilePath); + renamedFiles.Add(newFilePath); + if (isDuplicate) + fileDuplicates++; + + if (firstMatch) + { + Console.WriteLine(); + Console.WriteLine("Sample match:"); + Console.WriteLine($" {relativePathDisplay}"); + for (int i = 1; i < match.Groups.Count; i++) + { + Console.Write($" {i}: "); + Console.Write($"{match.Groups[i].Name} = "); + Console.WriteLine($"\"{match.Groups[i].Value}\""); + } + + Console.WriteLine(); + Console.WriteLine(apply ? "Renaming files:" : "Would rename files:"); + firstMatch = false; + } + Console.Write($" {relativePathDisplay.PadRight(maxLenSource)} "); + + if (apply) + { + try + { + // Create parent folders if needed. Only once per folder. + DirectoryInfo parentFolder = new(Path.GetDirectoryName(newFilePath)!); + if (!createdFolders.Contains(parentFolder) && !parentFolder.Exists) + parentFolder.Create(); + createdFolders.Add(parentFolder); + + // Try to rename the file + File.Move(file, newFilePath, overwrite); + filesRenamed++; + + // Report success + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("---> "); + if (isDuplicate) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"{newFileNameDisplay} (duplicate)"); + Console.ResetColor(); + } + else + { + Console.ResetColor(); + Console.WriteLine($"{newFileNameDisplay}"); + } + } + catch (Exception e) + { + fileErrors++; + // Report failure + Console.ForegroundColor = ConsoleColor.Red; + Console.Write("-X-> "); + Console.ResetColor(); + Console.WriteLine($"{newFileNameDisplay.PadRight(maxLenNew)} : {e.Message}"); + } + } + else + { + filesRenamed++; + // Show what would be done + Console.Write("···> "); + if (isDuplicate) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"{newFileNameDisplay} (duplicate)"); + Console.ResetColor(); + } + else + { + Console.WriteLine($"{newFileNameDisplay}"); + } + } + } + } + + // Report results summary + Console.WriteLine(); + var renameText = apply ? "renamed" : "to be renamed"; + Console.WriteLine($"Files matched: {filesMatched} out of {files.Length} found"); + Console.WriteLine($"Files {renameText}: {filesRenamed}"); + if (fileDuplicates > 0) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"Duplicate names: {fileDuplicates}"); + Console.ResetColor(); + } + if (fileErrors > 0) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Failed renaming: {fileErrors}"); + Console.ResetColor(); + } + + // Return error code + if (fileErrors > 0) + return 2; + if (fileDuplicates > 0) + return 1; + return 0; + } + } +} diff --git a/Mass Renamer/Mass Renamer.csproj b/Mass Renamer/Mass Renamer.csproj new file mode 100644 index 0000000..b8e2ecf --- /dev/null +++ b/Mass Renamer/Mass Renamer.csproj @@ -0,0 +1,32 @@ + + + + Exe + net8.0 + false + true + + en-US + + Mass_Renamer + enable + enable + Mass_Renamer.Program + mren + + + + portable + + + + portable + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4c4e66 --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +A simple console tool to bulk rename files using either glob-like or regex patterns. + +## Why? + +My workflows often produce quite a lot of similarly named files, which I need to rename using a pattern. Doing that manually is tedious. + +There are quite a lot of tools for bulk renaming on Windows. Problem is: + - they are too difficult, with miriad of options, which makes them difficult to use + - most of them are GUI oriented - that's not always a desirable thing + - I wanted something as simple as possible, keeping it repeatable. + +That was my reasoning to write this tool for my own usage. + +You are free to use it as you wish. Any suggestions or pull requests are welcome. + +## How? + +`mren` is the short name for Mass Renamer. `mren` looks to be unique enough, so that it shouldn't cause overlaps with other commands. + +`mren` needs arguments: + +- folder where to look for files (`.` for the current folder), +- source mask (see below), +- rename pattern (see below). + +By default `mren` does a DRY RUN only, i.e. no action is taken. Add `-y` option to apply. + +> NOTE: Currently there's no way to undo changes. If you are using recursive `-r` mode or overwrite `-w` mode - make sure to ALWAYS DO A DRY RUN first. + +For source mask and rename pattern - there are 2 modes: + +- glob-like (default): + - uses substitutions: + - `%A` ... `%Z` - any number of any characters, ungreedy; + - `%0` ... `%9` - 1 or more digits, greedy; + - `*` - any number of any characters, omitted, i.e. cannot be used in rename pattern; + - `?` - any single character, omitted, i.e. cannot be used in rename pattern; + - `%%` - results in single literal `%`, escape. + - all other characters are treated literally; + - are converted to regex patterns internally; there might be some bugs with this conversion. +- regex patterns mode (`-p` option): + - uses .NET (C#) regex patterns and substitutions syntax for both source mask and rename pattern; refer either to Microsoft docs or to regex101.com C# docs for regex syntax details. + +In both modes we must match a full relative (to target folder) name string, so start (`^`) & end (`$`) anchors are added in the background. You don't need to add them. + +Also, case INsensitive matching is used, hard-coded for now. + +### Example 1 - glob-like + +Say we have a files called something like `Some title.mkv_snapshot_01.02.03.jpg`. We want to rename those to `01.02.03 Some title.jpg` form. + +With glob-like (default) patterns we can do a preview: + +```sh +mren . '%A.mkv_snapshot_%B.jpg' '%B %A.jpg' +``` + +> NOTE: if `mren.exe` is not on your PATH variable - you might need to either run as `./mren.exe` if the file is located in the current folder, or to use a full path to the executable. + +Which gives us output: + +``` +Scanning for files in ".", using patterns "%A.mkv_snapshot_%B.jpg" ---> "%B %A.jpg". + +Sample match: + "Some title.mkv_snapshot_01.02.03.jpg" + 1: A = "Some title" + 2: B = "01.02.03" + +Would rename files: + "Some title.mkv_snapshot_01.02.03.jpg" ···> "01.02.03 Some title.jpg" + +Files matched: 1 out of 1 found +Files to be renamed: 1 +``` + +Now we have made sure that the result is indeed what we wanted - we can add `-y` option to run the action. Using glob-like that would be: + +```sh +mren . '%A.mkv_snapshot_%B.jpg' '%B %A.jpg' -y +``` + +### Example 1 - regex patterns + +We can do the same thing as before using regex pattern and substitution directly (`-p` option). + +Regex in command below is not using named caption groups - this is done intentionally to simplify patterns. Let's do the dry run using regex (`-p` option): + +```sh +mren . '(.*)\.mkv_snapshot_(.*)\.jpg' '$2 $1.jpg' -p +``` + +> NOTE the single quotes `'` used in the command above - shells (PowerShell here) can interfere with program execution by substituting environment variables. `$1` and `$2` in this case would most likely be replaced with empty strings if we use normal quotes `"`. The program would actually get ` .jpg` instead of `$2 $1.jpg`. + +We'll get a similar output from regex: + +``` +Scanning for files in ".", using patterns "(.*)\.mkv_snapshot_(.*)\.jpg" ---> "$2 $1.jpg". + +Sample match: + "Some title.mkv_snapshot_01.02.03.jpg" + 1: 1 = "Some title" + 2: 2 = "01.02.03" + +Would rename files: + "Some title.mkv_snapshot_01.02.03.jpg" ···> "01.02.03 Some title.jpg" + +Files matched: 1 out of 1 found +Files to be renamed: 1 +``` + +The only difference is that we now have full control, including caption groups naming. + +## Syntax + +`mren` syntax help that you can get via the program itself: + +``` +Description: + Mass Renamer - a tool to rename files in bulk using either glob-like or regex patterns. + +Usage: + mren [options] + +Arguments: + The target folder where to rename files. Relative and absolute paths could be used. + The source mask for matching files. + In glob-like mode (default) pattern must match full filename. Pattern supports named matches in form of %A ... %Z for any text, %0 ... %9 + for numeric matches, %% for % escaping. You can also use '*' and '?' as wildcards, but those will be omitted in the result. + Alternatively you can use '-p' flag and use C# regex pattern directly. + The pattern to rename files to. + Glob-like pattern (default) allows to use named matches from SourceMask in form of %A ... %Z, %0 ... %9. You can use %% for % escaping in + this mode. + Alternatively you can use '-p' flag and use C# regex substitutions directly. + +Options: + -y, --apply Apply changes. Dry run otherwise. + -r, --recursive Process files recursively (with sub-folders). + If specified - filename relative to TargetFolder will be used. + You SHOULD do a dry run (without '-y'), as logic is a bit different. + -p, --pattern Treat SourceMask and RenamePattern as regex PATTERNS directly. '-p' to avoid confusion with recursive. + -w, --overwrite Overwrite files during renaming, if target already exists. + CARE !!! DESTRUCTIVE !!! + --version Show version information + -?, -h, --help Show help and usage information +``` + +## Compilation from sources + +I've set up a single-file publishing with only `en-US` locale, reliant on .NET 8.0. To get the executable yourself (~350 KiB): + +- clone the repo +- go into solution or project folder +- run `dotnet publish` (.NET 8.0 SDK must be installed) + +Other build options: + +- Build a self-contained executable - set SelfContained to true in csproj file. Results in ~60 MiB file. It won't be reliant on .NET framework. Could be trimmed to reduce the size. +- Build an NativeAOT executable - uncomment PublishAot, comment PublishSingleFile in csproj file. Results in ~4 MiB file, natively compiled. It won't be reliant on .NET framework.