metaclean 1.0.2 → 4.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd45cefaffcadee5f5c740e5e469cba971140c9a2d448d54d433970c985aedf6
4
- data.tar.gz: 3c998706d833d78bbaa039ad537d6ed6a4918cb443294f1249597ca88c1b9799
3
+ metadata.gz: 29c5b4aad77d530c77d0f4fbae6bbb1811dd6c801719e67999a1da802522a06a
4
+ data.tar.gz: cbeb0bc1eaf21557b922641fe255b1542dfc776e8833d20ae3fc1c74c9d378a5
5
5
  SHA512:
6
- metadata.gz: 81170d9320c56d98a22939c92ca91e40c896ec1e1383880face355a5c372efdb77c6b94cb3614e98a6c6840296c14a1a7eadb6d885a093853015832cdeef2e23
7
- data.tar.gz: 67855bc4b712f6bf1601d9224f672006d4d0f3b9a9ed1c73888b204d45393f1f71323dc9ade31e527205771a726274113cfdd4082943ab3b62a102851cbc343c
6
+ metadata.gz: b33eebe165d0d484d8fd19c9ba8bcaa28a2767b5597c5a08df946fa329256fb23694b9df35507007d4301998b49ca2bf7599eae1f11b0979b1408ee46a71ee74
7
+ data.tar.gz: 48a238f59f367c4bd905455cc44bfe12863b02a73bda64b22391d528ef8523d513e5d668992a8d337fe593448ee6552746ca41cf1766fcac6dabbd540e36ba85
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,89 @@
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. Note the `.bak` is the
35
+ **original, with all its metadata** — delete or move the `.bak` files before
36
+ sharing an in-place-cleaned folder.
37
+ - **Lossless routing:** it avoids mat2 paths that recompress JPEG/WebP or
38
+ downconvert TIFF, and uses ffmpeg stream-copy for Matroska.
39
+ - **Batch-friendly:** failed or unverified files exit non-zero, so scripts and CI
40
+ do not mistake uncertainty for success.
41
+
42
+ ## What it looks like
43
+
44
+ ```text
45
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
46
+ 📄 photo.jpg
47
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48
+ ▸ Before (5 embedded tags)
49
+ [GPS]
50
+ GPSLatitude 59.9139
51
+ GPSLongitude 10.7522
52
+ [IFD0]
53
+ Artist Jane Doe
54
+ Make Apple
55
+ Model iPhone 15
56
+ Pipeline: exiftool
57
+ ✓ exiftool
58
+ ▸ After (0 embedded tags)
59
+ (no embedded metadata)
60
+ ▸ Diff
61
+ ▸ Removed (5)
62
+ - GPS:GPSLatitude 59.9139
63
+ - GPS:GPSLongitude 10.7522
64
+ - IFD0:Artist Jane Doe
65
+ - IFD0:Make Apple
66
+ - IFD0:Model iPhone 15
67
+ ✓ → photo_clean.jpg
68
+ ```
12
69
 
13
70
  ## Install
14
71
 
15
72
  ```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
73
+ # 1. Install the four required tools metaclean refuses to run without all of them
74
+ brew install exiftool mat2 qpdf ffmpeg # macOS
75
+ sudo apt install libimage-exiftool-perl mat2 qpdf ffmpeg # Debian / Ubuntu
76
+ sudo dnf install perl-Image-ExifTool mat2 qpdf ffmpeg # Fedora
77
+ sudo pacman -S perl-image-exiftool mat2 qpdf ffmpeg # Arch
78
+ # Windows: use WSL2 (see below), then the Debian / Ubuntu line above
79
+
80
+ # 2. Install metaclean from RubyGems
24
81
  gem install metaclean
25
82
  metaclean --version
26
83
  metaclean --help
27
84
  ```
28
85
 
29
- From a source checkout before publishing:
86
+ From a source checkout:
30
87
 
31
88
  ```bash
32
89
  git clone https://github.com/26zl/metaclean.git && cd metaclean
@@ -41,17 +98,31 @@ You can also run the checkout without installing it by using
41
98
 
42
99
  ### Windows
43
100
 
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.
101
+ Native Windows isn't supported: `mat2` depends on Python + GTK and doesn't
102
+ install cleanly there, and metaclean requires all four tools. Use
103
+ **[WSL2](https://learn.microsoft.com/windows/wsl/install)** with Ubuntu and
104
+ follow the Debian / Ubuntu line above everything runs inside WSL.
105
+
106
+ ### Android
49
107
 
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.
108
+ There's no native Android app, but the full CLI runs unchanged inside
109
+ **[Termux](https://f-droid.org/packages/com.termux/)** (install it from F-Droid,
110
+ not the Play Store) via a Debian proot — where `mat2` and the other three tools
111
+ install cleanly from `apt`:
112
+
113
+ ```bash
114
+ pkg install proot-distro
115
+ termux-setup-storage # tap "Allow" to grant access to your files
116
+ proot-distro install debian
117
+ proot-distro login debian --bind ~/storage/shared:/sdcard
118
+ # now inside Debian:
119
+ apt update && apt install -y ruby mat2 libimage-exiftool-perl qpdf ffmpeg
120
+ gem install metaclean
121
+ metaclean /sdcard/DCIM/Camera/photo.jpg
122
+ ```
123
+
124
+ The `--bind` is what lets metaclean reach your photos — without it Debian can't
125
+ see the phone's storage.
55
126
 
56
127
  ## Quick start
57
128
 
@@ -68,8 +139,8 @@ metaclean photo.jpg
68
139
  # Overwrite the original (a .bak is kept by default)
69
140
  metaclean --in-place photo.jpg
70
141
 
71
- # Clean a whole folder, recursively, no prompts, no backups
72
- metaclean -r --in-place --no-backup --force ./vacation
142
+ # Clean a whole folder, recursively, no prompts
143
+ metaclean -r --in-place --force ./vacation
73
144
 
74
145
  # See what would change without writing anything
75
146
  metaclean --dry-run photo.jpg
@@ -78,31 +149,27 @@ metaclean --dry-run photo.jpg
78
149
  ## Flags
79
150
 
80
151
  | Flag | What it does |
81
- |---|---|
152
+ | --- | --- |
82
153
  | `--inspect` | Read-only — print metadata, never write |
83
- | `--json` | JSON output (with `--inspect`) |
84
154
  | `--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`) |
155
+ | `-i`, `--in-place` | Overwrite originals (keeps a `<file>.bak` — the **original, metadata intact**; remove it before sharing) |
87
156
  | `-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
