metaclean 1.0.2 → 4.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd45cefaffcadee5f5c740e5e469cba971140c9a2d448d54d433970c985aedf6
4
- data.tar.gz: 3c998706d833d78bbaa039ad537d6ed6a4918cb443294f1249597ca88c1b9799
3
+ metadata.gz: b514075fb238c6274263922e87d7218b6c27b86f46c07dfa6fe36e8a1387b382
4
+ data.tar.gz: 7eff78f7bd882717bc03cc6f98cb140faa4c9123d5b6a0806ccdb0e342cd3935
5
5
  SHA512:
6
- metadata.gz: 81170d9320c56d98a22939c92ca91e40c896ec1e1383880face355a5c372efdb77c6b94cb3614e98a6c6840296c14a1a7eadb6d885a093853015832cdeef2e23
7
- data.tar.gz: 67855bc4b712f6bf1601d9224f672006d4d0f3b9a9ed1c73888b204d45393f1f71323dc9ade31e527205771a726274113cfdd4082943ab3b62a102851cbc343c
6
+ metadata.gz: b674107a22b4965a30bd5c990c90fec5d4944828a8ca755f27124029383de2a543fdf73fad42934b7fa6c4600d0670ffc79ff1602a9b23caf4da9bec7e239ccf
7
+ data.tar.gz: d265c366f6b7f0c76acd866e883dc8459ab4e35c27ef092abb4488262e4a5726f7b812d00bba5419282a47d05c80ac3e5e4b97254d2849f3bb4320d7cb19e5b8
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  # MIT License
2
2
 
3
- Copyright (c) 2026 metaclean contributors
3
+ Copyright (c) 2026 Laurent Zogaj
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,32 +1,87 @@
1
1
  # metaclean
