aboutsummaryrefslogtreecommitdiff
path: root/lib/bulk_rename/cli.rb
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bulk_rename/cli.rb')
-rw-r--r--lib/bulk_rename/cli.rb164
1 files changed, 164 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