Skip to content

Commit 617908e

Browse files
committed
Initial release
1 parent 9e5034e commit 617908e

File tree

5 files changed

+537
-0
lines changed

5 files changed

+537
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,3 +398,6 @@ FodyWeavers.xsd
398398

399399
# JetBrains Rider
400400
*.sln.iml
401+
402+
# Visual Studio debug run options
403+
**/Properties/launchSettings.json

Mass Renamer.sln

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
2+
Microsoft Visual Studio Solution File, Format Version 12.00
3+
# Visual Studio Version 17
4+
VisualStudioVersion = 17.12.35527.113 d17.12
5+
MinimumVisualStudioVersion = 10.0.40219.1
6+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mass Renamer", "Mass Renamer\Mass Renamer.csproj", "{B1045D4C-1BF5-4406-BE78-8A284CFD2DB6}"
7+
EndProject
8+
Global
9+
GlobalSection(SolutionConfigurationPlatforms) = preSolution
10+
Debug|Any CPU = Debug|Any CPU
11+
Release|Any CPU = Release|Any CPU
12+
EndGlobalSection
13+
GlobalSection(ProjectConfigurationPlatforms) = postSolution
14+
{B1045D4C-1BF5-4406-BE78-8A284CFD2DB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
15+
{B1045D4C-1BF5-4406-BE78-8A284CFD2DB6}.Debug|Any CPU.Build.0 = Debug|Any CPU
16+
{B1045D4C-1BF5-4406-BE78-8A284CFD2DB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
17+
{B1045D4C-1BF5-4406-BE78-8A284CFD2DB6}.Release|Any CPU.Build.0 = Release|Any CPU
18+
EndGlobalSection
19+
GlobalSection(SolutionProperties) = preSolution
20+
HideSolutionNode = FALSE
21+
EndGlobalSection
22+
EndGlobal

Mass Renamer/Mass Renamer.cs

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
// Ignore Spelling: Renamer
2+
3+
using System.CommandLine;
4+
using System.Text.RegularExpressions;
5+
6+
namespace Mass_Renamer
7+
{
8+
static class Program
9+
{
10+
static int Main(string[] args)
11+
{
12+
// Define options
13+
var applyOption = new Option<bool>(
14+
["--apply", "-y"],
15+
"Apply changes. Dry run otherwise.");
16+
var recursiveOption = new Option<bool>(
17+
["--recursive", "-r"],
18+
"Process files recursively (with sub-folders).\n"
19+
+ "If specified - filename relative to TargetFolder will be used.\n"
20+
+ "You SHOULD do a dry run (without '-y'), as logic is a bit different.");
21+
var isRegexOption = new Option<bool>(
22+
["--pattern", "-p"],
23+
"Treat SourceMask and RenamePattern as regex PATTERNS directly. '-p' to avoid confusion with recursive.");
24+
var overwriteOption = new Option<bool>(
25+
["--overwrite", "-w"],
26+
"Overwrite files during renaming, if target already exists.\n"
27+
+ "CARE !!! DESTRUCTIVE !!!");
28+
// Define arguments
29+
var targetFolderArgument = new Argument<DirectoryInfo>(
30+
"TargetFolder",
31+
"The target folder where to rename files. Relative and absolute paths could be used.");
32+
var sourceMaskArgument = new Argument<string>(
33+
"SourceMask",
34+
"The source mask for matching files.\n"
35+
+ "In glob-like mode (default) pattern must match full filename. "
36+
+ "Pattern supports named matches in form of %A ... %Z for any text, %0 ... %9 for numeric matches, %% for % escaping. "
37+
+ "You can also use '*' and '?' as wildcards, but those will be omitted in the result.\n"
38+
+ "Alternatively you can use '-p' flag and use C# regex pattern directly.");
39+
var renamePatternArgument = new Argument<string>(
40+
"RenamePattern",
41+
"The pattern to rename files to.\n"
42+
+ "Glob-like pattern (default) allows to use named matches from SourceMask in form of %A ... %Z, %0 ... %9. "
43+
+ "You can use %% for % escaping in this mode.\n"
44+
+ "Alternatively you can use '-p' flag and use C# regex substitutions directly.");
45+
// Assemble the root command
46+
var rootCommand = new RootCommand("Mass Renamer - a tool to rename files in bulk using either glob-like or regex patterns.")
47+
{
48+
applyOption,
49+
recursiveOption,
50+
isRegexOption,
51+
overwriteOption,
52+
targetFolderArgument,
53+
sourceMaskArgument,
54+
renamePatternArgument
55+
};
56+
57+
// Set actual handler and run the command
58+
rootCommand.SetHandler(Act,
59+
applyOption, recursiveOption, isRegexOption, overwriteOption, targetFolderArgument, sourceMaskArgument, renamePatternArgument);
60+
return rootCommand.Invoke(args);
61+
}
62+
63+
/// <summary> Convert a sourceMask pattern string to a regex string </summary>
64+
[System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1192:Unnecessary usage of verbatim string literal", Justification = "<Pending>")]
65+
static string SourceToRegexPattern(string pattern)
66+
{
67+
// HACK: This is dumb and ugly, but we do this only once and I don't know how to do it better currently.
68+
// TODO: Think of a better solution.
69+
return Regex.Escape(pattern)
70+
.Replace(@"%", @"\%")
71+
.Replace(@"\%\%", "%")
72+
.Replace(@"\%A", @"(?<A>.*?)", StringComparison.CurrentCultureIgnoreCase)
73+
.Replace(@"\%B", @"(?<B>.*?)", StringComparison.CurrentCultureIgnoreCase)
74+
.Replace(@"\%C", @"(?<C>.*?)", StringComparison.CurrentCultureIgnoreCase)
75+
.Replace(@"\%D", @"(?<D>.*?)", StringComparison.CurrentCultureIgnoreCase)
76+
.Replace(@"\%E", @"(?<E>.*?)", StringComparison.CurrentCultureIgnoreCase)
77+
.Replace(@"\%F", @"(?<F>.*?)", StringComparison.CurrentCultureIgnoreCase)
78+
.Replace(@"\%G", @"(?<G>.*?)", StringComparison.CurrentCultureIgnoreCase)
79+
.Replace(@"\%H", @"(?<H>.*?)", StringComparison.CurrentCultureIgnoreCase)
80+
.Replace(@"\%I", @"(?<I>.*?)", StringComparison.CurrentCultureIgnoreCase)
81+
.Replace(@"\%J", @"(?<J>.*?)", StringComparison.CurrentCultureIgnoreCase)
82+
.Replace(@"\%K", @"(?<K>.*?)", StringComparison.CurrentCultureIgnoreCase)
83+
.Replace(@"\%L", @"(?<L>.*?)", StringComparison.CurrentCultureIgnoreCase)
84+
.Replace(@"\%M", @"(?<M>.*?)", StringComparison.CurrentCultureIgnoreCase)
85+
.Replace(@"\%N", @"(?<N>.*?)", StringComparison.CurrentCultureIgnoreCase)
86+
.Replace(@"\%O", @"(?<O>.*?)", StringComparison.CurrentCultureIgnoreCase)
87+
.Replace(@"\%P", @"(?<P>.*?)", StringComparison.CurrentCultureIgnoreCase)
88+
.Replace(@"\%Q", @"(?<Q>.*?)", StringComparison.CurrentCultureIgnoreCase)
89+
.Replace(@"\%R", @"(?<R>.*?)", StringComparison.CurrentCultureIgnoreCase)
90+
.Replace(@"\%S", @"(?<S>.*?)", StringComparison.CurrentCultureIgnoreCase)
91+
.Replace(@"\%T", @"(?<T>.*?)", StringComparison.CurrentCultureIgnoreCase)
92+
.Replace(@"\%U", @"(?<U>.*?)", StringComparison.CurrentCultureIgnoreCase)
93+
.Replace(@"\%V", @"(?<V>.*?)", StringComparison.CurrentCultureIgnoreCase)
94+
.Replace(@"\%W", @"(?<W>.*?)", StringComparison.CurrentCultureIgnoreCase)
95+
.Replace(@"\%X", @"(?<X>.*?)", StringComparison.CurrentCultureIgnoreCase)
96+
.Replace(@"\%Y", @"(?<Y>.*?)", StringComparison.CurrentCultureIgnoreCase)
97+
.Replace(@"\%Z", @"(?<Z>.*?)", StringComparison.CurrentCultureIgnoreCase)
98+
.Replace(@"\%0", @"(?<d0>\d+)")
99+
.Replace(@"\%1", @"(?<d1>\d+)")
100+
.Replace(@"\%2", @"(?<d2>\d+)")
101+
.Replace(@"\%3", @"(?<d3>\d+)")
102+
.Replace(@"\%4", @"(?<d4>\d+)")
103+
.Replace(@"\%5", @"(?<d5>\d+)")
104+
.Replace(@"\%6", @"(?<d6>\d+)")
105+
.Replace(@"\%7", @"(?<d7>\d+)")
106+
.Replace(@"\%8", @"(?<d8>\d+)")
107+
.Replace(@"\%9", @"(?<d9>\d+)")
108+
.Replace(@"\*", @".*?")
109+
.Replace(@"\?", @".");
110+
}
111+
112+
/// <summary> Convert a renamePattern string to a regex string </summary>
113+
[System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1192:Unnecessary usage of verbatim string literal", Justification = "<Pending>")]
114+
static string TargetToRegexPattern(string pattern)
115+
{
116+
// HACK: This is dumb and ugly, but we do this only once and I don't know how to do it better currently.
117+
// TODO: Think of a better solution.
118+
return pattern
119+
.Replace(@"$", @"$$")
120+
.Replace(@"\", @"\\")
121+
.Replace(@"%", @"\%")
122+
.Replace(@"\%\%", "%")
123+
.Replace(@"\%A", @"${A}", StringComparison.CurrentCultureIgnoreCase)
124+
.Replace(@"\%B", @"${B}", StringComparison.CurrentCultureIgnoreCase)
125+
.Replace(@"\%C", @"${C}", StringComparison.CurrentCultureIgnoreCase)
126+
.Replace(@"\%D", @"${D}", StringComparison.CurrentCultureIgnoreCase)
127+
.Replace(@"\%E", @"${E}", StringComparison.CurrentCultureIgnoreCase)
128+
.Replace(@"\%F", @"${F}", StringComparison.CurrentCultureIgnoreCase)
129+
.Replace(@"\%G", @"${G}", StringComparison.CurrentCultureIgnoreCase)
130+
.Replace(@"\%H", @"${H}", StringComparison.CurrentCultureIgnoreCase)
131+
.Replace(@"\%I", @"${I}", StringComparison.CurrentCultureIgnoreCase)
132+
.Replace(@"\%J", @"${J}", StringComparison.CurrentCultureIgnoreCase)
133+
.Replace(@"\%K", @"${K}", StringComparison.CurrentCultureIgnoreCase)
134+
.Replace(@"\%L", @"${L}", StringComparison.CurrentCultureIgnoreCase)
135+
.Replace(@"\%M", @"${M}", StringComparison.CurrentCultureIgnoreCase)
136+
.Replace(@"\%N", @"${N}", StringComparison.CurrentCultureIgnoreCase)
137+
.Replace(@"\%O", @"${O}", StringComparison.CurrentCultureIgnoreCase)
138+
.Replace(@"\%P", @"${P}", StringComparison.CurrentCultureIgnoreCase)
139+
.Replace(@"\%Q", @"${Q}", StringComparison.CurrentCultureIgnoreCase)
140+
.Replace(@"\%R", @"${R}", StringComparison.CurrentCultureIgnoreCase)
141+
.Replace(@"\%S", @"${S}", StringComparison.CurrentCultureIgnoreCase)
142+
.Replace(@"\%T", @"${T}", StringComparison.CurrentCultureIgnoreCase)
143+
.Replace(@"\%U", @"${U}", StringComparison.CurrentCultureIgnoreCase)
144+
.Replace(@"\%V", @"${V}", StringComparison.CurrentCultureIgnoreCase)
145+
.Replace(@"\%W", @"${W}", StringComparison.CurrentCultureIgnoreCase)
146+
.Replace(@"\%X", @"${X}", StringComparison.CurrentCultureIgnoreCase)
147+
.Replace(@"\%Y", @"${Y}", StringComparison.CurrentCultureIgnoreCase)
148+
.Replace(@"\%Z", @"${Z}", StringComparison.CurrentCultureIgnoreCase)
149+
.Replace(@"\%0", @"${d0}")
150+
.Replace(@"\%1", @"${d1}")
151+
.Replace(@"\%2", @"${d2}")
152+
.Replace(@"\%3", @"${d3}")
153+
.Replace(@"\%4", @"${d4}")
154+
.Replace(@"\%5", @"${d5}")
155+
.Replace(@"\%6", @"${d6}")
156+
.Replace(@"\%7", @"${d7}")
157+
.Replace(@"\%8", @"${d8}")
158+
.Replace(@"\%9", @"${d9}")
159+
.Replace(@"\%", @"%");
160+
}
161+
162+
/// <summary> Take action with the given arguments </summary>
163+
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
164+
async static Task<int> Act(bool apply, bool recursive, bool isRegex, bool overwrite, DirectoryInfo targetFolder, string sourceMask, string renamePattern)
165+
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
166+
{
167+
if (!targetFolder.Exists)
168+
{
169+
Console.WriteLine($"Folder \"{targetFolder}\" was not found.");
170+
return 3;
171+
}
172+
173+
HashSet<DirectoryInfo> createdFolders = [];
174+
HashSet<string> renamedFiles = [];
175+
176+
var renamePatternRegexString = isRegex ? renamePattern : TargetToRegexPattern(renamePattern);
177+
var sourceMaskRegexString = isRegex ? sourceMask : SourceToRegexPattern(sourceMask);
178+
// TODO: Add RegexOptions flags control to CommandLine Options
179+
var sourceMaskRegex = new Regex($"^{sourceMaskRegexString}$", RegexOptions.IgnoreCase);
180+
181+
Console.Write($"Scanning for files in \"{targetFolder}\"");
182+
if (recursive)
183+
Console.Write(" recursively");
184+
Console.WriteLine($", using patterns \"{sourceMask}\" ---> \"{renamePattern}\".");
185+
186+
bool firstMatch = true;
187+
int maxLenSource = 0;
188+
int maxLenNew = 0;
189+
int filesRenamed = 0;
190+
int filesMatched = 0;
191+
int fileErrors = 0;
192+
int fileDuplicates = 0;
193+
194+
var files = Directory.GetFiles(targetFolder.FullName, "*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly);
195+
foreach (var file in files)
196+
{
197+
var relativePath = Path.GetRelativePath(targetFolder.FullName, file);
198+
var match = sourceMaskRegex.Match(relativePath);
199+
if (match.Success)
200+
{
201+
filesMatched++;
202+
203+
var relativePathDisplay = $"\"{relativePath}\"";
204+
maxLenSource = Math.Max(maxLenSource, relativePathDisplay.Length);
205+
206+
// TODO: Ensure substitution groups used in renamePattern are present in sourceMask
207+
// Currently they are just printed as "${C}", for example, if not present in the sourceMask
208+
var newFileName = sourceMaskRegex.Replace(relativePath, renamePatternRegexString);
209+
var newFilePath = Path.Combine(targetFolder.FullName, newFileName);
210+
211+
var newFileNameDisplay = $"\"{newFileName}\"";
212+
maxLenNew = Math.Max(maxLenNew, newFileNameDisplay.Length);
213+
214+
var isDuplicate = renamedFiles.Contains(newFilePath);
215+
renamedFiles.Add(newFilePath);
216+
if (isDuplicate)
217+
fileDuplicates++;
218+
219+
if (firstMatch)
220+
{
221+
Console.WriteLine();
222+
Console.WriteLine("Sample match:");
223+
Console.WriteLine($" {relativePathDisplay}");
224+
for (int i = 1; i < match.Groups.Count; i++)
225+
{
226+
Console.Write($" {i}: ");
227+
Console.Write($"{match.Groups[i].Name} = ");
228+
Console.WriteLine($"\"{match.Groups[i].Value}\"");
229+
}
230+
231+
Console.WriteLine();
232+
Console.WriteLine(apply ? "Renaming files:" : "Would rename files:");
233+
firstMatch = false;
234+
}
235+
Console.Write($" {relativePathDisplay.PadRight(maxLenSource)} ");
236+
237+
if (apply)
238+
{
239+
try
240+
{
241+
// Create parent folders if needed. Only once per folder.
242+
DirectoryInfo parentFolder = new(Path.GetDirectoryName(newFilePath)!);
243+
if (!createdFolders.Contains(parentFolder) && !parentFolder.Exists)
244+
parentFolder.Create();
245+
createdFolders.Add(parentFolder);
246+
247+
// Try to rename the file
248+
File.Move(file, newFilePath, overwrite);
249+
filesRenamed++;
250+
251+
// Report success
252+
Console.ForegroundColor = ConsoleColor.Green;
253+
Console.Write("---> ");
254+
if (isDuplicate)
255+
{
256+
Console.ForegroundColor = ConsoleColor.Yellow;
257+
Console.WriteLine($"{newFileNameDisplay} (duplicate)");
258+
Console.ResetColor();
259+
}
260+
else
261+
{
262+
Console.ResetColor();
263+
Console.WriteLine($"{newFileNameDisplay}");
264+
}
265+
}
266+
catch (Exception e)
267+
{
268+
fileErrors++;
269+
// Report failure
270+
Console.ForegroundColor = ConsoleColor.Red;
271+
Console.Write("-X-> ");
272+
Console.ResetColor();
273+
Console.WriteLine($"{newFileNameDisplay.PadRight(maxLenNew)} : {e.Message}");
274+
}
275+
}
276+
else
277+
{
278+
filesRenamed++;
279+
// Show what would be done
280+
Console.Write("···> ");
281+
if (isDuplicate)
282+
{
283+
Console.ForegroundColor = ConsoleColor.Yellow;
284+
Console.WriteLine($"{newFileNameDisplay} (duplicate)");
285+
Console.ResetColor();
286+
}
287+
else
288+
{
289+
Console.WriteLine($"{newFileNameDisplay}");
290+
}
291+
}
292+
}
293+
}
294+
295+
// Report results summary
296+
Console.WriteLine();
297+
var renameText = apply ? "renamed" : "to be renamed";
298+
Console.WriteLine($"Files matched: {filesMatched} out of {files.Length} found");
299+
Console.WriteLine($"Files {renameText}: {filesRenamed}");
300+
if (fileDuplicates > 0)
301+
{
302+
Console.ForegroundColor = ConsoleColor.Yellow;
303+
Console.WriteLine($"Duplicate names: {fileDuplicates}");
304+
Console.ResetColor();
305+
}
306+
if (fileErrors > 0)
307+
{
308+
Console.ForegroundColor = ConsoleColor.Red;
309+
Console.WriteLine($"Failed renaming: {fileErrors}");
310+
Console.ResetColor();
311+
}
312+
313+
// Return error code
314+
if (fileErrors > 0)
315+
return 2;
316+
if (fileDuplicates > 0)
317+
return 1;
318+
return 0;
319+
}
320+
}
321+
}

Mass Renamer/Mass Renamer.csproj

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<SelfContained>false</SelfContained>
7+
<PublishSingleFile>true</PublishSingleFile>
8+
<!--
9+
<PublishAot>true</PublishAot>
10+
-->
11+
<SatelliteResourceLanguages>en-US</SatelliteResourceLanguages>
12+
13+
<RootNamespace>Mass_Renamer</RootNamespace>
14+
<ImplicitUsings>enable</ImplicitUsings>
15+
<Nullable>enable</Nullable>
16+
<StartupObject>Mass_Renamer.Program</StartupObject>
17+
<AssemblyName>mren</AssemblyName>
18+
</PropertyGroup>
19+
20+
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
21+
<DebugType>portable</DebugType>
22+
</PropertyGroup>
23+
24+
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
25+
<DebugType>portable</DebugType>
26+
</PropertyGroup>
27+
28+
<ItemGroup>
29+
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
30+
</ItemGroup>
31+
32+
</Project>

0 commit comments

Comments
 (0)