diff options
Diffstat (limited to 'lib/bulk_rename')
| -rw-r--r-- | lib/bulk_rename/cli.rb | 164 | ||||
| -rw-r--r-- | lib/bulk_rename/version.rb | 5 |
2 files changed, 169 insertions, 0 deletions
diff --git a/lib/bulk_rename/cli.rb b/lib/bulk_rename/cli.rb new file mode 100644 index 0000000..e84d777 --- /dev/null +++ b/lib/bulk_rename/cli.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require "optparse" +require "pathname" +require "securerandom" + +module BulkRename + class CLI + def initialize(argv) + @argv = argv.dup + @dry_run = false + @force = false + @list_file = nil + @pattern = nil + @start = 1 + end + + def run + parse_options! + + dir = Pathname.new(@argv.shift || ".").expand_path + abort "error: #{dir} is not a directory" unless dir.directory? + + files = dir.children + .select { |f| f.file? && !f.basename.to_s.start_with?(".") } + .sort + + abort "nothing to rename in #{dir}" if files.empty? + + new_names = @pattern ? sequential_names(files) : names_from_list(dir) + + if files.length != new_names.length + abort "error: #{files.length} file(s) in #{dir} but #{new_names.length} " \ + "target name(s) - refusing to guess, fix the mismatch first" + end + + duplicates = new_names.tally.select { |_, count| count > 1 }.keys + unless duplicates.empty? + abort "error: duplicate target name(s) in the rename list: " \ + "#{duplicates.join(', ')} - refusing to run" + end + + pairs = files.zip(new_names).reject { |old_path, new_name| old_path.basename.to_s == new_name } + + if pairs.empty? + puts "nothing to do, every file already has its target name" + return 0 + end + + if @dry_run + pairs.each { |old_path, new_name| puts "#{old_path.basename} -> #{new_name}" } + return 0 + end + + perform_staged_rename(dir, pairs) + 0 + end + + private + + def parse_options! + OptionParser.new do |opts| + opts.banner = "Usage: bulk-rename [options] [DIRECTORY]\n\n" \ + "Renames every file in DIRECTORY (default: current directory), either\n" \ + "to names read from a list file, one per line in the same sorted order\n" \ + "as the files, or sequentially via --pattern.\n\n" + + opts.on("-l", "--list FILE", + "File with one target name per line " \ + "(default: fileNames.txt in the parent of DIRECTORY)") { |f| @list_file = f } + + opts.on("-p", "--pattern PATTERN", + "sprintf pattern for sequential renaming instead of a list, " \ + "e.g. 'wallpaper-%04d' - original extension is kept automatically") { |p| @pattern = p } + + opts.on("-s", "--start N", Integer, + "Starting number for --pattern (default: 1)") { |n| @start = n } + + opts.on("-n", "--dry-run", "Show planned renames without doing them") { @dry_run = true } + + opts.on("-f", "--force", "Overwrite existing files at the target name") { @force = true } + + opts.on("-h", "--help", "Show this help") do + puts opts + exit 0 + end + end.parse!(@argv) + end + + def names_from_list(dir) + path = @list_file ? Pathname.new(@list_file).expand_path : (dir.parent / "fileNames.txt") + unless path.exist? + abort "error: #{path} not found " \ + "(pass --list to point at a different file, or --pattern for sequential renaming)" + end + path.readlines(chomp: true).reject(&:empty?) + end + + def sequential_names(files) + files.each_with_index.map do |file, i| + format(@pattern, @start + i) + file.extname + end + end + + # The clobbering hazard with an in-place bulk rename isn't hypothetical: + # if file A is due to become "3.jpg" while file B (not yet processed) + # is currently named "3.jpg", renaming A clobbers B outright. Renaming + # in sorted order does not save you here - a name from later in the + # list can easily match a name earlier in the list. + # + # Fix: stage every file under a random temp name first. Only once + # every original file is safely out of the way under a name nothing + # else could possibly collide with, move each staged file to its real + # final name. At that point, an existing file at the target path is + # unambiguously either --force territory or a genuine conflict with + # something outside this batch - never a false collision with a + # not-yet-processed file from the same batch. + def perform_staged_rename(dir, pairs) + staged = [] + + begin + pairs.each do |old_path, new_name| + tmp_path = dir / ".bulk_rename_tmp_#{SecureRandom.hex(8)}" + File.rename(old_path, tmp_path) + staged << [tmp_path, old_path.basename.to_s, new_name] + end + rescue SystemCallError => e + warn "error while staging files: #{e.message}" + report_stranded(staged) + exit 1 + end + + begin + staged.each do |tmp_path, original_name, new_name| + final_path = dir / new_name + + if final_path.exist? && !@force + warn "skipping #{original_name.inspect}: target #{new_name.inspect} already exists " \ + "(use --force to overwrite) - left staged as #{tmp_path.basename}" + next + end + + File.rename(tmp_path, final_path) + puts "#{original_name} -> #{new_name}" + end + rescue SystemCallError => e + warn "error while finalizing renames: #{e.message}" + remaining = staged.select { |tmp_path, _original_name, _new_name| tmp_path.exist? } + report_stranded(remaining) + exit 1 + end + end + + def report_stranded(staged) + return if staged.empty? + + warn "the following files are safe but stuck under a temp name - " \ + "nothing was lost, finish the rename by hand:" + staged.each do |tmp_path, original_name, new_name| + warn " #{tmp_path.basename} (was #{original_name}, would become #{new_name})" + end + end + end +end diff --git a/lib/bulk_rename/version.rb b/lib/bulk_rename/version.rb new file mode 100644 index 0000000..f155944 --- /dev/null +++ b/lib/bulk_rename/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module BulkRename + VERSION = "0.1.0" +end |
