|
| 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 | +} |
0 commit comments