aboutsummaryrefslogtreecommitdiff
path: root/lib/bulk_rename/cli.rb
blob: e84d7779fbe2cfc999c4f1290a243bfcf33ab4bc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
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