157
  | `-f`, `--force` | Skip the confirmation prompt |
95
- | `--strict-verify` | Exit non-zero if privacy tags survive |
158
+ | `-h`, `--help` | Show usage and exit |
159
+ | `-v`, `--version` | Show metaclean's version **and** the detected versions of exiftool/mat2/qpdf/ffmpeg (prints `not found` for any missing) |
96
160
 
97
161
  ## Publishing
98
162
 
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.
163
+ The release workflow builds a `.gem` for every `v*` tag, attaches it to a
164
+ GitHub Release, and publishes it to RubyGems via a
165
+ [Trusted Publisher](https://guides.rubygems.org/trusted-publishing/) (OIDC) —
166
+ `rubygems/release-gem` with `id-token: write`. There is **no `RUBYGEMS_API_KEY`
167
+ secret**; the one-time prerequisite is registering this gem's Trusted Publisher
168
+ on rubygems.org.
102
169
 
103
170
  ```bash
104
- git tag v1.0.1
105
- git push origin v1.0.1
171
+ git tag v4.0.1
172
+ git push origin v4.0.1
106
173
  ```
107
174
 
108
175
  ## Safety
@@ -111,12 +178,29 @@ git push origin v1.0.1
111
178
  with spaces, quotes, or shell metacharacters are safe.
112
179
  - `--in-place` writes atomically: the file is built in a temp file and
113
180
  renamed into place, so a crash mid-run cannot leave a half-written original.
114
- - Symlinks are skipped by default.
181
+ - Symlinks are always skipped metaclean never cleans through a link.
182
+ - Folder and recursive (`-r`) scans deliberately skip **hidden files** (dot-prefixed,
183
+ e.g. `.secret.jpg`) and metaclean's own outputs (`*_clean.*`, `*.bak`), so a
184
+ directory run does not clean every file in the tree. To clean a hidden file,
185
+ name it explicitly: `metaclean .secret.jpg`.
186
+ - A `<file>.bak` left by `--in-place` is the **untouched original** and still
187
+ contains all its metadata. It is reported in the run, but it is yours to delete
188
+ or move before sharing the folder.
115
189
  - Filename collisions (`photo_clean.jpg` already exists, `.bak` already
116
- exists) are resolved with `_1`, `_2`, … suffixes.
190
+ exists) are resolved with `_1`, `_2`, … suffixes, including late collisions
191
+ that appear while a file is being cleaned.
117
192
  - After cleaning, metaclean re-reads the file and warns if known
118
193
  privacy-relevant tags (GPS, MakerNotes, Author, camera serial number, etc.)
119
- survived. With `--strict-verify` this becomes a non-zero exit code.
194
+ survived.
195
+ - A file whose strip leaves a privacy residual is **never written** — no
196
+ `_clean` copy and no `--in-place` overwrite — it is reported failed and the
197
+ original is left untouched.
198
+ - metaclean requires ExifTool, mat2, qpdf, and ffmpeg, and refuses to run (with
199
+ install instructions, exit code 2) if any is missing — so the post-clean
200
+ residual check always runs.
201
+ - Naming no files (a missing path, or everything filtered out), failed files,
202
+ and unverified cleans exit non-zero, so scripts do not mistake uncertainty for
203
+ success.
120
204
 
121
205
  ## What it does *not* do
122
206
 
@@ -124,8 +208,20 @@ git push origin v1.0.1
124
208
  - Filesystem metadata (mtime, ownership) — that's the OS, not the file.
125
209
  - Office macros or PDF JavaScript — open untrusted files in a sandbox.
126
210
 
127
- Keep ExifTool, mat2, and qpdf updated; they parse hostile binary formats and
128
- have had CVEs in the past.
211
+ The post-clean "still present" check is bounded by what ExifTool can re-read.
212
+ For container formats that mat2 cleans but ExifTool only partially parses (e.g.
213
+ `.zip`, `.epub` internals), metadata may be removed that the verification can't
214
+ independently confirm — a clean report means "the tools ran", not "every byte
215
+ was audited".
216
+
217
+ **SVG:** some mat2 builds (e.g. 0.14.0 on recent Python) crash on SVG, and
218
+ ExifTool is read-only for it — so on those systems metaclean cannot clean
219
+ `.svg`. It reports the file as **failed** (exit 1) and leaves the original
220
+ untouched rather than claiming a false "clean". Where mat2 handles SVG, it
221
+ cleans normally.
222
+
223
+ Keep ExifTool, mat2, qpdf, and ffmpeg updated; they parse hostile binary
224
+ formats and have had CVEs in the past.
129
225
 
130
226
  ## License
131
227
 
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