metaclean 1.0.2
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +132 -0
- data/bin/metaclean +29 -0
- data/lib/metaclean/cli.rb +169 -0
- data/lib/metaclean/display.rb +197 -0
- data/lib/metaclean/exiftool.rb +140 -0
- data/lib/metaclean/mat2.rb +123 -0
- data/lib/metaclean/qpdf.rb +75 -0
- data/lib/metaclean/runner.rb +451 -0
- data/lib/metaclean/strategy.rb +96 -0
- data/lib/metaclean/version.rb +11 -0
- data/lib/metaclean.rb +33 -0
- metadata +61 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dd45cefaffcadee5f5c740e5e469cba971140c9a2d448d54d433970c985aedf6
|
|
4
|
+
data.tar.gz: 3c998706d833d78bbaa039ad537d6ed6a4918cb443294f1249597ca88c1b9799
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 81170d9320c56d98a22939c92ca91e40c896ec1e1383880face355a5c372efdb77c6b94cb3614e98a6c6840296c14a1a7eadb6d885a093853015832cdeef2e23
|
|
7
|
+
data.tar.gz: 67855bc4b712f6bf1601d9224f672006d4d0f3b9a9ed1c73888b204d45393f1f71323dc9ade31e527205771a726274113cfdd4082943ab3b62a102851cbc343c
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 metaclean contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# metaclean
|
|
2
|
+
|
|
3
|
+
A small cross-platform Ruby CLI that strips metadata from almost any file
|
|
4
|
+
(images, audio, video, PDFs, Office documents, …) and shows you a colored
|
|
5
|
+
before/after diff of exactly what was removed.
|
|
6
|
+
|
|
7
|
+
It wraps three battle-tested tools and routes each file to the right one:
|
|
8
|
+
|
|
9
|
+
- **ExifTool** — the broadest format coverage (EXIF, IPTC, XMP, GPS, ID3, …)
|
|
10
|
+
- **mat2** — stricter on `.docx` / `.pdf` / `.png` (rebuilds the file)
|
|
11
|
+
- **qpdf** — rebuilds PDFs and clears residual metadata in unused streams
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# 1. Install the underlying tools (ExifTool is required, mat2 + qpdf optional)
|
|
17
|
+
brew install exiftool mat2 qpdf # macOS
|
|
18
|
+
sudo apt install libimage-exiftool-perl mat2 qpdf # Debian / Ubuntu
|
|
19
|
+
sudo dnf install perl-Image-ExifTool mat2 qpdf # Fedora
|
|
20
|
+
sudo pacman -S perl-image-exiftool mat2 qpdf # Arch
|
|
21
|
+
scoop install exiftool qpdf # Windows
|
|
22
|
+
|
|
23
|
+
# 2. Install metaclean after it has been published to RubyGems
|
|
24
|
+
gem install metaclean
|
|
25
|
+
metaclean --version
|
|
26
|
+
metaclean --help
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
From a source checkout before publishing:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git clone https://github.com/26zl/metaclean.git && cd metaclean
|
|
33
|
+
chmod +x bin/metaclean
|
|
34
|
+
bundle install
|
|
35
|
+
bundle exec rake install
|
|
36
|
+
metaclean --version
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
You can also run the checkout without installing it by using
|
|
40
|
+
`./bin/metaclean`.
|
|
41
|
+
|
|
42
|
+
### Windows
|
|
43
|
+
|
|
44
|
+
You also need Ruby itself. The recommended installer is
|
|
45
|
+
[**RubyInstaller**](https://rubyinstaller.org/) — it ships Ruby plus the
|
|
46
|
+
MSYS2 toolchain (DevKit) that some gems need to compile native extensions.
|
|
47
|
+
`scoop install ruby` and `choco install ruby` install the same RubyInstaller
|
|
48
|
+
distribution under the hood.
|
|
49
|
+
|
|
50
|
+
Note: `mat2` is hard to install on native Windows (it depends on Python +
|
|
51
|
+
GTK). If you want full coverage, the simplest route is
|
|
52
|
+
**[WSL2](https://learn.microsoft.com/windows/wsl/install)** with Ubuntu, then
|
|
53
|
+
follow the Debian/Ubuntu line above. On native Windows without `mat2`,
|
|
54
|
+
metaclean still works — ExifTool + qpdf cover the common cases.
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
These examples assume metaclean is installed as a gem. From a source checkout,
|
|
59
|
+
replace `metaclean` with `./bin/metaclean`.
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Show metadata, do not modify
|
|
63
|
+
metaclean --inspect photo.jpg
|
|
64
|
+
|
|
65
|
+
# Clean a file → writes photo_clean.jpg next to the original
|
|
66
|
+
metaclean photo.jpg
|
|
67
|
+
|
|
68
|
+
# Overwrite the original (a .bak is kept by default)
|
|
69
|
+
metaclean --in-place photo.jpg
|
|
70
|
+
|
|
71
|
+
# Clean a whole folder, recursively, no prompts, no backups
|
|
72
|
+
metaclean -r --in-place --no-backup --force ./vacation
|
|
73
|
+
|
|
74
|
+
# See what would change without writing anything
|
|
75
|
+
metaclean --dry-run photo.jpg
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Flags
|
|
79
|
+
|
|
80
|
+
| Flag | What it does |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `--inspect` | Read-only — print metadata, never write |
|
|
83
|
+
| `--json` | JSON output (with `--inspect`) |
|
|
84
|
+
| `--dry-run` | Simulate cleaning, show diff, write nothing |
|
|
85
|
+
| `-i`, `--in-place` | Overwrite originals |
|
|
86
|
+
| `--no-backup` | Do not keep `<file>.bak` (with `--in-place`) |
|
|
87
|
+
| `-r`, `--recursive` | Recurse into directories |
|
|
88
|
+
| `--types=jpg,png,…` | Only process these extensions |
|
|
89
|
+
| `--follow-symlinks` | Follow symlinks (default: skip) |
|
|
90
|
+
| `--keep-orientation` | Preserve EXIF Orientation |
|
|
91
|
+
| `--keep-color-profile` | Preserve embedded ICC profile |
|
|
92
|
+
| `--exiftool-only` | Use only ExifTool |
|
|
93
|
+
| `--no-mat2` / `--no-qpdf` / `--no-exiftool` | Disable a specific tool |
|
|
94
|
+
| `-f`, `--force` | Skip the confirmation prompt |
|
|
95
|
+
| `--strict-verify` | Exit non-zero if privacy tags survive |
|
|
96
|
+
|
|
97
|
+
## Publishing
|
|
98
|
+
|
|
99
|
+
The release workflow builds a `.gem` file for every `v*` tag. If the GitHub
|
|
100
|
+
repository has a `RUBYGEMS_API_KEY` secret, the workflow also publishes the gem
|
|
101
|
+
to RubyGems.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
git tag v1.0.1
|
|
105
|
+
git push origin v1.0.1
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Safety
|
|
109
|
+
|
|
110
|
+
- All shell-outs use argument arrays (`Open3.capture3(*args)`), so filenames
|
|
111
|
+
with spaces, quotes, or shell metacharacters are safe.
|
|
112
|
+
- `--in-place` writes atomically: the file is built in a temp file and
|
|
113
|
+
renamed into place, so a crash mid-run cannot leave a half-written original.
|
|
114
|
+
- Symlinks are skipped by default.
|
|
115
|
+
- Filename collisions (`photo_clean.jpg` already exists, `.bak` already
|
|
116
|
+
exists) are resolved with `_1`, `_2`, … suffixes.
|
|
117
|
+
- After cleaning, metaclean re-reads the file and warns if known
|
|
118
|
+
privacy-relevant tags (GPS, MakerNotes, Author, camera serial number, etc.)
|
|
119
|
+
survived. With `--strict-verify` this becomes a non-zero exit code.
|
|
120
|
+
|
|
121
|
+
## What it does *not* do
|
|
122
|
+
|
|
123
|
+
- Steganography (data hidden inside the pixel/audio data itself).
|
|
124
|
+
- Filesystem metadata (mtime, ownership) — that's the OS, not the file.
|
|
125
|
+
- Office macros or PDF JavaScript — open untrusted files in a sandbox.
|
|
126
|
+
|
|
127
|
+
Keep ExifTool, mat2, and qpdf updated; they parse hostile binary formats and
|
|
128
|
+
have had CVEs in the past.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT
|
data/bin/metaclean
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# ───────────────────────────────────────────────────────────────────────────
|
|
5
|
+
# bin/metaclean — entry point invoked by the user's shell.
|
|
6
|
+
#
|
|
7
|
+
# The shebang `#!/usr/bin/env ruby` makes the file executable on macOS/Linux
|
|
8
|
+
# (after `chmod +x`). `env` looks up Ruby on PATH so it works regardless of
|
|
9
|
+
# where Ruby is installed (Homebrew, rbenv, asdf, etc.).
|
|
10
|
+
#
|
|
11
|
+
# `frozen_string_literal: true` is a Ruby 3 magic comment: every string in
|
|
12
|
+
# this file is implicitly frozen (immutable). This prevents accidental
|
|
13
|
+
# mutation and gives a small performance/memory win.
|
|
14
|
+
# ───────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
# Add `<repo>/lib` to Ruby's load path so `require 'metaclean'` finds our code
|
|
17
|
+
# without needing the gem installed. `__dir__` is the directory of this file
|
|
18
|
+
# (`bin/`), so `../lib` is the sibling `lib/` directory. `expand_path` turns
|
|
19
|
+
# the relative path into an absolute one.
|
|
20
|
+
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
21
|
+
|
|
22
|
+
# Pull in the whole library. See `lib/metaclean.rb` — that file `require`s
|
|
23
|
+
# every sub-component in dependency order.
|
|
24
|
+
require 'metaclean'
|
|
25
|
+
|
|
26
|
+
# Hand control to the CLI. `ARGV` is Ruby's built-in array of command-line
|
|
27
|
+
# arguments (everything after the script name). `CLI.start` parses them and
|
|
28
|
+
# decides what to do.
|
|
29
|
+
Metaclean::CLI.start(ARGV)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ───────────────────────────────────────────────────────────────────────────
|
|
4
|
+
# Command-line argument parser.
|
|
5
|
+
#
|
|
6
|
+
# We use Ruby's standard library `OptionParser` instead of a gem like Thor
|
|
7
|
+
# because it has zero dependencies and is plenty for our needs. The pattern is:
|
|
8
|
+
#
|
|
9
|
+
# 1. Define each flag inside an `OptionParser.new do |o| ... end` block.
|
|
10
|
+
# 2. `o.on(...)` takes the flag spec and a block that runs when the flag
|
|
11
|
+
# is seen. The block usually mutates `@options` to record the choice.
|
|
12
|
+
# 3. `parser.parse!(@argv)` consumes flags from `@argv` and leaves
|
|
13
|
+
# positional args (the file paths) behind.
|
|
14
|
+
# ───────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
require 'optparse'
|
|
17
|
+
|
|
18
|
+
module Metaclean
|
|
19
|
+
class CLI
|
|
20
|
+
# Class-level convenience: `Metaclean::CLI.start(ARGV)` reads cleaner
|
|
21
|
+
# than `Metaclean::CLI.new(ARGV).run`.
|
|
22
|
+
def self.start(argv)
|
|
23
|
+
new(argv).run
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(argv)
|
|
27
|
+
# `dup` makes a shallow copy so we can mutate `@argv` without
|
|
28
|
+
# surprising the caller (ARGV itself stays intact).
|
|
29
|
+
@argv = argv.dup
|
|
30
|
+
|
|
31
|
+
# All options default to safe/off values. `parse!` flips them
|
|
32
|
+
# selectively as it sees flags.
|
|
33
|
+
@options = {
|
|
34
|
+
recursive: false,
|
|
35
|
+
in_place: false,
|
|
36
|
+
no_backup: false,
|
|
37
|
+
force: false,
|
|
38
|
+
inspect_only: false,
|
|
39
|
+
format: :pretty,
|
|
40
|
+
keep_orientation: false,
|
|
41
|
+
keep_color_profile: false,
|
|
42
|
+
dry_run: false,
|
|
43
|
+
follow_symlinks: false,
|
|
44
|
+
strict_verify: false,
|
|
45
|
+
no_mat2: false,
|
|
46
|
+
no_qpdf: false,
|
|
47
|
+
no_exiftool: false,
|
|
48
|
+
exiftool_only: false,
|
|
49
|
+
types: nil
|
|
50
|
+
}
|
|
51
|
+
@paths = []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Top-level dispatcher. Catches our errors and exits with codes that
|
|
55
|
+
# shells/CI can act on:
|
|
56
|
+
# 0 → success
|
|
57
|
+
# 1 → general failure
|
|
58
|
+
# 2 → ExifTool missing (specific install hint shown)
|
|
59
|
+
# 130→ user pressed Ctrl-C (matches the standard SIGINT exit code)
|
|
60
|
+
def run
|
|
61
|
+
parse!
|
|
62
|
+
runner = Runner.new(@options)
|
|
63
|
+
if @options[:inspect_only]
|
|
64
|
+
runner.inspect_paths(@paths)
|
|
65
|
+
else
|
|
66
|
+
runner.clean_paths(@paths)
|
|
67
|
+
end
|
|
68
|
+
rescue ExiftoolMissing => e
|
|
69
|
+
warn Display.error('ExifTool missing')
|
|
70
|
+
warn e.message
|
|
71
|
+
exit 2
|
|
72
|
+
rescue Error => e
|
|
73
|
+
warn Display.error(e.message)
|
|
74
|
+
exit 1
|
|
75
|
+
rescue Interrupt
|
|
76
|
+
# Pressing Ctrl-C raises `Interrupt`. Catching it lets us print a
|
|
77
|
+
# clean message instead of a Ruby stack trace.
|
|
78
|
+
warn "\n#{Display.error('Interrupted.')}"
|
|
79
|
+
exit 130
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def parse!
|
|
85
|
+
parser = OptionParser.new do |o|
|
|
86
|
+
# The banner shows up at the top of `--help`.
|
|
87
|
+
o.banner = 'Usage: metaclean [options] <path> [<path>...]'
|
|
88
|
+
o.separator ''
|
|
89
|
+
o.separator 'Cross-platform metadata cleaner. Strips EXIF, IPTC, XMP, GPS,'
|
|
90
|
+
o.separator 'MakerNotes, ID3, document properties, etc. — uses ExifTool, mat2'
|
|
91
|
+
o.separator 'and qpdf together for maximum coverage.'
|
|
92
|
+
o.separator ''
|
|
93
|
+
|
|
94
|
+
# Each `o.on` registers a flag. The block runs when that flag is
|
|
95
|
+
# found in argv. `_v` is the captured value (unused for booleans).
|
|
96
|
+
o.separator 'Modes:'
|
|
97
|
+
o.on('--inspect', 'Only show metadata, do not modify files') { @options[:inspect_only] = true }
|
|
98
|
+
o.on('--json', 'Output as JSON (with --inspect)') { @options[:format] = :json }
|
|
99
|
+
o.on('--dry-run', 'Simulate cleaning, show diff, write nothing') { @options[:dry_run] = true }
|
|
100
|
+
|
|
101
|
+
o.separator ''
|
|
102
|
+
o.separator 'Output:'
|
|
103
|
+
o.on('-i', '--in-place', 'Overwrite originals (default: write *_clean.<ext>)') { @options[:in_place] = true }
|
|
104
|
+
o.on('--no-backup', "Don't keep a .bak when --in-place") { @options[:no_backup] = true }
|
|
105
|
+
|
|
106
|
+
o.separator ''
|
|
107
|
+
o.separator 'Selection:'
|
|
108
|
+
o.on('-r', '--recursive', 'Recurse into directories') { @options[:recursive] = true }
|
|
109
|
+
o.on('--follow-symlinks', 'Follow symlinks (default: skip them)') { @options[:follow_symlinks] = true }
|
|
110
|
+
# `--types=LIST, Array` tells OptionParser to split the value on
|
|
111
|
+
# commas. So `--types=jpg,png` arrives as ["jpg", "png"].
|
|
112
|
+
o.on('--types=LIST', Array, 'Only process these extensions (e.g. jpg,png,pdf)') do |v|
|
|
113
|
+
@options[:types] = v.map { |x| x.to_s.downcase.delete('.') }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
o.separator ''
|
|
117
|
+
o.separator 'Tool selection:'
|
|
118
|
+
o.on('--exiftool-only', 'Use only ExifTool (skip mat2 and qpdf)') { @options[:exiftool_only] = true }
|
|
119
|
+
o.on('--no-mat2', 'Disable mat2 even if available') { @options[:no_mat2] = true }
|
|
120
|
+
o.on('--no-qpdf', 'Disable qpdf even if available') { @options[:no_qpdf] = true }
|
|
121
|
+
o.on('--no-exiftool', 'Disable ExifTool') { @options[:no_exiftool] = true }
|
|
122
|
+
|
|
123
|
+
o.separator ''
|
|
124
|
+
o.separator 'Preservation:'
|
|
125
|
+
o.on('--keep-orientation', 'Preserve EXIF Orientation tag') { @options[:keep_orientation] = true }
|
|
126
|
+
o.on('--keep-color-profile', 'Preserve embedded ICC profile') { @options[:keep_color_profile] = true }
|
|
127
|
+
|
|
128
|
+
o.separator ''
|
|
129
|
+
o.separator 'Verification:'
|
|
130
|
+
o.on('-f', '--force', 'Skip confirmation prompt') { @options[:force] = true }
|
|
131
|
+
o.on('--strict-verify', 'Exit non-zero if privacy tags survive') { @options[:strict_verify] = true }
|
|
132
|
+
|
|
133
|
+
o.separator ''
|
|
134
|
+
o.separator 'Other:'
|
|
135
|
+
o.on('-h', '--help') { puts o; exit }
|
|
136
|
+
o.on('-v', '--version') do
|
|
137
|
+
puts "metaclean #{Metaclean::VERSION}"
|
|
138
|
+
# `&.split&.last` is safe-navigation: if `Qpdf.version` is nil
|
|
139
|
+
# (qpdf not installed), the chain short-circuits to nil rather
|
|
140
|
+
# than blowing up with NoMethodError.
|
|
141
|
+
puts " exiftool: #{Exiftool.version || 'not found'}"
|
|
142
|
+
puts " mat2: #{Mat2.version || 'not found'}"
|
|
143
|
+
puts " qpdf: #{Qpdf.version&.split&.last || 'not found'}"
|
|
144
|
+
exit
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# `parse!` mutates @argv in place: known flags are consumed,
|
|
149
|
+
# positional args (file paths) are left behind.
|
|
150
|
+
begin
|
|
151
|
+
parser.parse!(@argv)
|
|
152
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
|
|
153
|
+
# Bad flag → show the message + the help text and exit non-zero.
|
|
154
|
+
warn Display.error(e.message)
|
|
155
|
+
warn parser
|
|
156
|
+
exit 1
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# No paths after flags → user probably ran `metaclean` with no args.
|
|
160
|
+
# Show help and exit non-zero so scripts notice.
|
|
161
|
+
if @argv.empty?
|
|
162
|
+
puts parser
|
|
163
|
+
exit 1
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
@paths = @argv.dup
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ───────────────────────────────────────────────────────────────────────────
|
|
4
|
+
# Anything that prints to the terminal lives here: ANSI colors, headers,
|
|
5
|
+
# tables, the before/after diff. Keeping presentation in one module means
|
|
6
|
+
# the rest of the codebase stays focused on logic.
|
|
7
|
+
#
|
|
8
|
+
# ANSI escape sequences:
|
|
9
|
+
# "\e[31m" turns the terminal text red.
|
|
10
|
+
# "\e[0m" resets all styling.
|
|
11
|
+
# A modern terminal interprets these; if you redirect to a file, they show
|
|
12
|
+
# up as garbage — that's why we check `tty?` before emitting them.
|
|
13
|
+
# ───────────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
module Metaclean
|
|
16
|
+
module Display
|
|
17
|
+
COLORS = {
|
|
18
|
+
reset: "\e[0m",
|
|
19
|
+
bold: "\e[1m",
|
|
20
|
+
dim: "\e[2m",
|
|
21
|
+
red: "\e[31m",
|
|
22
|
+
green: "\e[32m",
|
|
23
|
+
yellow: "\e[33m",
|
|
24
|
+
blue: "\e[34m",
|
|
25
|
+
magenta: "\e[35m",
|
|
26
|
+
cyan: "\e[36m",
|
|
27
|
+
gray: "\e[90m"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# ExifTool reports four "groups" that are descriptions of the file
|
|
31
|
+
# itself, not embedded metadata: System (filesystem stat), File (header
|
|
32
|
+
# info), ExifTool (its own version), Composite (computed values).
|
|
33
|
+
# Excluding these makes the diff focus on what actually got stripped.
|
|
34
|
+
NON_METADATA_GROUPS = %w[System File ExifTool Composite].freeze
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Decides whether to emit ANSI color codes. Colors are wrong when:
|
|
39
|
+
# * stdout is a pipe/file (not a terminal) — `tty?` is false there
|
|
40
|
+
# * NO_COLOR env var is set (de-facto convention, see no-color.org)
|
|
41
|
+
# * we're on classic Windows cmd.exe (modern Windows Terminal is fine,
|
|
42
|
+
# but to be safe we require an explicit FORCE_COLOR opt-in there)
|
|
43
|
+
def color?
|
|
44
|
+
return @color if defined?(@color)
|
|
45
|
+
|
|
46
|
+
# Per https://no-color.org: disable only when NO_COLOR is set to a
|
|
47
|
+
# non-empty value. An unset or empty NO_COLOR leaves colors on.
|
|
48
|
+
no_color = ENV['NO_COLOR'].to_s
|
|
49
|
+
@color = $stdout.tty? && no_color.empty? && !Gem.win_platform?
|
|
50
|
+
@color = true if ENV['FORCE_COLOR']
|
|
51
|
+
@color
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# `c` for "color". Wraps text in the requested color, or returns it
|
|
55
|
+
# plain if colors are disabled. The reset code at the end stops the
|
|
56
|
+
# color from bleeding into following output.
|
|
57
|
+
def c(text, color)
|
|
58
|
+
return text.to_s unless color?
|
|
59
|
+
|
|
60
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Visual section markers used throughout the runner's output. Keeping
|
|
64
|
+
# them here means a single change updates the look everywhere.
|
|
65
|
+
def header(text)
|
|
66
|
+
puts
|
|
67
|
+
puts c('━' * 64, :gray)
|
|
68
|
+
puts c(text, :bold)
|
|
69
|
+
puts c('━' * 64, :gray)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def section(text); puts c("▸ #{text}", :cyan); end
|
|
73
|
+
def info(text); puts c(" #{text}", :gray); end
|
|
74
|
+
def success(text); puts c("✓ #{text}", :green); end
|
|
75
|
+
def warning(text); puts c("⚠ #{text}", :yellow);end
|
|
76
|
+
|
|
77
|
+
# `error` returns a string instead of printing it — callers usually want
|
|
78
|
+
# to send it to STDERR via `warn`, not stdout via `puts`.
|
|
79
|
+
def error(text); c("✗ #{text}", :red); end
|
|
80
|
+
|
|
81
|
+
# Prints a metadata Hash as a grouped, indented table.
|
|
82
|
+
# `only_embedded:` filters out the System/File/etc. noise.
|
|
83
|
+
def metadata_table(meta, only_embedded: false)
|
|
84
|
+
rows = meta.reject { |k, _| k == 'SourceFile' }
|
|
85
|
+
rows = rows.reject { |k, _| NON_METADATA_GROUPS.include?(group_of(k)) } if only_embedded
|
|
86
|
+
|
|
87
|
+
if rows.empty?
|
|
88
|
+
info(only_embedded ? '(no embedded metadata)' : '(no metadata)')
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# `group_by` partitions an Enumerable into a Hash keyed by the block's
|
|
93
|
+
# result. Here we group all "GPS:*" tags together, all "EXIF:*" together,
|
|
94
|
+
# etc., then print each group as a labeled sub-table.
|
|
95
|
+
grouped = rows.group_by { |k, _| group_of(k) }
|
|
96
|
+
grouped.sort_by { |g, _| g.to_s }.each do |group, pairs|
|
|
97
|
+
puts c(" [#{group}]", :magenta)
|
|
98
|
+
pairs.sort_by { |k, _| k.to_s }.each do |k, v|
|
|
99
|
+
tag = k.to_s.split(':', 2).last
|
|
100
|
+
# `format` (alias of sprintf) does column alignment: %-38s = left-
|
|
101
|
+
# aligned, padded to 38 chars.
|
|
102
|
+
line = format(' %-38s %s', truncate(tag, 38), truncate(format_value(v), 60))
|
|
103
|
+
puts c(line, :dim)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Compares two metadata hashes (before vs after) and prints three
|
|
109
|
+
# sections: removed, changed, still-present. This is the "before/after"
|
|
110
|
+
# the user asked for.
|
|
111
|
+
def diff(before, after)
|
|
112
|
+
keys = (before.keys + after.keys).uniq
|
|
113
|
+
.reject { |k| k == 'SourceFile' }
|
|
114
|
+
.reject { |k| NON_METADATA_GROUPS.include?(group_of(k)) }
|
|
115
|
+
|
|
116
|
+
removed = []
|
|
117
|
+
changed = []
|
|
118
|
+
kept = []
|
|
119
|
+
|
|
120
|
+
# Classifying each key into one of three buckets keeps the rest of
|
|
121
|
+
# the method simple and testable.
|
|
122
|
+
keys.sort.each do |k|
|
|
123
|
+
b = before[k]
|
|
124
|
+
a = after[k]
|
|
125
|
+
if a.nil? && !b.nil?
|
|
126
|
+
removed << [k, b]
|
|
127
|
+
elsif !b.nil? && a != b
|
|
128
|
+
changed << [k, b, a]
|
|
129
|
+
elsif !b.nil?
|
|
130
|
+
kept << [k, b]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
if removed.any?
|
|
135
|
+
section "Removed (#{removed.size})"
|
|
136
|
+
removed.each do |k, b|
|
|
137
|
+
puts " #{c('-', :red)} #{c(k, :red)} #{c(truncate(format_value(b), 60), :gray)}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if changed.any?
|
|
142
|
+
section "Changed (#{changed.size})"
|
|
143
|
+
changed.each do |k, b, a|
|
|
144
|
+
puts " #{c('~', :yellow)} #{c(k, :yellow)}"
|
|
145
|
+
puts " #{c('-', :red)} #{truncate(format_value(b), 60)}"
|
|
146
|
+
puts " #{c('+', :green)} #{truncate(format_value(a), 60)}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if kept.any?
|
|
151
|
+
section "Still present (#{kept.size})"
|
|
152
|
+
kept.each do |k, b|
|
|
153
|
+
puts " #{c('=', :gray)} #{c(k, :gray)} #{c(truncate(format_value(b), 60), :gray)}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if removed.empty? && changed.empty? && kept.empty?
|
|
158
|
+
info 'Nothing to strip — file already clean.'
|
|
159
|
+
elsif removed.empty? && changed.empty?
|
|
160
|
+
info 'No tags were removed — see "Still present" above.'
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Pull the group name out of "Group:Tag". The `2` argument to split caps
|
|
165
|
+
# the result at 2 elements, so a value containing ":" doesn't break it.
|
|
166
|
+
def group_of(key)
|
|
167
|
+
key.to_s.split(':', 2).first.to_s
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Make any value safe to print on a single line. Hashes/Arrays get
|
|
171
|
+
# `inspect` (shows their structure); strings are collapsed to single
|
|
172
|
+
# spaces so a multiline tag value doesn't wreck the table.
|
|
173
|
+
def format_value(v)
|
|
174
|
+
case v
|
|
175
|
+
when Hash, Array then v.inspect
|
|
176
|
+
else v.to_s.gsub(/\s+/, ' ')
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Truncate to N chars with a single-character ellipsis. We use "…"
|
|
181
|
+
# (one Unicode char) instead of "..." so the truncation doesn't itself
|
|
182
|
+
# spill over the budget.
|
|
183
|
+
def truncate(s, n)
|
|
184
|
+
s = s.to_s
|
|
185
|
+
s.length > n ? "#{s[0, n - 1]}…" : s
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# How many "real" embedded tags are there? Used for the
|
|
189
|
+
# "Before (24 embedded tags) → After (0)" summary line.
|
|
190
|
+
def count_embedded(meta)
|
|
191
|
+
meta.keys
|
|
192
|
+
.reject { |k| k == 'SourceFile' }
|
|
193
|
+
.reject { |k| NON_METADATA_GROUPS.include?(group_of(k)) }
|
|
194
|
+
.size
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|