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
|