metaclean 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +95 -30
- data/SECURITY.md +59 -0
- data/bin/metaclean +0 -1
- data/lib/metaclean/cli.rb +48 -45
- data/lib/metaclean/committer.rb +130 -0
- data/lib/metaclean/discovery.rb +127 -0
- data/lib/metaclean/display.rb +81 -95
- data/lib/metaclean/exiftool.rb +34 -65
- data/lib/metaclean/ffmpeg.rb +96 -50
- data/lib/metaclean/file_ops.rb +214 -0
- data/lib/metaclean/mat2.rb +28 -77
- data/lib/metaclean/qpdf.rb +46 -46
- data/lib/metaclean/runner.rb +130 -433
- data/lib/metaclean/strategy.rb +75 -100
- data/lib/metaclean/version.rb +1 -5
- data/lib/metaclean.rb +53 -73
- metadata +8 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metaclean
|
|
4
|
+
class Discovery
|
|
5
|
+
attr_reader :scan_errors, :guards
|
|
6
|
+
|
|
7
|
+
def initialize(recursive: false)
|
|
8
|
+
@recursive = recursive
|
|
9
|
+
@scan_errors = 0
|
|
10
|
+
@guards = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def expand(paths)
|
|
14
|
+
@scan_errors = 0
|
|
15
|
+
@guards = {}
|
|
16
|
+
explicit = []
|
|
17
|
+
discovered = []
|
|
18
|
+
paths.each do |path|
|
|
19
|
+
collect_path(path, explicit, discovered)
|
|
20
|
+
end
|
|
21
|
+
discovered.reject! { |file| skip?(file) }
|
|
22
|
+
result = dedupe_by_realpath(explicit + discovered)
|
|
23
|
+
result.select do |file|
|
|
24
|
+
@guards[file] = FileOps.path_guard!(file)
|
|
25
|
+
true
|
|
26
|
+
rescue Error, SystemCallError => e
|
|
27
|
+
scan_failed(file, e)
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def skip?(file)
|
|
33
|
+
base = File.basename(file)
|
|
34
|
+
base.start_with?('.') ||
|
|
35
|
+
base.end_with?('.bak') ||
|
|
36
|
+
base.match?(Metaclean::CLEAN_OUTPUT_RE) ||
|
|
37
|
+
base.include?(Metaclean::TMP_MARKER)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dedupe_by_realpath(paths)
|
|
41
|
+
seen = {}
|
|
42
|
+
paths.each_with_object([]) do |path, result|
|
|
43
|
+
key = safe_realpath(path)
|
|
44
|
+
next if seen[key]
|
|
45
|
+
|
|
46
|
+
seen[key] = true
|
|
47
|
+
result << path
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def collect_path(path, explicit, discovered)
|
|
54
|
+
unless File.exist?(path) || File.symlink?(path)
|
|
55
|
+
Display.warning "Not found: #{path}"
|
|
56
|
+
@scan_errors += 1
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
FileOps.path_guard!(path)
|
|
61
|
+
if File.directory?(path)
|
|
62
|
+
collect_dir(path, discovered)
|
|
63
|
+
elsif File.file?(path)
|
|
64
|
+
explicit << path
|
|
65
|
+
else
|
|
66
|
+
scan_failed(path, Error.new('not a regular file or directory'))
|
|
67
|
+
end
|
|
68
|
+
rescue Error, SystemCallError => e
|
|
69
|
+
scan_failed(path, e)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def safe_realpath(path)
|
|
73
|
+
File.realpath(path)
|
|
74
|
+
rescue SystemCallError
|
|
75
|
+
path
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def collect_dir(dir, files)
|
|
79
|
+
if @recursive
|
|
80
|
+
walk_recursive(dir, files)
|
|
81
|
+
else
|
|
82
|
+
Dir.children(dir).each do |entry|
|
|
83
|
+
next if entry.start_with?('.')
|
|
84
|
+
|
|
85
|
+
path = File.join(dir, entry)
|
|
86
|
+
next if File.symlink?(path)
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
FileOps.path_guard!(path)
|
|
90
|
+
files << path if File.file?(path)
|
|
91
|
+
rescue Error, SystemCallError => e
|
|
92
|
+
scan_failed(path, e)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
rescue SystemCallError => e
|
|
97
|
+
scan_failed(dir, e)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def walk_recursive(dir, files)
|
|
101
|
+
Dir.each_child(dir) do |entry|
|
|
102
|
+
next if entry.start_with?('.')
|
|
103
|
+
|
|
104
|
+
path = File.join(dir, entry)
|
|
105
|
+
next if File.symlink?(path)
|
|
106
|
+
|
|
107
|
+
begin
|
|
108
|
+
FileOps.path_guard!(path)
|
|
109
|
+
if File.directory?(path)
|
|
110
|
+
walk_recursive(path, files)
|
|
111
|
+
elsif File.file?(path)
|
|
112
|
+
files << path
|
|
113
|
+
end
|
|
114
|
+
rescue Error, SystemCallError => e
|
|
115
|
+
scan_failed(path, e)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
rescue SystemCallError => e
|
|
119
|
+
scan_failed(dir, e)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def scan_failed(dir, error)
|
|
123
|
+
Display.warning "Skipping #{dir}: #{error.message}"
|
|
124
|
+
@scan_errors += 1
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/metaclean/display.rb
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# All terminal output lives here: ANSI colors, headers, tables, the
|
|
4
|
-
# before/after diff. Colors are gated on `tty?` (see color?).
|
|
5
|
-
|
|
6
3
|
module Metaclean
|
|
7
4
|
module Display
|
|
8
5
|
COLORS = {
|
|
@@ -17,14 +14,8 @@ module Metaclean
|
|
|
17
14
|
gray: "\e[90m"
|
|
18
15
|
}.freeze
|
|
19
16
|
|
|
20
|
-
# ExifTool reports four "groups" that are descriptions of the file
|
|
21
|
-
# itself, not embedded metadata: System (filesystem stat), File (header
|
|
22
|
-
# info), ExifTool (its own version), Composite (computed values).
|
|
23
|
-
# Excluding these makes the diff focus on what actually got stripped.
|
|
24
17
|
NON_METADATA_GROUPS = %w[System File ExifTool Composite].freeze
|
|
25
18
|
|
|
26
|
-
# ASCII wordmark shown at the top of --help / --version. Printed by `banner`
|
|
27
|
-
# (see there for why it's colored line-by-line).
|
|
28
19
|
LOGO = <<~ART
|
|
29
20
|
███╗ ███╗███████╗████████╗ █████╗ ██████╗██╗ ███████╗ █████╗ ███╗ ██╗
|
|
30
21
|
████╗ ████║██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔════╝██╔══██╗████╗ ██║
|
|
@@ -36,21 +27,31 @@ module Metaclean
|
|
|
36
27
|
|
|
37
28
|
module_function
|
|
38
29
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
def configure(quiet: false, redact_values: false)
|
|
31
|
+
@quiet = quiet
|
|
32
|
+
@redact_values = redact_values
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def quiet?
|
|
36
|
+
@quiet == true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def redact_values?
|
|
40
|
+
@redact_values == true
|
|
41
|
+
end
|
|
42
|
+
|
|
42
43
|
def color?
|
|
43
44
|
return @color if defined?(@color)
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
@color = if !ENV['NO_COLOR'].to_s.empty?
|
|
47
|
+
false
|
|
48
|
+
elsif !ENV['FORCE_COLOR'].to_s.empty?
|
|
49
|
+
true
|
|
50
|
+
else
|
|
51
|
+
$stdout.tty?
|
|
52
|
+
end
|
|
51
53
|
end
|
|
52
54
|
|
|
53
|
-
# Wrap text in a color, or pass it through plain when colors are off.
|
|
54
55
|
def c(text, color)
|
|
55
56
|
text = printable(text)
|
|
56
57
|
return text unless color?
|
|
@@ -58,35 +59,30 @@ module Metaclean
|
|
|
58
59
|
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
59
60
|
end
|
|
60
61
|
|
|
61
|
-
# Red ASCII wordmark (matches Ruby's brand color) + one-line tagline for
|
|
62
|
-
# --help / --version. Colored line-by-line on purpose: `c` runs text through
|
|
63
|
-
# `printable`, which turns control chars (including the heredoc's newlines)
|
|
64
|
-
# into spaces — so coloring the whole block at once would collapse the logo
|
|
65
|
-
# onto one line.
|
|
66
62
|
def banner
|
|
67
63
|
LOGO.each_line { |line| puts c(line.chomp, :red) }
|
|
68
64
|
puts c(' strip EXIF · IPTC · XMP · GPS · ID3 — leave the file clean', :gray)
|
|
69
65
|
end
|
|
70
66
|
|
|
71
67
|
def header(text)
|
|
68
|
+
return if quiet?
|
|
69
|
+
|
|
72
70
|
puts
|
|
73
71
|
puts c('━' * 64, :gray)
|
|
74
72
|
puts c(text, :bold)
|
|
75
73
|
puts c('━' * 64, :gray)
|
|
76
74
|
end
|
|
77
75
|
|
|
78
|
-
def section(text); puts c("▸ #{text}", :cyan)
|
|
79
|
-
def info(text); puts c(" #{text}", :gray)
|
|
80
|
-
def success(text); puts c("✓ #{text}", :green)
|
|
81
|
-
def warning(text);
|
|
76
|
+
def section(text); puts c("▸ #{text}", :cyan) unless quiet?; end
|
|
77
|
+
def info(text); puts c(" #{text}", :gray) unless quiet?; end
|
|
78
|
+
def success(text); puts c("✓ #{text}", :green) unless quiet?; end
|
|
79
|
+
def warning(text); warn c("⚠ #{text}", :yellow);end
|
|
82
80
|
|
|
83
|
-
# `error` returns a string instead of printing it — callers usually want
|
|
84
|
-
# to send it to STDERR via `warn`, not stdout via `puts`.
|
|
85
81
|
def error(text); c("✗ #{text}", :red); end
|
|
86
82
|
|
|
87
|
-
# Prints a metadata Hash as a grouped, indented table.
|
|
88
|
-
# `only_embedded:` filters out the System/File/etc. noise.
|
|
89
83
|
def metadata_table(meta, only_embedded: false)
|
|
84
|
+
return if quiet?
|
|
85
|
+
|
|
90
86
|
rows = meta.reject { |k, _| k == 'SourceFile' }
|
|
91
87
|
rows = rows.select { |k, _| embedded_key?(k) } if only_embedded
|
|
92
88
|
|
|
@@ -95,120 +91,110 @@ module Metaclean
|
|
|
95
91
|
return
|
|
96
92
|
end
|
|
97
93
|
|
|
98
|
-
# Group "GPS:*", "EXIF:*", … each into its own labeled sub-table.
|
|
99
94
|
grouped = rows.group_by { |k, _| group_of(k) }
|
|
100
95
|
grouped.sort_by { |g, _| g.to_s }.each do |group, pairs|
|
|
101
96
|
puts c(" [#{group}]", :magenta)
|
|
102
97
|
pairs.sort_by { |k, _| k.to_s }.each do |k, v|
|
|
103
98
|
tag = k.to_s.split(':', 2).last
|
|
104
|
-
line = format(' %-38s %s', truncate(tag, 38), truncate(
|
|
99
|
+
line = format(' %-38s %s', truncate(tag, 38), truncate(visible_value(v), 60))
|
|
105
100
|
puts c(line, :dim)
|
|
106
101
|
end
|
|
107
102
|
end
|
|
108
103
|
end
|
|
109
104
|
|
|
110
|
-
# Compares two metadata hashes (before vs after) and prints three
|
|
111
|
-
# sections: removed, changed, still-present. This is the "before/after"
|
|
112
|
-
# the user asked for.
|
|
113
105
|
def diff(before, after)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
removed =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
elsif !b.nil? && a != b
|
|
126
|
-
changed << [k, b, a]
|
|
127
|
-
elsif !b.nil?
|
|
128
|
-
kept << [k, b]
|
|
129
|
-
end
|
|
106
|
+
return if quiet?
|
|
107
|
+
|
|
108
|
+
removed, changed, kept = classify_diff(before, after)
|
|
109
|
+
render_removed(removed)
|
|
110
|
+
render_changed(changed)
|
|
111
|
+
render_kept(kept)
|
|
112
|
+
|
|
113
|
+
if [removed, changed, kept].all?(&:empty?)
|
|
114
|
+
info 'Nothing to strip — file already clean.'
|
|
115
|
+
elsif removed.empty? && changed.empty?
|
|
116
|
+
info 'No tags were removed — see "Still present" above.'
|
|
130
117
|
end
|
|
118
|
+
end
|
|
131
119
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
120
|
+
def classify_diff(before, after)
|
|
121
|
+
rows = [[], [], []]
|
|
122
|
+
keys = (before.keys + after.keys).uniq.select { |key| embedded_key?(key) }
|
|
123
|
+
keys.sort.each do |key|
|
|
124
|
+
old_value = before[key]
|
|
125
|
+
new_value = after[key]
|
|
126
|
+
if new_value.nil? && !old_value.nil?
|
|
127
|
+
rows[0] << [key, old_value]
|
|
128
|
+
elsif !old_value.nil? && new_value != old_value
|
|
129
|
+
rows[1] << [key, old_value, new_value]
|
|
130
|
+
elsif !old_value.nil?
|
|
131
|
+
rows[2] << [key, old_value]
|
|
136
132
|
end
|
|
137
133
|
end
|
|
134
|
+
rows
|
|
135
|
+
end
|
|
138
136
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
end
|
|
137
|
+
def render_removed(rows)
|
|
138
|
+
return if rows.empty?
|
|
139
|
+
|
|
140
|
+
section "Removed (#{rows.size})"
|
|
141
|
+
rows.each do |key, value|
|
|
142
|
+
puts " #{c('-', :red)} #{c(key, :red)} #{c(truncate(visible_value(value), 60), :gray)}"
|
|
146
143
|
end
|
|
144
|
+
end
|
|
147
145
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
146
|
+
def render_changed(rows)
|
|
147
|
+
return if rows.empty?
|
|
148
|
+
|
|
149
|
+
section "Changed (#{rows.size})"
|
|
150
|
+
rows.each do |key, old_value, new_value|
|
|
151
|
+
puts " #{c('~', :yellow)} #{c(key, :yellow)}"
|
|
152
|
+
puts " #{c('-', :red)} #{truncate(visible_value(old_value), 60)}"
|
|
153
|
+
puts " #{c('+', :green)} #{truncate(visible_value(new_value), 60)}"
|
|
153
154
|
end
|
|
155
|
+
end
|
|
154
156
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
def render_kept(rows)
|
|
158
|
+
return if rows.empty?
|
|
159
|
+
|
|
160
|
+
section "Still present (#{rows.size})"
|
|
161
|
+
rows.each do |key, value|
|
|
162
|
+
puts " #{c('=', :gray)} #{c(key, :gray)} #{c(truncate(visible_value(value), 60), :gray)}"
|
|
159
163
|
end
|
|
160
164
|
end
|
|
161
165
|
|
|
162
|
-
# Group name out of "Group:Tag" (split caps at 2 so a ":" in the value is safe).
|
|
163
166
|
def group_of(key)
|
|
164
167
|
key.to_s.split(':', 2).first.to_s
|
|
165
168
|
end
|
|
166
169
|
|
|
167
|
-
# True when `key` names real embedded metadata: not the SourceFile
|
|
168
|
-
# bookkeeping key, and not one of the System/File/ExifTool/Composite
|
|
169
|
-
# groups that describe the file rather than its embedded tags. Single
|
|
170
|
-
# source of truth for "is this a tag we actually stripped?" — shared by
|
|
171
|
-
# the table, diff, count, removed-count and privacy-residual checks.
|
|
172
170
|
def embedded_key?(key)
|
|
173
171
|
key != 'SourceFile' && !NON_METADATA_GROUPS.include?(group_of(key))
|
|
174
172
|
end
|
|
175
173
|
|
|
176
|
-
# Make any value safe to print on a single line. Hashes/Arrays get
|
|
177
|
-
# `inspect` (shows their structure); strings are collapsed to single
|
|
178
|
-
# spaces so a multiline tag value doesn't wreck the table.
|
|
179
174
|
def format_value(v)
|
|
180
175
|
case v
|
|
181
176
|
when Hash, Array then printable(v.inspect)
|
|
182
177
|
else
|
|
183
|
-
# Guard the regexp gsub against invalid-encoding tag values — gsub raises
|
|
184
|
-
# ArgumentError on them. Exiftool.read already scrubs; this is belt-and-
|
|
185
|
-
# suspenders so the display layer can never crash the run on hostile bytes.
|
|
186
178
|
s = printable(v)
|
|
187
179
|
s.gsub(/\s+/, ' ')
|
|
188
180
|
end
|
|
189
181
|
end
|
|
190
182
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
# (C1, U+0080–U+009F, holds the 8-bit CSI/OSC introducers some terminals honor).
|
|
183
|
+
def visible_value(value)
|
|
184
|
+
redact_values? ? '[redacted]' : format_value(value)
|
|
185
|
+
end
|
|
186
|
+
|
|
196
187
|
def printable(text)
|
|
197
188
|
s = text.to_s
|
|
198
189
|
s = s.scrub unless s.valid_encoding?
|
|
199
190
|
s.gsub(/[[:cntrl:]]/, ' ')
|
|
200
191
|
end
|
|
201
192
|
|
|
202
|
-
# Truncate to N chars with a single-character ellipsis. We use "…"
|
|
203
|
-
# (one Unicode char) instead of "..." so the truncation doesn't itself
|
|
204
|
-
# spill over the budget.
|
|
205
193
|
def truncate(s, n)
|
|
206
194
|
s = s.to_s
|
|
207
195
|
s.length > n ? "#{s[0, n - 1]}…" : s
|
|
208
196
|
end
|
|
209
197
|
|
|
210
|
-
# How many "real" embedded tags are there? Used for the
|
|
211
|
-
# "Before (24 embedded tags) → After (0)" summary line.
|
|
212
198
|
def count_embedded(meta)
|
|
213
199
|
meta.keys.count { |k| embedded_key?(k) }
|
|
214
200
|
end
|
data/lib/metaclean/exiftool.rb
CHANGED
|
@@ -1,66 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Thin wrapper around the external `exiftool` binary.
|
|
4
|
-
#
|
|
5
|
-
# Open3.capture3 with multiple args bypasses the shell, so a filename like
|
|
6
|
-
# `cat; rm -rf /` is one argument, not a command. That's not the whole story:
|
|
7
|
-
# exiftool still parses its own arguments, so a filename beginning with "-"
|
|
8
|
-
# (e.g. `-config`) would be read as an option. Every path goes through
|
|
9
|
-
# `Metaclean.safe_path`, which prefixes a leading dash with "./" so it's
|
|
10
|
-
# always seen as a filename.
|
|
11
|
-
|
|
12
|
-
require 'open3'
|
|
13
3
|
require 'json'
|
|
14
4
|
|
|
15
5
|
module Metaclean
|
|
16
6
|
module Exiftool
|
|
17
7
|
module_function
|
|
18
8
|
|
|
19
|
-
# True if `exiftool` is on PATH. Memoized so repeated checks don't re-spawn
|
|
20
|
-
# it (defined? not nil? — the cached value can legitimately be false).
|
|
21
9
|
def available?
|
|
22
10
|
return @available if defined?(@available)
|
|
23
11
|
|
|
24
|
-
out, _err, status =
|
|
12
|
+
out, _err, status = Metaclean.capture3(
|
|
13
|
+
'exiftool', '-ver',
|
|
14
|
+
timeout: Metaclean::PROBE_TIMEOUT,
|
|
15
|
+
max_output: Metaclean::PROBE_MAX_OUTPUT_BYTES
|
|
16
|
+
)
|
|
25
17
|
@available = status.success?
|
|
26
|
-
# Stash the version off the same call so `version` need not re-spawn.
|
|
27
18
|
@version = @available ? out.strip : nil
|
|
28
19
|
@available
|
|
29
|
-
rescue Errno::ENOENT
|
|
20
|
+
rescue Errno::ENOENT, Error
|
|
30
21
|
@version = nil
|
|
31
|
-
@available = false
|
|
22
|
+
@available = false
|
|
32
23
|
end
|
|
33
24
|
|
|
34
|
-
# Returns the version string, or nil if exiftool is missing/broken.
|
|
35
|
-
# Captured by `available?`, so this never re-runs the binary.
|
|
36
25
|
def version
|
|
37
26
|
available? ? @version : nil
|
|
38
27
|
end
|
|
39
28
|
|
|
40
|
-
# Reads metadata from a file and returns a flat Hash of "Group:Tag" => value.
|
|
41
|
-
#
|
|
42
|
-
# ExifTool flag glossary:
|
|
43
|
-
# -j JSON output (machine-parseable)
|
|
44
|
-
# -G1 Include the family-1 group name. NB: with -G1 mainstream EXIF
|
|
45
|
-
# tags appear under "IFD0"/"ExifIFD"/"IFD1", not "EXIF" (that's
|
|
46
|
-
# the family-0 name); GPS/IPTC/XMP-dc keep those group names.
|
|
47
|
-
# -a Allow duplicate tags (some formats have several with same name)
|
|
48
|
-
# -u Include unknown/unidentified tags
|
|
49
|
-
# -s Short tag names (no descriptions)
|
|
50
|
-
# -n Numeric values (no human formatting like "1/100 sec")
|
|
51
|
-
# -api largefilesupport=1 Allow files >4 GB
|
|
52
29
|
def read(path)
|
|
53
30
|
out, err, status = Metaclean.capture3(
|
|
54
31
|
'exiftool', '-j', '-G1', '-a', '-u', '-s', '-n', '-api', 'largefilesupport=1',
|
|
55
32
|
Metaclean.safe_path(path)
|
|
56
33
|
)
|
|
57
|
-
raise Error, "ExifTool read failed: #{err
|
|
34
|
+
raise Error, "ExifTool read failed: #{read_error_detail(out, err)}" unless status.success?
|
|
58
35
|
|
|
59
|
-
# ExifTool's JSON output is an array (one entry per file). We always
|
|
60
|
-
# pass one file, so we take the first element. `|| {}` handles the
|
|
61
|
-
# edge case where exiftool returns an empty array. A non-array shape is
|
|
62
|
-
# unexpected — bail with a clear error instead of crashing later on
|
|
63
|
-
# `.first` returning a Hash/scalar.
|
|
64
36
|
data = JSON.parse(out)
|
|
65
37
|
raise Error, 'Unexpected ExifTool output (expected a JSON array)' unless data.is_a?(Array)
|
|
66
38
|
|
|
@@ -69,12 +41,17 @@ module Metaclean
|
|
|
69
41
|
raise Error, "Could not parse ExifTool output: #{e.message}"
|
|
70
42
|
end
|
|
71
43
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
44
|
+
def read_error_detail(out, err)
|
|
45
|
+
return err.strip unless err.strip.empty?
|
|
46
|
+
|
|
47
|
+
data = JSON.parse(out)
|
|
48
|
+
entry = data.is_a?(Array) ? data.first : nil
|
|
49
|
+
detail = entry && (entry['ExifTool:Error'] || entry['Error'])
|
|
50
|
+
detail ? detail.to_s.strip : 'unknown error'
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
'unknown error'
|
|
53
|
+
end
|
|
54
|
+
|
|
78
55
|
def scrub_encoding(obj)
|
|
79
56
|
case obj
|
|
80
57
|
when String then obj.valid_encoding? ? obj : obj.scrub
|
|
@@ -84,35 +61,27 @@ module Metaclean
|
|
|
84
61
|
end
|
|
85
62
|
end
|
|
86
63
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# safe because the post-strip residual re-read still gates the :cleaned status.
|
|
96
|
-
WRITE_UNSUPPORTED_RE = /not yet support|can't currently write|writing of .* files/i
|
|
97
|
-
|
|
98
|
-
# Removes every removable tag, in place. Returns true on success,
|
|
99
|
-
# :unsupported when ExifTool cannot write the format, and raises on failure.
|
|
100
|
-
#
|
|
101
|
-
# `-all=` sets every tag to empty, which deletes them. `-overwrite_original`
|
|
102
|
-
# makes ExifTool replace the file directly instead of writing `file_original`
|
|
103
|
-
# next to it. `-api largefilesupport=1` lets files larger than 4 GB through.
|
|
64
|
+
WRITE_UNSUPPORTED_RE = /
|
|
65
|
+
writing\sof\s(?:this\stype\sof\s|[^\s]+\s)?files?\sis\snot\s(?:yet\s)?supported |
|
|
66
|
+
can't\scurrently\swrite |
|
|
67
|
+
does\snot\syet\ssupport\swriting
|
|
68
|
+
/ix
|
|
69
|
+
|
|
70
|
+
PRESERVE_TAGS = %w[Orientation ICC_Profile].freeze
|
|
71
|
+
|
|
104
72
|
def strip!(path, also_delete: [])
|
|
105
|
-
|
|
106
|
-
# IFD0 directory and leaves its tags (Artist, Software, …) behind. So we
|
|
107
|
-
# ALSO delete the known privacy tags by name and clear the GPS group: both
|
|
108
|
-
# are no-ops where `-all=` already removed them (e.g. JPEG), but they make
|
|
109
|
-
# the strip complete AND lossless (no re-encode) for IFD0-preserving formats.
|
|
73
|
+
FileOps.regular_stat!(path)
|
|
110
74
|
args = ['exiftool', '-all=', '-gps:all=']
|
|
111
75
|
also_delete.each { |tag| args << "-#{tag}=" }
|
|
76
|
+
args.concat(['-tagsfromfile', '@'])
|
|
77
|
+
PRESERVE_TAGS.each { |tag| args << "-#{tag}" }
|
|
112
78
|
args.concat(['-overwrite_original', '-q', '-q', '-api', 'largefilesupport=1', Metaclean.safe_path(path)])
|
|
113
79
|
|
|
114
80
|
_out, err, status = Metaclean.capture3(*args)
|
|
115
|
-
|
|
81
|
+
if status.success?
|
|
82
|
+
FileOps.validate_output!(path)
|
|
83
|
+
return true
|
|
84
|
+
end
|
|
116
85
|
return :unsupported if err.match?(WRITE_UNSUPPORTED_RE)
|
|
117
86
|
|
|
118
87
|
raise Error, "ExifTool strip failed: #{err.strip}"
|