2
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.
3
+ ```
4
+ ███╗ ███╗███████╗████████╗ █████╗ ██████╗██╗ ███████╗ █████╗ ███╗ ██╗
5
+ ████╗ ████║██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔════╝██╔══██╗████╗ ██║
6
+ ██╔████╔██║█████╗ ██║ ███████║██║ ██║ █████╗ ███████║██╔██╗ ██║
7
+ ██║╚██╔╝██║██╔══╝ ██║ ██╔══██║██║ ██║ ██╔══╝ ██╔══██║██║╚██╗██║
8
+ ██║ ╚═╝ ██║███████╗ ██║ ██║ ██║╚██████╗███████╗███████╗██║ ██║██║ ╚████║
9
+ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝
10
+ strip EXIF · IPTC · XMP · GPS · ID3 — leave the file clean
11
+ ```
12
+
13
+ [![CI](https://img.shields.io/github/actions/workflow/status/26zl/metaclean/ci.yml?branch=main&label=CI)](https://github.com/26zl/metaclean/actions/workflows/ci.yml)
14
+ [![Gem](https://img.shields.io/gem/v/metaclean)](https://rubygems.org/gems/metaclean)
15
+ [![Ruby](https://img.shields.io/badge/ruby-%E2%89%A5%203.2-CC342D)](https://www.ruby-lang.org)
16
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
17
+
18
+ A small Ruby CLI that strips metadata from almost any file —
19
+ images, audio, video, PDFs, Office documents — and shows a colored before/after
20
+ diff of exactly what was removed.
6
21
 
7
- It wraps three battle-tested tools and routes each file to the right one:
22
+ It wraps four battle-tested tools and routes each file to the right one:
8
23
 
9
24
  - **ExifTool** — the broadest format coverage (EXIF, IPTC, XMP, GPS, ID3, …)
10
- - **mat2** — stricter on `.docx` / `.pdf` / `.png` (rebuilds the file)
25
+ - **mat2** — stricter on `.docx` / `.png` and Office/OpenDocument files (rebuilds the file)
11
26
  - **qpdf** — rebuilds PDFs and clears residual metadata in unused streams
27
+ - **ffmpeg** — strips the Matroska containers (`.mkv` / `.webm`) the others can't write, by remuxing losslessly (stream copy, no re-encode)
28
+
29
+ ## Why metaclean?
30
+
31
+ - **Verification-first:** it re-reads the cleaned file and refuses to write a
32
+ result when known privacy metadata survives.
33
+ - **Safer defaults:** it writes `*_clean` copies by default; `--in-place` keeps a
34
+ `.bak` and asks for confirmation unless `--force` is set.
35
+ - **Lossless routing:** it avoids mat2 paths that recompress JPEG/WebP or
36
+ downconvert TIFF, and uses ffmpeg stream-copy for Matroska.
37
+ - **Batch-friendly:** failed or unverified files exit non-zero, so scripts and CI
38
+ do not mistake uncertainty for success.
39
+
40
+ ## What it looks like
41
+
42
+ ```text
43
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
44
+ 📄 photo.jpg
45
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
46
+ ▸ Before (5 embedded tags)
47
+ [GPS]
48
+ GPSLatitude 59.9139
49
+ GPSLongitude 10.7522
50
+ [IFD0]
51
+ Artist Jane Doe
52
+ Make Apple
53
+ Model iPhone 15
54
+ Pipeline: exiftool
55
+ ✓ exiftool
56
+ ▸ After (0 embedded tags)
57
+ (no embedded metadata)
58
+ ▸ Diff
59
+ ▸ Removed (5)
60
+ - GPS:GPSLatitude 59.9139
61
+ - GPS:GPSLongitude 10.7522
62
+ - IFD0:Artist Jane Doe
63
+ - IFD0:Make Apple
64
+ - IFD0:Model iPhone 15
65
+ ✓ → photo_clean.jpg
66
+ ```
12
67
 
13
68
  ## Install
14
69
 
15
70
  ```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
71
+ # 1. Install the four required tools metaclean refuses to run without all of them
72
+ brew install exiftool mat2 qpdf ffmpeg # macOS
73
+ sudo apt install libimage-exiftool-perl mat2 qpdf ffmpeg # Debian / Ubuntu
74
+ sudo dnf install perl-Image-ExifTool mat2 qpdf ffmpeg # Fedora
75
+ sudo pacman -S perl-image-exiftool mat2 qpdf ffmpeg # Arch
76
+ # Windows: use WSL2 (see below), then the Debian / Ubuntu line above
77
+
78
+ # 2. Install metaclean from RubyGems
24
79
  gem install metaclean
25
80
  metaclean --version
26
81
  metaclean --help
27
82
  ```
28
83
 
29
- From a source checkout before publishing:
84
+ From a source checkout:
30
85
 
31
86
  ```bash
32
87
  git clone https://github.com/26zl/metaclean.git && cd metaclean
@@ -41,17 +96,31 @@ You can also run the checkout without installing it by using
41
96
 
42
97
  ### Windows
43
98
 
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.
99
+ Native Windows isn't supported: `mat2` depends on Python + GTK and doesn't
100
+ install cleanly there, and metaclean requires all four tools. Use
101
+ **[WSL2](https://learn.microsoft.com/windows/wsl/install)** with Ubuntu and
102
+ follow the Debian / Ubuntu line above everything runs inside WSL.
103
+
104
+ ### Android
49
105
 
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.
106
+ There's no native Android app, but the full CLI runs unchanged inside
107
+ **[Termux](https://f-droid.org/packages/com.termux/)** (install it from F-Droid,
108
+ not the Play Store) via a Debian proot — where `mat2` and the other three tools
109
+ install cleanly from `apt`:
110
+
111
+ ```bash
112
+ pkg install proot-distro
113
+ termux-setup-storage # tap "Allow" to grant access to your files
114
+ proot-distro install debian
115
+ proot-distro login debian --bind ~/storage/shared:/sdcard
116
+ # now inside Debian:
117
+ apt update && apt install -y ruby mat2 libimage-exiftool-perl qpdf ffmpeg
118
+ gem install metaclean
119
+ metaclean /sdcard/DCIM/Camera/photo.jpg
120
+ ```
121
+
122
+ The `--bind` is what lets metaclean reach your photos — without it Debian can't
123
+ see the phone's storage.
55
124
 
56
125
  ## Quick start
57
126
 
@@ -68,8 +137,8 @@ metaclean photo.jpg
68
137
  # Overwrite the original (a .bak is kept by default)
69
138
  metaclean --in-place photo.jpg
70
139
 
71
- # Clean a whole folder, recursively, no prompts, no backups
72
- metaclean -r --in-place --no-backup --force ./vacation
140
+ # Clean a whole folder, recursively, no prompts
141
+ metaclean -r --in-place --force ./vacation
73
142
 
74
143
  # See what would change without writing anything
75
144
  metaclean --dry-run photo.jpg
@@ -78,31 +147,27 @@ metaclean --dry-run photo.jpg
78
147
  ## Flags
79
148
 
80
149
  | Flag | What it does |
81
- |---|---|
150
+ | --- | --- |
82
151
  | `--inspect` | Read-only — print metadata, never write |
83
- | `--json` | JSON output (with `--inspect`) |
84
152
  | `--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`) |
153
+ | `-i`, `--in-place` | Overwrite originals (keeps a `<file>.bak`) |
87
154
  | `-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
155
  | `-f`, `--force` | Skip the confirmation prompt |
95
- | `--strict-verify` | Exit non-zero if privacy tags survive |
156
+ | `-h`, `--help` | Show usage and exit |
157
+ | `-v`, `--version` | Show metaclean's version **and** the detected versions of exiftool/mat2/qpdf/ffmpeg (prints `not found` for any missing) |
96
158
 
97
159
  ## Publishing
98
160
 
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.
161
+ The release workflow builds a `.gem` for every `v*` tag, attaches it to a
162
+ GitHub Release, and publishes it to RubyGems via a
163
+ [Trusted Publisher](https://guides.rubygems.org/trusted-publishing/) (OIDC) —
164
+ `rubygems/release-gem` with `id-token: write`. There is **no `RUBYGEMS_API_KEY`
165
+ secret**; the one-time prerequisite is registering this gem's Trusted Publisher
166
+ on rubygems.org.
102
167
 
103
168
  ```bash
104
- git tag v1.0.1
105
- git push origin v1.0.1
169
+ git tag v3.0.0
170
+ git push origin v3.0.0
106
171
  ```
107
172
 
108
173
  ## Safety
@@ -111,12 +176,22 @@ git push origin v1.0.1
111
176
  with spaces, quotes, or shell metacharacters are safe.
112
177
  - `--in-place` writes atomically: the file is built in a temp file and
113
178
  renamed into place, so a crash mid-run cannot leave a half-written original.
114
- - Symlinks are skipped by default.
179
+ - Symlinks are always skipped metaclean never cleans through a link.
115
180
  - Filename collisions (`photo_clean.jpg` already exists, `.bak` already
116
- exists) are resolved with `_1`, `_2`, … suffixes.
181
+ exists) are resolved with `_1`, `_2`, … suffixes, including late collisions
182
+ that appear while a file is being cleaned.
117
183
  - After cleaning, metaclean re-reads the file and warns if known
118
184
  privacy-relevant tags (GPS, MakerNotes, Author, camera serial number, etc.)
119
- survived. With `--strict-verify` this becomes a non-zero exit code.
185
+ survived.
186
+ - A file whose strip leaves a privacy residual is **never written** — no
187
+ `_clean` copy and no `--in-place` overwrite — it is reported failed and the
188
+ original is left untouched.
189
+ - metaclean requires ExifTool, mat2, qpdf, and ffmpeg, and refuses to run (with
190
+ install instructions, exit code 2) if any is missing — so the post-clean
191
+ residual check always runs.
192
+ - Naming no files (a missing path, or everything filtered out), failed files,
193
+ and unverified cleans exit non-zero, so scripts do not mistake uncertainty for
194
+ success.
120
195
 
121
196
  ## What it does *not* do
122
197
 
@@ -124,8 +199,20 @@ git push origin v1.0.1
124
199
  - Filesystem metadata (mtime, ownership) — that's the OS, not the file.
125
200
  - Office macros or PDF JavaScript — open untrusted files in a sandbox.
126
201
 
127
- Keep ExifTool, mat2, and qpdf updated; they parse hostile binary formats and
128
- have had CVEs in the past.
202
+ The post-clean "still present" check is bounded by what ExifTool can re-read.
203
+ For container formats that mat2 cleans but ExifTool only partially parses (e.g.
204
+ `.zip`, `.epub` internals), metadata may be removed that the verification can't
205
+ independently confirm — a clean report means "the tools ran", not "every byte
206
+ was audited".
207
+
208
+ **SVG:** some mat2 builds (e.g. 0.14.0 on recent Python) crash on SVG, and
209
+ ExifTool is read-only for it — so on those systems metaclean cannot clean
210
+ `.svg`. It reports the file as **failed** (exit 1) and leaves the original
211
+ untouched rather than claiming a false "clean". Where mat2 handles SVG, it
212
+ cleans normally.
213
+
214
+ Keep ExifTool, mat2, qpdf, and ffmpeg updated; they parse hostile binary
215
+ formats and have had CVEs in the past.
129
216
 
130
217
  ## License
131
218
 
data/bin/metaclean CHANGED
@@ -1,29 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
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.
4
+ # Add <repo>/lib to the load path so this runs from a checkout, gem or not.
20
5
  $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
6
  require 'metaclean'
25
7
 
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
8
  Metaclean::CLI.start(ARGV)
data/lib/metaclean/cli.rb CHANGED
@@ -1,52 +1,23 @@
1
1
  # frozen_string_literal: true
2
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
- # ───────────────────────────────────────────────────────────────────────────
3
+ # CLI argument parser. Uses stdlib OptionParser (zero deps) over a gem like Thor.
15
4
 
16
5
  require 'optparse'
17
6
 
18
7
  module Metaclean
19
8
  class CLI
20
- # Class-level convenience: `Metaclean::CLI.start(ARGV)` reads cleaner
21
- # than `Metaclean::CLI.new(ARGV).run`.
22
9
  def self.start(argv)
23
10
  new(argv).run
24
11
  end
25
12
 
26
13
  def initialize(argv)
27
- # `dup` makes a shallow copy so we can mutate `@argv` without
28
- # surprising the caller (ARGV itself stays intact).
29
14
  @argv = argv.dup
30
-
31
- # All options default to safe/off values. `parse!` flips them
32
- # selectively as it sees flags.
33
15
  @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
16
+ recursive: false,
17
+ in_place: false,
18
+ force: false,
19
+ inspect_only: false,
20
+ dry_run: false
50
21
  }
51
22
  @paths = []
52
23
  end
@@ -55,26 +26,32 @@ module Metaclean
55
26
  # shells/CI can act on:
56
27
  # 0 → success
57
28
  # 1 → general failure
58
- # 2 → ExifTool missing (specific install hint shown)
29
+ # 2 → a required tool (exiftool/mat2/qpdf/ffmpeg) is missing (install hint shown)
59
30
  # 130→ user pressed Ctrl-C (matches the standard SIGINT exit code)
60
31
  def run
61
32
  parse!
33
+ # Refuse to run unless all four external tools are present (see
34
+ # Metaclean.ensure_tools!). --help/--version already exited inside parse!,
35
+ # so this only gates an actual inspect/clean.
36
+ Metaclean.ensure_tools!
62
37
  runner = Runner.new(@options)
63
38
  if @options[:inspect_only]
64
39
  runner.inspect_paths(@paths)
65
40
  else
66
41
  runner.clean_paths(@paths)
67
42
  end
68
- rescue ExiftoolMissing => e
69
- warn Display.error('ExifTool missing')
43
+ rescue ToolsMissing => e
44
+ warn Display.error('Missing required tools')
70
45
  warn e.message
71
46
  exit 2
72
- rescue Error => e
47
+ rescue Error, SystemCallError => e
48
+ # Errno::* (disk full, permission denied, read-only fs) is a SIBLING of
49
+ # our Error, not a subclass; naming it here gives filesystem failures a
50
+ # clean message + exit 1 instead of a raw backtrace.
73
51
  warn Display.error(e.message)
74
52
  exit 1
75
53
  rescue Interrupt
76
- # Pressing Ctrl-C raises `Interrupt`. Catching it lets us print a
77
- # clean message instead of a Ruby stack trace.
54
+ # Print a clean message instead of a stack trace.
78
55
  warn "\n#{Display.error('Interrupted.')}"
79
56
  exit 130
80
57
  end
@@ -83,82 +60,55 @@ module Metaclean
83
60
 
84
61
  def parse!
85
62
  parser = OptionParser.new do |o|
86
- # The banner shows up at the top of `--help`.
87
63
  o.banner = 'Usage: metaclean [options] <path> [<path>...]'
88
64
  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.'
65
+ o.separator 'Metadata cleaner. Strips EXIF, IPTC, XMP, GPS,'
66
+ o.separator 'MakerNotes, ID3, document properties, etc. — uses ExifTool, mat2,'
67
+ o.separator 'qpdf and ffmpeg together for maximum coverage.'
92
68
  o.separator ''
93
69
 
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
70
  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 }
71
+ o.on('--inspect', 'Only show metadata, do not modify files') { @options[:inspect_only] = true }
72
+ o.on('--dry-run', 'Simulate cleaning, show diff, write nothing') { @options[:dry_run] = true }
100
73
 
101
74
  o.separator ''
102
75
  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 }
76
+ o.on('-i', '--in-place', 'Overwrite originals (keeps a .bak; default: *_clean.<ext>)') { @options[:in_place] = true }
77
+ o.on('-r', '--recursive', 'Recurse into directories') { @options[:recursive] = true }
78
+ o.on('-f', '--force', 'Skip confirmation prompt') { @options[:force] = true }
132
79
 
133
80
  o.separator ''
134
81
  o.separator 'Other:'
135
- o.on('-h', '--help') { puts o; exit }
82
+ o.on('-h', '--help') { Display.banner; puts o; exit }
136
83
  o.on('-v', '--version') do
84
+ Display.banner
137
85
  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'}"
86
+ # Route tool versions (from the binaries' own stdout) through printable,
87
+ # like every other output path, so a tool emitting ANSI/OSC control
88
+ # bytes on its version line can't inject the terminal.
89
+ puts " exiftool: #{Display.printable(Exiftool.version || 'not found')}"
90
+ puts " mat2: #{Display.printable(Mat2.version || 'not found')}"
91
+ puts " qpdf: #{Display.printable(Qpdf.version || 'not found')}"
92
+ puts " ffmpeg: #{Display.printable(Ffmpeg.version || 'not found')}"
144
93
  exit
145
94
  end
146
95
  end
147
96
 
148
- # `parse!` mutates @argv in place: known flags are consumed,
149
- # positional args (file paths) are left behind.
150
97
  begin
151
98
  parser.parse!(@argv)
152
- rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
153
- # Bad flag show the message + the help text and exit non-zero.
99
+ rescue OptionParser::ParseError => e
100
+ # Any malformed flag unknown, missing argument, or an ambiguous
101
+ # abbreviation like `--i` (matches both --inspect and --in-place) —
102
+ # shows the message + help and exits non-zero, never a raw backtrace.
103
+ # ParseError is the base class of all of OptionParser's error types.
154
104
  warn Display.error(e.message)
155
105
  warn parser
156
106
  exit 1
157
107
  end
158
108
 
159
- # No paths after flags user probably ran `metaclean` with no args.
160
- # Show help and exit non-zero so scripts notice.
109
+ # No paths: show help, exit non-zero so scripts notice.
161
110
  if @argv.empty?
111
+ Display.banner
162
112
  puts parser
163
113
  exit 1
164
114
  end