Compare commits

..

3 Commits

Author SHA1 Message Date
Anthony Cardinale 030a40aa4b add btime fallback, app-library exclusion, --year, --include-untagged
Behavior changes (all opt-in or safety-first):
- prep refuses to operate inside .photoslibrary, .lrlibrary, .aplibrary,
  .fcpbundle, .band, .logicx, .app, etc. unless --allow-app-libraries
- --year YYYY restricts to files whose embedded ts (or btime) starts with YYYY
- --include-untagged accepts hand-named image files (no CleanShot/Screenshot
  prefix) and dates them via stat btime → mtime fallback. Gated on the folder
  containing ≥10 tagged matches to prevent sweeping ~/Pictures or similar
- prep pre-pass auto-normalizes the missing-space typo
  ('foo barCleanShot 2026-...' → 'foo bar CleanShot 2026-...') by os.rename
- plan now iterates the desc-tsv contents instead of the full src dir, with
  alt-extension fallback for Haiku's occasional .jpg-instead-of-.png echo
- build_new_name supports app=None (untagged) — emits
  '<keywords> - <Description> - YYYY-MM-DD.ext'

SKILL.md: gotchas #14-17 documenting each new guard, run-order updated
with the new flags, common-mistakes table extended.

Verified by smoke test with seeded files: --year filter, --include-untagged
threshold gate, app-library refusal, and typo normalization all behave.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:14:55 -04:00
Anthony Cardinale 0728ae6592 docs: enhance README and homepage with two screenshot plates
- Add assets/before-after.png — Plate i: five real renames laid out as a
  typeset table; mulberry highlight on the description segment; visual
  callouts for the U+202F gap and the user-keyword preservation case
- Add assets/session.png — Plate ii: editorial paper frame around an
  ink-dark Claude Code session card showing user prompt, claude
  orchestration, parallel Haiku fan-out across ten batches, and the run
  receipt
- Keep the source HTMLs (assets/{before-after,session}.html) so the
  plates can be regenerated via headless Brave at 1600x1100
- README.md rewritten: centered hero with embedded plate i, dedicated
  "A session, end to end" section embedding plate ii, new highlights
  (multi-prefix, idempotent), new "What the parser accepts" table,
  gotchas extended to 13, real-world impact promoted to a 3-row table
- docs/index.html surgically extended (existing editorial CSS preserved):
  new .plate and .additions components, nav gets what's-new and session
  links, new section 04 "What's new" with three additions cards plus a
  run-iii receipt, original receipt relabeled run-ii, gotchas list
  extended with #11-13, new section 07 "The session" embedding plate ii,
  install renumbered to 08
- Image refs in docs/index.html use absolute gitea raw URLs so they
  resolve when served from gitea pages or viewed locally

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:14:12 -04:00
Anthony Cardinale 3a9997e990 sync: dual-prefix support, U+202F handling, keyword preservation
Brings repo HEAD up to current live skill state in ~/.claude/skills/screenshot-rename/.
- recognize CleanShot AND Apple Screenshot filenames in one pass
- normalize U+202F (NARROW NO-BREAK SPACE) before AM/PM in Apple Screenshot names
- preserve user-typed keyword prefix and merge into description
- skip files already in renamed form (idempotent re-run)
- gotchas #11-13 added to SKILL.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:09:54 -04:00
8 changed files with 1446 additions and 110 deletions
+54 -15
View File
@@ -1,8 +1,20 @@
<div align="center">
# screenshot-rename
> A Claude Code skill that turns a folder of timestamp-named screenshots into a folder of human-readable, searchable filenames — using parallel Haiku vision agents.
*A Claude Code skill that turns a folder of timestamp-named screenshots*<br>
*into a folder of human-readable, searchable filenames —*<br>
*using parallel Haiku vision agents.*
**Documentation:** [pages.tojo.team/cardinale/screenshot-rename](https://pages.tojo.team/cardinale/screenshot-rename/) — full homepage with the workflow, gotchas, and use-case worked examples.
[**Homepage**](https://pages.tojo.team/cardinale/screenshot-rename/) &nbsp;·&nbsp; [SKILL.md](SKILL.md) &nbsp;·&nbsp; [pipeline.py](pipeline.py) &nbsp;·&nbsp; [MIT](LICENSE)
<br>
<img src="assets/before-after.png" alt="Before / after — five real renames, including U+202F handling and user-keyword preservation" width="900">
</div>
<br>
```
CleanShot 2026-04-15 at 09.14.07.png
@@ -10,28 +22,41 @@ CleanShot 2026-04-15 at 09.14.07.png
CleanShot - Shamel Studio Affiliate Referral Code Modal - 2026-04-15 at 09.14.07.png
```
Built for [CleanShot](https://cleanshot.com)-style screenshot folders, but works on any directory of `.png` / `.gif` / `.mp4` / `.pdf` files named only by timestamp.
Built for [CleanShot](https://cleanshot.com)-style screenshot folders, but works on any directory of `.png` / `.gif` / `.mp4` / `.pdf` files named only by timestamp. Recognizes both **`CleanShot ...`** and Apple **`Screenshot ...`** filenames in the same pass, preserves any leading user-typed keyword prefix, and is safe to re-run on a folder that's already partially renamed.
## Highlights
- **Parallel** — describes ~200 files in 3 minutes using 10 concurrent Haiku subagents.
- **Safe** — pre-builds the full rename plan in memory, validates uniqueness and target collisions, then renames atomically with file-count audit. Designed after losing 4 files to a `mv` overwrite during prototyping.
- **Handles video/PDF** — extracts the first frame so vision agents can describe them.
- **Resizes for the vision tool** — Retina screenshots exceed Read's image cap; pipeline downsamples to 1568px max.
- **Safe** — pre-builds the full rename plan in memory, validates uniqueness and target collisions, then renames atomically with a file-count audit. Designed after losing 4 files to a `mv` overwrite during prototyping.
- **Multi-prefix** — same pipeline handles `CleanShot ...`, Apple `Screenshot ...`, and files with hand-typed leading keywords (e.g. `jojo travel CleanShot ...`).
- **Idempotent** — re-running on a folder skips files already in the renamed `App - Description - timestamp.ext` form. No description-stacking.
- **Handles video / PDF** — extracts the first frame so vision agents can describe them.
- **Resizes for the vision tool** — Retina screenshots exceed Read's image cap; pipeline downsamples to 1568 px max.
## A session, end to end
<div align="center">
<img src="assets/session.png" alt="Claude Code session — user prompt, parallel Haiku fan-out across ten batches, receipt of the run" width="900">
</div>
The skill activates when you ask Claude conversationally. Behind the scenes it preps the folder, fans out ten Haiku agents in a single round-trip, validates the resulting plan, then applies the renames in a single Python pass with a file-count audit at the end.
## Installation
This is a Claude Code skill. Drop the `screenshot-rename/` directory into `~/.claude/skills/`:
This is a Claude Code skill. Drop the repo into `~/.claude/skills/`:
```bash
git clone https://gitea.tojo.team/cardinale/screenshot-rename.git ~/.claude/skills/screenshot-rename
git clone https://gitea.tojo.team/cardinale/screenshot-rename.git \
~/.claude/skills/screenshot-rename
```
In your next Claude Code session, ask:
> rename all the cleanshot files in `~/Documents/Screenshots/` based on their content
The skill will activate automatically.
The skill will activate automatically from its description.
## Usage from the command line
@@ -52,19 +77,22 @@ python3 pipeline.py plan --src "/path/to/folder"
python3 pipeline.py execute --src "/path/to/folder"
```
The dispatch step (#2) currently requires a Claude Code session. See [Roadmap](#roadmap).
The dispatch step (#2) currently requires a Claude Code session.
## Documentation
## What the parser accepts
- **Homepage with worked examples:** [docs/index.html](docs/index.html)
- **Full skill spec:** [SKILL.md](SKILL.md)
- **Pipeline source:** [pipeline.py](pipeline.py)
| Form | Recognized | Becomes |
|---|---|---|
| `CleanShot 2026-MM-DD at HH.MM.SS.png` | yes | `CleanShot - <description> - 2026-MM-DD at HH.MM.SS.png` |
| `Screenshot 2026-MM-DD at H.MM.SS PM.png` (with U+202F) | yes — U+202F normalized to ASCII space | `Screenshot - <description> - 2026-MM-DD at H.MM.SS PM.png` |
| `<keywords> CleanShot 2026-MM-DD at HH.MM.SS.png` | yes — keywords title-cased and prepended to the AI description | `CleanShot - <Keywords + description> - 2026-MM-DD at HH.MM.SS.png` |
| `App - <description> - 2026-MM-DD at HH.MM.SS.png` | already renamed → **skipped** | (unchanged) |
## The gotchas this skill encodes
This skill exists because every one of these caused real damage during development:
1. The macOS `Read` tool has an image-size cap. Resize first.
1. The `Read` tool has an image-size cap. Resize first.
2. Vision can't read `.mp4` or multi-page `.pdf` directly. Extract a frame.
3. **Bash regex `[[ =~ ]]` does NOT populate `BASH_REMATCH` in zsh.** Targets become empty. Loops collide on the same filename. Files vanish. Use Python for any filename mutation.
4. `mv` silently overwrites. Use `mv -n` or `os.rename` with explicit pre-existence check.
@@ -74,9 +102,20 @@ This skill exists because every one of these caused real damage during developme
8. `Bash run_in_background` may exit early on `while read` loops. Run renames foreground via Python.
9. Haiku occasionally returns the resized `.jpg` filename instead of the original `.png`. Validator must try alt extensions.
10. Always preserve the original `.mp4` / `.pdf` extension — describe via the extracted frame, rename the source.
11. **macOS `Screenshot` filenames contain U+202F (NARROW NO-BREAK SPACE)** before AM/PM. Haiku echoes it as ASCII space, so a verbatim filename lookup misses every Screenshot file. Normalize on both sides of the lookup; emit ASCII space in the new name.
12. **Re-running is only safe if the parser skips already-renamed files.** Detect `^App - .+ - timestamp.ext$` and exclude.
13. **Leading user-typed keyword prefix is signal, not noise.** Title-case the keywords and prepend them to the AI description before assembling the new name.
The full discussion is in [SKILL.md](SKILL.md#the-critical-gotchas-every-one-of-these-caused-real-pain).
## Real-world impact
| Run | Files | What happened |
|---|---|---|
| 1 | 196 CleanShot | Lost 4 to the bash-regex-in-zsh gotcha (#3). |
| 2 | 196 CleanShot | Rebuilt with Python and `mv -n` — 189 renamed cleanly, zero loss. |
| 3 | 20 mixed (CleanShot + Apple Screenshot + one user-prefixed) | First plan attempt dropped every Screenshot file with a misleading `NO_DESC` error. Diagnosed the U+202F gotcha (#11) via `repr()` of the live filename. After adding U+202F normalization, multi-prefix support, and keyword preservation — all 20 renamed in one pass. |
## Roadmap
- Direct Anthropic API mode (no Claude Code session required) — needs `ANTHROPIC_API_KEY`
+43 -1
View File
@@ -23,11 +23,17 @@ The pipeline is **prep → batch → describe (parallel agents) → validate pla
- Any image batch where the source filenames are timestamps and the user wants them human-scannable
- ≥ ~10 files (otherwise just rename them inline)
- Files include PNG/GIF and optionally MP4 or PDF (pipeline handles all four)
- Both `CleanShot ...` and Apple `Screenshot ...` filename prefixes are recognized in the same pass
- Files with a leading user-typed keyword prefix (e.g. `jojo travel CleanShot 2026-...png`) are recognized; the keywords are preserved and merged into the new name
- Files already in the renamed form (`App - Description - timestamp.ext`) are detected and skipped — re-running the skill on a folder is safe and idempotent
- Hand-named files with no embedded timestamp (e.g. `flight to australia 1.png`) — pass `--include-untagged`. Date is taken from filesystem btime/mtime. Only allowed when the folder already contains ≥10 tagged screenshots, so we don't sweep up arbitrary photo libraries.
- Restrict to a single year with `--year YYYY` (matches embedded ts or btime).
**Don't use for:**
- Code or text files — vision isn't needed
- Files where the name pattern is already meaningful
- Single-file rename (just do it directly)
- App-managed image catalogs (Apple Photos `.photoslibrary`, Lightroom `.lrlibrary`, Aperture `.aplibrary`, Final Cut, etc.) — the pipeline refuses to run inside these by default. Override with `--allow-app-libraries` only if you know what you're doing.
## Workflow
@@ -80,6 +86,20 @@ The pipeline is **prep → batch → describe (parallel agents) → validate pla
10. **Always preserve mp4/pdf source files** — the pipeline reads from the resized JPEG but renames the original mp4/pdf. Don't lose the source extension.
11. **macOS Screenshot files use U+202F (NARROW NO-BREAK SPACE) before AM/PM.** Apple's `Screenshot 2026-MM-DD at H.MM.SS PM.png` filenames have U+202F (not ASCII space) between the seconds and the meridiem marker. The Haiku subagent reliably normalizes it to ASCII space when echoing the filename into the desc TSV, so the desc-dictionary lookup fails silently — every Screenshot file is dropped from the plan with a misleading "NO_DESC" error and untouched on disk. **Fix:** normalize both sides of the lookup with `s.replace("", " ")` AND emit ASCII space in the new filename so the renamed file is keyboard-typable. Detect the offender with `python3 -c "import sys; print(repr(sys.argv[1]))" "filename"``` will appear in the output.
12. **Re-running the skill on a folder is safe iff the parser skips already-renamed files.** Without an `^App - .+ - timestamp\.ext$` skip rule, the parser will pile a second AI description into the name on every run. The pipeline detects and excludes these.
13. **Leading keyword prefix is part of the source signal.** When the user has hand-prefixed a file (e.g. `jojo travel flight ... CleanShot 2026-...png`), those keywords are user knowledge the AI doesn't have. Title-case them and prepend them to the AI description before assembling the new name. Don't drop them.
14. **App library packages are off-limits by default.** Apple Photos (`.photoslibrary`), Lightroom (`.lrlibrary`), Aperture (`.aplibrary`), Final Cut (`.fcpbundle`), GarageBand (`.band`), Logic (`.logicx`) and any `.app` are all bundles whose internals are managed by the host app. Renaming files inside them silently corrupts the catalog. The pipeline checks every segment of the source path against a suffix list and refuses to run if any matches. `--allow-app-libraries` overrides for the rare legitimate case (e.g. a `.app` bundle that happens to contain user-curated screenshots).
15. **Untagged files need a "this is a screenshot dump" gate.** A naive run on `~/Pictures` would happily try to rename every JPEG in sight. The fix: require ≥10 files matching the existing CleanShot/Screenshot regex BEFORE accepting any untagged file as a rename candidate. Without that signal, fall back to a hint-only message ("N untagged file(s) skipped; pass --include-untagged"). The threshold is configurable via `--untagged-threshold`.
16. **Filename embeds a timestamp until it doesn't.** Hand-named files like `flight to australia 1.png` have no `2026-MM-DD at HH.MM.SS` to harvest. Use `stat -f %SB -t %F` for macOS btime when available; mtime if btime is absent or before 1990 (a sentinel for "filesystem doesn't track this"). Date precision drops from `YYYY-MM-DD at HH.MM.SS` to `YYYY-MM-DD` and the new filename uses ` - ` between the kept-stem and the AI description: `<stem> - <Description> - YYYY-MM-DD.ext`.
17. **The missing-space typo (`tabCleanShot 2026-...`) silently excludes files.** Some user-prefixed files lack the space between the user's keyword and `CleanShot`/`Screenshot`. The parser requires `\s+` and drops these. The fix is a pre-pass in `prep` that runs `os.rename` to insert the space (`tabCleanShot ...``tab CleanShot ...`) before parsing. Logged so the user sees what got normalized.
## Quick Reference
| Step | Command |
@@ -102,6 +122,14 @@ Run order:
python3 ~/.claude/skills/screenshot-rename/pipeline.py prep \
--src "/path/to/folder" --batch-size 19
# Optional flags on prep:
# --year 2026 only files whose ts (or btime) starts with 2026
# --include-untagged also rename hand-named images using btime/mtime
# as the date (only if folder has ≥10 tagged files)
# --untagged-threshold N override the ≥10 default
# --allow-app-libraries bypass the .photoslibrary / .lrlibrary guard
# (DANGEROUS — only for the rare legitimate case)
# Now dispatch one Haiku Agent per /tmp/screenshot-rename/full-batch-NN file
# (Claude Code does this — see SKILL.md "Workflow" step 3)
@@ -147,6 +175,13 @@ Dispatch all batches **in a single message with multiple Agent tool calls** so t
| Skipping the file-count audit | Silent data loss goes unnoticed | `len(os.listdir(DEST))` before & after — must be equal |
| Trusting Haiku's filename column | 30%+ of entries may have wrong extension | Plan-builder tries alt extensions |
| Running rename loop in background `Bash run_in_background=true` | Background `while read` may exit immediately, 0 progress | Run via Python foreground (it's fast — `os.rename` is just a syscall) |
| Looking up Haiku's filename column verbatim | Apple Screenshot files contain U+202F (narrow no-break space); Haiku echoes it as ASCII space, lookup misses every Screenshot file | Normalize U+202F → ASCII space on both sides of the desc dict |
| Hardcoding a single `--prefix` (e.g. `CleanShot`) | Apple Screenshot files and user-prefixed files get silently excluded from the manifest | Parser accepts both `CleanShot` and `Screenshot` and an optional leading keyword phrase |
| Re-running the skill without an already-renamed skip rule | Each run prepends another description; names balloon | Detect `^App - .+ - timestamp\.ext$` and skip |
| Walking into `.photoslibrary` / `.lrlibrary` etc. on a parent dir scan | Renames inside an app-managed bundle silently corrupt the catalog | Refuse if any path segment ends with one of the package suffixes; require `--allow-app-libraries` to override |
| Sweeping arbitrary photos in a non-screenshot folder | A user invokes the skill on `~/Pictures` and the pipeline tries to rename every JPEG | Gate untagged-file inclusion on ≥10 CleanShot/Screenshot matches in the folder, AND require explicit `--include-untagged` |
| Treating filename as the only date source | Hand-named files (e.g. `flight to Australia 1.png`) have no embedded timestamp and get dropped | Fall back to filesystem btime (`stat -f %SB`), then mtime; emit `YYYY-MM-DD` (no time component) in the new filename |
| User keyword abutting `CleanShot` with no space | Files like `weird tabCleanShot 2026-...png` don't match the regex and get silently excluded | Pre-pass in `prep` runs `os.rename` to insert the missing space before parsing |
## Recovery — if something does go wrong
@@ -154,7 +189,14 @@ Dispatch all batches **in a single message with multiple Agent tool calls** so t
2. **Check external backups (Backblaze, Time Machine to physical disk)** — these contain real file bytes.
3. **Local APFS Time Machine snapshots are NOT useful for iCloud-synced files** — they store file-provider stubs that time out on read.
4. **Check icloud.com → Drive → Recently Deleted** — iCloud keeps deleted files for ~30 days, but `mv` overwrites are NOT "deletes" from iCloud's perspective and may not appear there.
5. **If a Screenshot rename appeared to fail silently** — check for U+202F in the source filename: `python3 -c "import os; [print(repr(n)) for n in os.listdir('.') if 'Screenshot' in n]"`. The `` shows up in the repr; the parser must normalize it.
## Real-World Impact
First run on 196 CleanShot files lost 4 of them due to the bash-regex-in-zsh gotcha (rule #3). After the rebuild with Python and `mv -n`, second run renamed 189 files cleanly with zero loss. This skill exists so that doesn't happen again.
First run on 196 CleanShot files lost 4 of them due to the bash-regex-in-zsh gotcha (rule #3). After the rebuild with Python and `mv -n`, second run renamed 189 files cleanly with zero loss.
Third run (20 mixed CleanShot + Apple Screenshot + one user-prefixed file) hit the U+202F gotcha (rule #11) on first plan attempt — every Screenshot file was dropped from the plan with a NO_DESC error despite the description being present. Diagnosed via `repr()` of the live filename. After adding U+202F normalization, multi-prefix support, and keyword preservation, all 20 renamed in one pass.
Fourth run (43 files of mixed years in a Dropbox folder containing 2,260 total) needed a year filter and revealed that hand-named files (`flight to Australia 1.png`) silently fell through both the prefix gate and the year-substring filter. Subsequent skill update added `--year`, `--include-untagged` (gated on ≥10 tagged matches), btime/mtime fallback for date inference, automatic missing-space typo normalization, and a hard refusal to walk into Apple Photos / Lightroom / Aperture / Final Cut packages. The "screenshot dump" gate was added specifically to prevent the skill from sweeping `~/Pictures` on a future invocation.
This skill exists so those don't happen again.
+332
View File
@@ -0,0 +1,332 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>screenshot-rename · before / after</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,400;1,9..144,500;1,9..144,700&family=JetBrains+Mono:wght@400;500;600&display=block">
<style>
:root {
--paper: #f7f3ed;
--paper-2: #efe7d8;
--paper-3: #e8dfcd;
--ink: #1c1916;
--ink-soft: #3c3530;
--ink-mute: #8d8478;
--accent: #7a1f3d;
--accent-soft: rgba(122,31,61,.10);
--accent-deep: #5d1730;
--rule: #d8cebf;
--rule-soft: #e6dfd2;
--serif: "Fraunces", Georgia, serif;
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
width: 1600px;
height: 1100px;
background: var(--paper);
color: var(--ink);
font-family: var(--serif);
font-feature-settings: "ss01", "ss02", "kern";
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
background-image:
radial-gradient(1100px 380px at 92% -10%, rgba(122,31,61,.06), transparent 60%),
radial-gradient(900px 320px at 0% 22%, rgba(0,0,0,.03), transparent 70%);
position: relative;
overflow: hidden;
}
/* paper grain */
body::before {
content: "";
position: absolute; inset: 0;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
pointer-events: none;
mix-blend-mode: multiply;
z-index: 0;
}
.frame {
position: relative;
z-index: 1;
padding: 70px 88px 64px;
height: 100%;
}
/* ───── Header ────────────────────────────────────── */
.head {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: end;
gap: 36px;
padding-bottom: 28px;
border-bottom: 1px solid var(--rule);
}
.eyebrow {
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
display: flex; align-items: center; gap: 14px;
}
.eyebrow .bar { display: inline-block; width: 44px; height: 1px; background: var(--accent); }
.title {
font-family: var(--serif);
font-style: italic;
font-weight: 400;
font-variation-settings: "opsz" 144;
font-size: 76px;
line-height: 0.96;
letter-spacing: -0.022em;
margin: 0;
color: var(--ink);
}
.title .ampersand { font-weight: 300; color: var(--accent); padding: 0 4px; }
.runtag {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
color: var(--ink-mute);
text-transform: uppercase;
text-align: right;
}
.runtag .v { color: var(--ink); display: block; font-size: 13px; margin-top: 4px; letter-spacing: 0.06em; }
/* ───── Column legend ─────────────────────────────── */
.legend {
display: grid;
grid-template-columns: 30px minmax(0, 1fr) minmax(0, 1.55fr);
gap: 32px;
margin-top: 32px;
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-mute);
}
/* ───── Rows ──────────────────────────────────────── */
.rows { margin-top: 14px; }
.row {
display: grid;
grid-template-columns: 30px minmax(0, 1fr) minmax(0, 1.55fr);
gap: 32px;
padding: 18px 0 18px;
border-top: 1px dashed var(--rule);
align-items: center;
position: relative;
}
.row:first-child { border-top: 1px solid var(--rule); }
.glyph {
font-family: var(--mono);
font-size: 13px;
color: var(--accent);
text-align: center;
letter-spacing: 0;
font-weight: 500;
}
.before {
font-family: var(--mono);
font-size: 17px;
color: var(--ink-mute);
letter-spacing: -0.005em;
}
.before .nbsp {
display: inline-block;
background: rgba(122,31,61,.16);
border-bottom: 1px dashed var(--accent);
width: 0.4em;
height: 1.05em;
vertical-align: -0.18em;
margin: 0 -0.04em;
}
.after {
font-family: var(--mono);
font-size: 17.5px;
color: var(--ink);
letter-spacing: -0.005em;
line-height: 1.45;
}
.after .desc {
color: var(--accent-deep);
font-weight: 500;
background: linear-gradient(180deg, transparent 56%, var(--accent-soft) 56%, var(--accent-soft) 100%);
padding: 0 1px;
}
.after .keep {
color: var(--accent);
font-weight: 600;
}
/* annotation callout to the right of certain rows */
.row .note {
position: absolute;
right: -76px;
top: 50%;
transform: translateY(-50%);
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
white-space: nowrap;
display: none;
}
.row.callout .note {
display: block;
position: static;
transform: none;
grid-column: 2 / 4;
margin-top: 6px;
display: flex;
align-items: center;
gap: 14px;
color: var(--ink-mute);
}
.row.callout .note::before {
content: "↳";
color: var(--accent);
font-family: var(--serif);
font-style: italic;
font-size: 14px;
letter-spacing: 0;
}
.row.callout .note b {
color: var(--accent);
font-weight: 600;
text-transform: uppercase;
}
/* ───── Footer strip ─────────────────────────────── */
.foot {
margin-top: 48px;
padding-top: 26px;
border-top: 1px solid var(--rule);
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 36px;
}
.foot .stat {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
line-height: 1.3;
}
.foot .stat b {
display: block;
font-family: var(--serif);
font-style: italic;
font-weight: 400;
font-variation-settings: "opsz" 96;
font-size: 38px;
letter-spacing: -0.02em;
text-transform: none;
color: var(--accent);
margin-top: 6px;
line-height: 1.05;
}
.foot .stat b .small { font-style: normal; font-weight: 500; font-size: 16px; color: var(--ink); }
.colophon {
position: absolute;
bottom: 28px; right: 88px;
font-family: var(--serif);
font-style: italic;
font-size: 13px;
color: var(--ink-mute);
}
/* sheet number */
.sheet {
position: absolute;
top: 70px; right: 88px;
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.22em;
color: var(--ink-mute);
text-transform: uppercase;
}
.sheet b { color: var(--accent); }
::selection { background: var(--accent); color: var(--paper); }
</style>
</head>
<body>
<div class="frame">
<div class="sheet">Sheet · <b>01 / 02</b></div>
<div class="head">
<div class="eyebrow"><span class="bar"></span>screenshot-rename · plate i</div>
<h1 class="title">Before <span class="ampersand">&amp;</span> after</h1>
<div class="runtag">A real run, twenty files<span class="v">2026·05·04</span></div>
</div>
<div class="legend">
<span></span>
<span>Filename · before</span>
<span>Filename · after</span>
</div>
<div class="rows">
<div class="row">
<span class="glyph"></span>
<span class="before">CleanShot 2026-04-15 at 09.14.07.png</span>
<span class="after">CleanShot - <span class="desc">Shamel Studio Affiliate Referral Code Modal</span> - 2026-04-15 at 09.14.07.png</span>
</div>
<div class="row callout">
<span class="glyph"></span>
<span class="before">Screenshot 2026-03-12 at 11.42.18<span class="nbsp" title="U+202F"></span>PM.png</span>
<span class="after">Screenshot - <span class="desc">Synqora Modal Architecture Decisions Diagram</span> - 2026-03-12 at 11.42.18 PM.png</span>
<span class="note"><b>U+202F</b> · Apple's narrow no-break space, normalized in plan + execute</span>
</div>
<div class="row callout">
<span class="glyph"></span>
<span class="before"><span class="keep" style="color: var(--accent); font-weight: 600;">jojo travel</span> CleanShot 2026-03-31 at 10.52.34.png</span>
<span class="after">CleanShot - <span class="desc"><span class="keep">Jojo Travel</span> Flight Australia Melbourne Flightaware Map Route</span> - 2026-03-31 at 10.52.34.png</span>
<span class="note"><b>User keyword</b> · hand-typed prefix is preserved and title-cased into the new name</span>
</div>
<div class="row">
<span class="glyph"></span>
<span class="before">CleanShot 2026-01-26 at 17.38.30.png</span>
<span class="after">CleanShot - <span class="desc">Claude Code Hitting Limits Conversation Transcript</span> - 2026-01-26 at 17.38.30.png</span>
</div>
<div class="row">
<span class="glyph"></span>
<span class="before">CleanShot 2026-02-09 at 07.29.35.png</span>
<span class="after">CleanShot - <span class="desc">Claude Code Skill Rust Refactor Diff View</span> - 2026-02-09 at 07.29.35.png</span>
</div>
</div>
<div class="foot">
<div class="stat">Files in&nbsp;run<b>20<span class="small"> &nbsp;files</span></b></div>
<div class="stat">Renamed<b>20<span class="small"> &nbsp;</span></b></div>
<div class="stat">Prefix variants<b>3<span class="small"> &nbsp;CleanShot · Screenshot · keyword</span></b></div>
<div class="stat">Idempotent rerun<b>safe<span class="small"> &nbsp;already-renamed skipped</span></b></div>
</div>
<div class="colophon">Set in Fraunces &amp; JetBrains Mono. Plate i of ii.</div>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

+340
View File
@@ -0,0 +1,340 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>screenshot-rename · session</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,400;1,9..144,500;1,9..144,700&family=JetBrains+Mono:wght@400;500;600&display=block">
<style>
:root {
--paper: #f7f3ed;
--paper-2: #efe7d8;
--paper-3: #e8dfcd;
--ink: #1c1916;
--ink-2: #221e1a;
--ink-soft: #3c3530;
--ink-mute: #8d8478;
--accent: #7a1f3d;
--accent-soft: rgba(122,31,61,.10);
--accent-deep: #5d1730;
--rule: #d8cebf;
--rule-soft: #e6dfd2;
--term-paper: #ece4d2;
--term-mute: #a8a094;
--serif: "Fraunces", Georgia, serif;
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
width: 1600px;
height: 1100px;
background: var(--paper);
color: var(--ink);
font-family: var(--serif);
font-feature-settings: "ss01", "ss02", "kern";
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
background-image:
radial-gradient(1100px 380px at 92% -10%, rgba(122,31,61,.06), transparent 60%),
radial-gradient(900px 320px at 0% 22%, rgba(0,0,0,.03), transparent 70%);
position: relative;
overflow: hidden;
}
body::before {
content: "";
position: absolute; inset: 0;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
pointer-events: none;
mix-blend-mode: multiply;
z-index: 0;
}
.frame {
position: relative;
z-index: 1;
padding: 70px 88px 64px;
height: 100%;
}
/* ───── Header ────────────────────────────────────── */
.head {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: end;
gap: 36px;
padding-bottom: 28px;
border-bottom: 1px solid var(--rule);
}
.eyebrow {
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
display: flex; align-items: center; gap: 14px;
}
.eyebrow .bar { display: inline-block; width: 44px; height: 1px; background: var(--accent); }
.title {
font-family: var(--serif);
font-style: italic;
font-weight: 400;
font-variation-settings: "opsz" 144;
font-size: 76px;
line-height: 0.96;
letter-spacing: -0.022em;
margin: 0;
}
.title .ampersand { color: var(--accent); padding: 0 4px; font-weight: 300; }
.runtag {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
color: var(--ink-mute);
text-transform: uppercase;
text-align: right;
}
.runtag .v { color: var(--ink); display: block; font-size: 13px; margin-top: 4px; letter-spacing: 0.06em; }
.sheet {
position: absolute;
top: 70px; right: 88px;
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.22em;
color: var(--ink-mute);
text-transform: uppercase;
}
.sheet b { color: var(--accent); }
/* ───── Terminal card ─────────────────────────────── */
.terminal {
margin-top: 38px;
background: var(--ink);
color: var(--term-paper);
font-family: var(--mono);
font-size: 14px;
line-height: 1.7;
padding: 30px 38px 28px;
border-left: 3px solid var(--accent);
position: relative;
display: grid;
grid-template-columns: 80px 1fr;
gap: 22px 24px;
align-items: baseline;
}
.terminal::after {
content: "claude code · session 0a3f";
position: absolute;
top: 10px; right: 18px;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-mute);
}
.tag {
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
text-align: right;
padding-top: 3px;
}
.tag.you { color: var(--accent); }
.tag.bash { color: #c8d3a3; }
.tag.cc { color: #d8a4b3; }
.tag.fan { color: var(--accent); }
.line { font-size: 15px; }
.line.you { color: var(--term-paper); font-style: italic; font-family: var(--serif); font-size: 18px; line-height: 1.45; font-weight: 400; }
.line.cc { color: var(--term-paper); font-size: 15px; line-height: 1.55; }
.line.cc em { color: #d8a4b3; font-style: normal; font-weight: 500; }
.line.bash { color: var(--term-mute); font-size: 13px; line-height: 1.65; }
.line.bash b { color: var(--term-paper); font-weight: 500; }
.line.bash .num { color: #e9c98c; }
.line.bash .com { color: #6f685d; font-style: italic; }
/* ───── Agent fan ─────────────────────────────────── */
.fan-wrap { padding: 4px 0 6px; }
.agents {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.agent {
background: var(--ink-2);
border: 1px solid #2c2722;
padding: 14px 16px 13px;
display: grid;
grid-template-columns: 22px 1fr auto;
gap: 12px;
align-items: center;
position: relative;
}
.agent .dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px rgba(122,31,61,.18);
justify-self: center;
}
.agent .id {
font-size: 13px;
color: var(--term-paper);
letter-spacing: 0.04em;
}
.agent .id b { color: #d8a4b3; font-weight: 500; }
.agent .ct {
font-size: 11px;
color: var(--term-mute);
letter-spacing: 0.06em;
}
.agent .ct .ok { color: var(--accent); font-weight: 600; padding-left: 2px; }
.agent .haiku-tag {
position: absolute;
top: -8px; left: 12px;
background: var(--ink);
font-size: 9px;
letter-spacing: 0.2em;
color: var(--accent);
padding: 0 6px;
text-transform: uppercase;
}
/* ───── Receipt strip beneath ─────────────────────── */
.receipt {
margin-top: 38px;
background: var(--paper-2);
border: 1px solid var(--rule);
padding: 28px 36px;
font-family: var(--mono);
font-size: 14px;
line-height: 1.7;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 36px;
position: relative;
}
.receipt::before, .receipt::after {
content: ""; position: absolute; left: 0; right: 0; height: 8px;
background-image: radial-gradient(circle at 6px 4px, var(--paper) 4px, transparent 4px);
background-size: 14px 8px;
background-repeat: repeat-x;
}
.receipt::before { top: -1px; }
.receipt::after { bottom: -1px; transform: scaleY(-1); }
.receipt .cell .k {
font-size: 10.5px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-mute);
display: block;
margin-bottom: 6px;
}
.receipt .cell .v {
font-family: var(--serif);
font-style: italic;
font-variation-settings: "opsz" 96;
font-size: 32px;
line-height: 1.05;
color: var(--ink);
letter-spacing: -0.012em;
}
.receipt .cell .v .ok { color: var(--accent); font-weight: 500; }
.receipt .cell .v .small {
font-style: normal;
font-family: var(--mono);
font-size: 13px;
color: var(--ink-soft);
letter-spacing: 0.04em;
margin-left: 4px;
}
.colophon {
position: absolute;
bottom: 28px; right: 88px;
font-family: var(--serif);
font-style: italic;
font-size: 13px;
color: var(--ink-mute);
}
::selection { background: var(--accent); color: var(--paper); }
</style>
</head>
<body>
<div class="frame">
<div class="sheet">Sheet · <b>02 / 02</b></div>
<div class="head">
<div class="eyebrow"><span class="bar"></span>screenshot-rename · plate ii</div>
<h1 class="title">A&nbsp;session, <span class="ampersand">end&nbsp;to&nbsp;end</span></h1>
<div class="runtag">196 files · 10 agents · ~3 min<span class="v">claude code · 2026·05·04</span></div>
</div>
<div class="terminal">
<span class="tag you">you</span>
<div class="line you">rename all the cleanshots in <code style="font-family:var(--mono); font-size:15px; color:#e9c98c;">~/Documents/Screenshots/</code> based on their content.</div>
<span class="tag cc">claude</span>
<div class="line cc">I'll prep the folder, batch into ten files of 19, dispatch <em>parallel Haiku agents</em>, then plan and execute. Resizing Retinas first; extracting frames from <em>.mp4</em> and <em>.pdf</em>.</div>
<span class="tag bash">bash</span>
<div class="line bash"><span class="com"># 1 · prep</span><br>
python3 pipeline.py prep --src <span style="color:#e9c98c">~/Documents/Screenshots/</span><br>
<b><span class="num">196</span></b> source files · <b><span class="num">9</span></b> mp4/pdf frames · resized to <b>1568 px</b> · <b><span class="num">10</span></b> batches written
</div>
<span class="tag fan">fan-out</span>
<div class="line fan-wrap">
<div class="agents">
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>01</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>02</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>03</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>04</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>05</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>06</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>07</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>08</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>09</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>10</b></span><span class="ct">10/10 <span class="ok"></span></span></div>
</div>
</div>
<span class="tag bash">bash</span>
<div class="line bash"><span class="com"># 2 · plan, then execute</span><br>
python3 pipeline.py plan --src <span style="color:#e9c98c">~/Documents/Screenshots/</span> &nbsp;<b><span class="num">189</span></b> renames · <b><span class="num">0</span></b> errors · <b><span class="num">0</span></b> collisions<br>
python3 pipeline.py execute --src <span style="color:#e9c98c">~/Documents/Screenshots/</span> &nbsp;→ audit <b><span class="num">195</span></b> = <b><span class="num">195</span></b> · <span style="color:#d8a4b3;">189&nbsp;</span>
</div>
</div>
<div class="receipt">
<div class="cell">
<span class="k">Agents in flight</span>
<span class="v">10<span class="small">parallel</span></span>
</div>
<div class="cell">
<span class="k">Renames committed</span>
<span class="v"><span class="ok">189</span><span class="small">/&nbsp;189 planned</span></span>
</div>
<div class="cell">
<span class="k">File-count audit</span>
<span class="v">195 <span style="color:var(--ink-mute);">=</span> 195 <span class="ok"></span></span>
</div>
<div class="cell">
<span class="k">Files lost</span>
<span class="v"><span class="ok">0</span><span class="small">zero overwrites</span></span>
</div>
</div>
<div class="colophon">Set in Fraunces &amp; JetBrains Mono. Plate ii of ii.</div>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

+243 -6
View File
@@ -657,6 +657,132 @@ footer .colophon {
max-width: 50ch;
}
/* ───── Plate (image + caption) ───────────────── */
.plate {
margin: clamp(56px, 7vw, 96px) 0 0;
text-align: center;
}
.plate figure {
margin: 0;
display: inline-block;
max-width: 100%;
position: relative;
}
.plate figure::before, .plate figure::after {
content: "";
position: absolute;
width: 56px; height: 1px;
background: var(--accent);
top: 50%;
}
.plate figure::before { left: -68px; }
.plate figure::after { right: -68px; }
@media (max-width: 1100px) {
.plate figure::before, .plate figure::after { display: none; }
}
.plate img {
display: block;
width: 100%;
max-width: 1180px;
height: auto;
border: 1px solid var(--rule);
background: var(--paper);
box-shadow:
0 28px 56px -34px rgba(28,25,22,.30),
0 6px 14px -8px rgba(28,25,22,.18);
}
.plate figcaption {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-mute);
margin-top: 22px;
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
flex-wrap: wrap;
}
.plate figcaption .dot { color: var(--accent); }
.plate figcaption .b { color: var(--ink); }
/* ───── Additions (what's new this revision) ──── */
.additions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
border: 1px solid var(--rule);
background: var(--paper-2);
margin-bottom: 32px;
}
@media (max-width: 920px) { .additions { grid-template-columns: 1fr; } }
.add {
padding: 32px 28px 28px;
border-right: 1px solid var(--rule);
position: relative;
}
.add:last-child { border-right: none; }
@media (max-width: 920px) {
.add { border-right: none; border-bottom: 1px solid var(--rule); }
.add:last-child { border-bottom: none; }
}
.add .badge {
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.20em;
color: var(--accent);
text-transform: uppercase;
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 10px;
}
.add .badge .num {
display: inline-block;
border: 1px solid var(--accent);
border-radius: 50%;
width: 22px; height: 22px;
text-align: center; line-height: 20px;
font-size: 10px;
}
.add h3 {
font-family: var(--serif);
font-style: italic;
font-weight: 500;
font-variation-settings: "opsz" 60;
font-size: 26px;
line-height: 1.15;
margin: 0 0 12px;
letter-spacing: -0.012em;
color: var(--ink);
}
.add p {
font-size: 15px;
color: var(--ink-soft);
line-height: 1.55;
margin: 0 0 14px;
}
.add code {
font-family: var(--mono);
font-size: 12px;
color: var(--accent-deep);
background: rgba(122,31,61,.08);
padding: 1px 6px;
}
.add .glyph {
position: absolute;
top: 24px; right: 22px;
font-family: var(--serif);
font-style: italic;
font-size: 42px;
color: var(--accent-soft);
line-height: 1;
pointer-events: none;
}
/* ───── Reveal animation ─────────────────────── */
@media (prefers-reduced-motion: no-preference) {
@@ -686,8 +812,9 @@ footer .colophon {
<nav class="top">
<a href="#problem">problem</a>
<a href="#pipeline">pipeline</a>
<a href="#whats-new">what's&nbsp;new</a>
<a href="#gotchas">gotchas</a>
<a href="#cases">use cases</a>
<a href="#session">session</a>
<a href="#install">install</a>
<a href="https://gitea.tojo.team/cardinale/screenshot-rename">repo&nbsp;</a>
</nav>
@@ -837,14 +964,29 @@ footer .colophon {
<p style="font-family:var(--mono); font-size:12px; color:var(--ink-mute); letter-spacing:0.1em; text-transform:uppercase; margin-top:20px;">
The original timestamp survives unchanged. Sorting still works. The description sits between, set off by em-dashes.
</p>
<div class="plate reveal">
<figure>
<img src="https://gitea.tojo.team/cardinale/screenshot-rename/raw/branch/main/assets/before-after.png" alt="Plate i — five real renames, including U+202F handling and user-keyword preservation">
<figcaption>
<span class="bar" style="display:inline-block;width:28px;height:1px;background:var(--ink-mute);"></span>
<span>Plate i</span>
<span class="dot">·</span>
<span class="b">Five real renames</span>
<span class="dot">·</span>
<span>CleanShot · Apple Screenshot · user-keyword</span>
<span class="bar" style="display:inline-block;width:28px;height:1px;background:var(--ink-mute);"></span>
</figcaption>
</figure>
</div>
</div>
</section>
<!-- ───── Receipt ──────────────────────────────────── -->
<!-- ───── Receipt (run ii) ─────────────────────────── -->
<section>
<div class="wrap">
<div class="receipt reveal">
<div class="head">screenshot-rename · run log · 2026-05-04</div>
<div class="head">screenshot-rename · run ii · 2026-05-04 · 196 files</div>
<div class="line"><span>source files</span><span class="v">196</span></div>
<div class="line"><span>resized to 1568px</span><span class="v">196</span></div>
<div class="line"><span>frames extracted (mp4 / pdf)</span><span class="v">9</span></div>
@@ -860,10 +1002,74 @@ footer .colophon {
</div>
</section>
<!-- ───── What's new ───────────────────────────────── -->
<section id="whats-new">
<div class="wrap">
<div class="section-label"><span class="num">04</span>What's new this revision</div>
<h2><em>Three</em> additions, one mixed run.</h2>
<p class="lede-2">
A second batch of files surfaced cases the first run never hit: Apple's own
<em>Screenshot</em> filenames, user-typed keyword prefixes, and folders where some
files were already renamed. The pipeline now handles all three in a single pass
&mdash; and the parser learned a new gotcha along the way.
</p>
<div class="additions reveal">
<div class="add">
<span class="glyph">a.</span>
<div class="badge"><span class="num">i</span>multi-prefix</div>
<h3>CleanShot <em>and</em> Screenshot, in one run.</h3>
<p>
The parser now accepts both <code>CleanShot 2026-MM-DD at HH.MM.SS.png</code> and
Apple's <code>Screenshot 2026-MM-DD at H.MM.SS PM.png</code>. Mixed folders no
longer need two passes &mdash; the manifest builder picks up either prefix.
</p>
</div>
<div class="add">
<span class="glyph">b.</span>
<div class="badge"><span class="num">ii</span>keyword preservation</div>
<h3>Hand-typed prefixes survive.</h3>
<p>
A file named <code>jojo travel CleanShot 2026-...png</code> carries user knowledge
the AI doesn't have. The parser strips the keyword phrase, title-cases it, and
prepends it to the AI description &mdash; so the new filename reads
<em>Jojo Travel Flight Australia Melbourne Flightaware Map Route</em>.
</p>
</div>
<div class="add">
<span class="glyph">c.</span>
<div class="badge"><span class="num">iii</span>idempotent re-runs</div>
<h3>Re-running won't stack descriptions.</h3>
<p>
The parser now detects names already in the
<code>App - Description - timestamp.ext</code> form and excludes them from the
manifest. You can re-run the skill on a partially-renamed folder without the
name growing on every pass.
</p>
</div>
</div>
<div class="receipt reveal" style="max-width: 820px;">
<div class="head">screenshot-rename · run iii · 2026-05-04 · mixed prefixes</div>
<div class="line"><span>source files</span><span class="v">20</span></div>
<div class="line"><span>CleanShot</span><span class="v">14</span></div>
<div class="line"><span>Apple Screenshot (with U+202F)</span><span class="v">5</span></div>
<div class="line"><span>user-prefixed</span><span class="v">1</span></div>
<div class="line"><span>already-renamed (skipped)</span><span class="v">0</span></div>
<div class="line"><span>plan validated</span><span class="v">20 renames · 0 errors</span></div>
<div class="line"><span>file count before / after</span><span class="v">20 = 20</span></div>
<div class="line total"><span>renames committed</span><span class="ok">20 ✓</span></div>
<div class="line total"><span>files lost</span><span class="ok">0 ✓</span></div>
</div>
</div>
</section>
<!-- ───── Gotchas ──────────────────────────────────── -->
<section id="gotchas">
<div class="wrap">
<div class="section-label"><span class="num">04</span>The rules that prevent data loss</div>
<div class="section-label"><span class="num">05</span>The rules that prevent data loss</div>
<h2>Every rule below was <em>paid for</em>.</h2>
<p class="lede-2">
During development, four files were destroyed by a one-line bash mistake.
@@ -880,6 +1086,9 @@ footer .colophon {
<li><b>Run renames foreground.</b><code>Bash run_in_background</code> with <code>while read</code> may exit early with no progress. Run via Python in the same shell — <code>os.rename</code> is just a syscall.</li>
<li><b>Validate the filename column.</b>Haiku occasionally returns the resized <code>.jpg</code> name instead of the original <code>.png</code>. The plan-builder must try alternate extensions when the claimed source isn't found.</li>
<li><b>Preserve the original extension.</b>The pipeline reads from a resized JPEG but renames the original <code>.mp4</code> / <code>.pdf</code>. Write the source extension back into the new name.</li>
<li><b>Apple Screenshot files use <code>U+202F</code>.</b>The narrow no-break space sits between the seconds and AM/PM. Haiku echoes it as ASCII space, the lookup misses, and every Screenshot file is dropped from the plan with a misleading <code>NO_DESC</code> error. Normalize on both sides; emit ASCII space in the new name.</li>
<li><b>Re-runs must skip already-renamed files.</b>Without an <code>^App - .+ - timestamp.ext$</code> exclusion rule the parser will pile a second AI description into every name on every run. The pipeline detects and excludes them.</li>
<li><b>User-typed keyword prefix is signal.</b>A name like <code>jojo travel CleanShot ...</code> carries knowledge the AI doesn't have. Strip the keyword phrase, title-case it, and prepend it to the description before assembly. Don't drop it.</li>
</ol>
</div>
</section>
@@ -887,7 +1096,7 @@ footer .colophon {
<!-- ───── Use cases ────────────────────────────────── -->
<section id="cases">
<div class="wrap">
<div class="section-label"><span class="num">05</span>Use cases</div>
<div class="section-label"><span class="num">06</span>Use cases</div>
<h2>What this looks like in <em>practice</em>.</h2>
<p class="lede-2">
The skill earns its keep when "Spotlight will find it" stops being true. Four scenarios where it has.
@@ -929,10 +1138,38 @@ footer .colophon {
</div>
</section>
<!-- ───── Session (plate ii) ───────────────────────── -->
<section id="session">
<div class="wrap">
<div class="section-label"><span class="num">07</span>A session, end to end</div>
<h2>What it <em>looks like</em> when you ask.</h2>
<p class="lede-2">
The skill is conversational at the top, mechanical underneath. You ask in plain
English; ten Haiku agents fan out in a single round-trip; Python validates the
plan and applies it under the file-count audit. The whole thing fits on one page.
</p>
<div class="plate reveal">
<figure>
<img src="https://gitea.tojo.team/cardinale/screenshot-rename/raw/branch/main/assets/session.png" alt="Plate ii — Claude Code session showing user prompt, parallel Haiku fan-out across ten batches, and the run receipt">
<figcaption>
<span class="bar" style="display:inline-block;width:28px;height:1px;background:var(--ink-mute);"></span>
<span>Plate ii</span>
<span class="dot">·</span>
<span class="b">A session, end to end</span>
<span class="dot">·</span>
<span>10 agents · ~3 min · zero loss</span>
<span class="bar" style="display:inline-block;width:28px;height:1px;background:var(--ink-mute);"></span>
</figcaption>
</figure>
</div>
</div>
</section>
<!-- ───── Install ──────────────────────────────────── -->
<section id="install">
<div class="wrap">
<div class="section-label"><span class="num">06</span>Install &amp; run</div>
<div class="section-label"><span class="num">08</span>Install &amp; run</div>
<h2>Three commands, <em>one folder</em>.</h2>
<p class="lede-2">
The skill installs as a Claude Code skill. Once cloned into <code>~/.claude/skills/</code>, it
+434 -88
View File
@@ -8,59 +8,368 @@ Three subcommands:
The Haiku-subagent dispatch step happens between `prep` and `plan` and is
performed by Claude Code in-session, not by this script.
Recognizes both `CleanShot ...` and Apple `Screenshot ...` filenames in one
pass, preserves any leading user-typed keyword prefix, and skips files that
are already in the renamed `App - Description - timestamp.ext` form.
Also handles, behind opt-in flags:
--year YYYY restrict to files whose embedded ts (or file btime)
starts with YYYY
--include-untagged include image files that lack any CleanShot/Screenshot
prefix, dating them from filesystem btime/mtime;
requires the folder to look like a screenshot dump
(≥10 tagged matches) so we don't sweep up arbitrary
photos.
Refuses to operate on paths inside known app library packages
(.photoslibrary, .aplibrary, .lrlibrary, etc.) unless --allow-app-libraries
is passed — guards against accidental runs over Apple Photos / Lightroom
catalogs when invoked on a parent dir.
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
WORK = Path("/tmp/screenshot-rename")
FRAMES = WORK / "frames"
SMALL = WORK / "small"
# Apple's Screenshot tool inserts U+202F (narrow no-break space) before AM/PM.
# Haiku normalizes it to ASCII space when echoing the filename, so desc-dict
# lookups fail silently. Normalize on both sides AND emit ASCII space.
NNBSP = ""
def norm_ws(s: str) -> str:
return s.replace(NNBSP, " ")
# Filename parser. Captures:
# keywords — optional leading user-typed prefix (e.g. "jojo travel flight")
# app — CleanShot | Screenshot
# ts — "2026-MM-DD at HH.MM.SS" optionally followed by " AM" or " PM"
# dup — optional "(2)" or " 2" duplicate marker
# ext — file extension
#
# Run norm_ws() on the filename BEFORE matching so U+202F doesn't break the
# meridiem branch.
APP_PATTERN = re.compile(
r"^(?:(?P<keywords>.+?)\s+)?"
r"(?P<app>CleanShot|Screenshot)\s+"
r"(?P<ts>\d{4}-\d{2}-\d{2}\s+at\s+\d{1,2}\.\d{2}\.\d{2}(?:\s*[AP]M)?)"
r"(?P<dup>\(\d+\)|\s+\d+)?"
r"\.(?P<ext>[^.]+)$"
)
# Already-renamed: "App - <description> - <timestamp>(<dup>)?.<ext>"
ALREADY_RENAMED = re.compile(
r"^(?:CleanShot|Screenshot)\s+-\s+.+?\s+-\s+"
r"\d{4}-\d{2}-\d{2}\s+at\s+\d{1,2}\.\d{2}\.\d{2}(?:\s*[AP]M)?"
r"(?:\(\d+\))?\.[^.]+$"
)
# Untagged-already-renamed: "<keywords> - <description> - YYYY-MM-DD.<ext>"
# We use this to skip the result of a previous --include-untagged run.
UNTAGGED_RENAMED = re.compile(
r"^.+?\s+-\s+.+?\s+-\s+\d{4}-\d{2}-\d{2}(?:\(\d+\))?\.[^.]+$"
)
# User keyword abutting CleanShot/Screenshot with no space.
# e.g. "weird hightlighted tabCleanShot 2026-..." → insert space.
MISSING_SPACE_PATTERN = re.compile(
r"(?P<pre>\S)(?P<app>CleanShot|Screenshot)(?P<post>\s+\d{4}-)"
)
# Folder-name patterns we refuse to walk into. Apple Photos packages, Lightroom
# catalogs, Aperture, Final Cut, etc. — these contain images managed by other
# apps and should never be renamed by this skill.
APP_LIB_SUFFIXES = (
".photoslibrary",
".aplibrary",
".lrlibrary",
".lrcat",
".lrcat-data",
".tvlibrary",
".tvprojcache",
".fcpbundle",
".band",
".logicx",
".app",
)
APP_LIB_NAMES = ("Photo Booth Library", "Photos Library")
IMAGE_EXTS = (".png", ".gif", ".jpg", ".jpeg", ".webp", ".heic")
VIDEO_EXTS = (".mp4", ".mov")
PDF_EXTS = (".pdf",)
def is_in_app_library(p: Path) -> bool:
"""True if any segment of p is an app library package (or a known name)."""
try:
rp = p.resolve()
except OSError:
rp = p
for seg in rp.parts:
if any(seg.endswith(suf) for suf in APP_LIB_SUFFIXES):
return True
if seg in APP_LIB_NAMES:
return True
return False
def file_date(p: Path) -> str:
"""YYYY-MM-DD from stat btime when sane, else mtime.
On macOS `stat -f %SB -t %F` returns the file's birth time. If unset or
before 1990 (suggests fallback or broken metadata), use mtime instead.
"""
try:
r = subprocess.run(
["stat", "-f", "%SB", "-t", "%F", str(p)],
capture_output=True, text=True, timeout=5,
)
if r.returncode == 0:
s = r.stdout.strip()
if s and s.startswith(("19", "20")) and s >= "1990-01-01":
return s
except (OSError, subprocess.SubprocessError):
pass
return datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%d")
def title_case(s: str) -> str:
s = re.sub(r"\s+", " ", s.strip())
return " ".join(w[:1].upper() + w[1:] if w else w for w in s.split(" "))
def parse_filename(name: str):
"""Parts dict for tagged filenames; None for already-renamed or non-match."""
n = norm_ws(name)
if ALREADY_RENAMED.match(n):
return None
m = APP_PATTERN.match(n)
if not m:
return None
return {
"keywords": (m.group("keywords") or "").strip(),
"app": m.group("app"),
"ts": m.group("ts"),
"dup": m.group("dup") or "",
"ext": m.group("ext"),
}
def synthesize_untagged_parts(p: Path):
"""Parts dict for an untagged file (no CleanShot/Screenshot prefix).
Date is the file's btime/mtime since the filename has no embedded ts.
Returns None if file doesn't exist or has no extension.
"""
if not p.is_file():
return None
name = norm_ws(p.name)
if UNTAGGED_RENAMED.match(name):
return None
stem, dotext = os.path.splitext(name)
if not dotext:
return None
return {
"keywords": stem,
"app": None,
"ts": file_date(p),
"dup": "",
"ext": dotext[1:],
}
def normalize_typo_filename(name: str) -> str:
"""Insert space between user-keyword and CleanShot/Screenshot if abutting.
'weird tabCleanShot 2026-...''weird tab CleanShot 2026-...'
No-op if the pattern doesn't match.
"""
return MISSING_SPACE_PATTERN.sub(r"\g<pre> \g<app>\g<post>", name)
def build_new_name(parts: dict, ai_desc: str, max_words: int) -> str:
words = ai_desc.split()[:max_words]
cleaned = []
for w in words:
cw = "".join(c for c in w if c.isalnum())
if cw:
cleaned.append(cw)
if len(cleaned) < 6:
raise ValueError(f"<6 words after sanitize: {ai_desc!r}")
titled = title_case(" ".join(cleaned[:max_words]))
dup = parts["dup"]
if dup and not dup.startswith("("):
dup = "(" + dup.strip() + ")"
if parts["app"]:
pieces = []
if parts["keywords"]:
pieces.append(title_case(parts["keywords"]))
pieces.append(titled)
full_desc = " ".join(pieces)
return f'{parts["app"]} - {full_desc} - {parts["ts"]}{dup}.{parts["ext"]}'
# Untagged: <keywords> - <ai-desc> - <date>.<ext> with explicit separator
kw = title_case(parts["keywords"]) if parts["keywords"] else ""
if kw:
return f"{kw} - {titled} - {parts['ts']}{dup}.{parts['ext']}"
return f"{titled} - {parts['ts']}{dup}.{parts['ext']}"
def run(cmd, **kw):
return subprocess.run(cmd, capture_output=True, text=True, **kw)
def title_case(s: str) -> str:
return " ".join(w.capitalize() for w in s.split())
def parts_year(parts) -> str:
"""Extract YYYY from parts (tagged or untagged)."""
m = re.match(r"(\d{4})", parts["ts"])
return m.group(1) if m else ""
# ---------- prep ----------
def prep(src: Path, batch_size: int, prefix: str) -> None:
def prep(
src: Path,
batch_size: int,
year: str | None = None,
include_untagged: bool = False,
allow_app_libraries: bool = False,
untagged_threshold: int = 10,
) -> None:
if not src.is_dir():
sys.exit(f"source not a directory: {src}")
if is_in_app_library(src) and not allow_app_libraries:
sys.exit(
f"refusing to run inside an app library package: {src}\n"
f"if intentional, pass --allow-app-libraries"
)
WORK.mkdir(parents=True, exist_ok=True)
FRAMES.mkdir(exist_ok=True)
SMALL.mkdir(exist_ok=True)
pattern = re.compile(rf"^{re.escape(prefix)}\s+\d{{4}}-\d{{2}}-\d{{2}}.*$")
files = sorted(p for p in src.iterdir() if p.is_file() and pattern.match(p.name))
if not files:
sys.exit(f"no matching files (prefix='{prefix}') in {src}")
print(f"found {len(files)} source files")
# Pre-pass: normalize missing-space typos in source filenames.
typo_renamed = 0
for p in sorted(src.iterdir()):
if not p.is_file():
continue
n = norm_ws(p.name)
fixed = normalize_typo_filename(n)
if fixed != n:
new_path = src / fixed
if not new_path.exists():
os.rename(p, new_path)
typo_renamed += 1
print(f"normalized typo: {p.name!r}{fixed!r}")
if typo_renamed:
print(f"pre-pass: normalized {typo_renamed} missing-space typo(s)\n")
# Main pass: classify each file.
tagged_count = 0
untagged_candidates = []
eligible = [] # list of (path, parts) tuples
skipped_already = 0
skipped_other = 0
skipped_year = 0
refused_lib = 0
for p in sorted(src.iterdir()):
if not p.is_file():
continue
if is_in_app_library(p) and not allow_app_libraries:
refused_lib += 1
continue
parts = parse_filename(p.name)
if parts is not None:
tagged_count += 1
if year and parts_year(parts) != year:
skipped_year += 1
continue
eligible.append((p, parts))
continue
n = norm_ws(p.name)
if ALREADY_RENAMED.match(n) or UNTAGGED_RENAMED.match(n):
skipped_already += 1
continue
# Untagged candidate — defer until we know whether the folder qualifies
# as a screenshot dump.
if p.suffix.lower() in IMAGE_EXTS + VIDEO_EXTS + PDF_EXTS:
untagged_candidates.append(p)
else:
skipped_other += 1
if include_untagged:
if tagged_count >= untagged_threshold:
for p in untagged_candidates:
parts = synthesize_untagged_parts(p)
if parts is None:
skipped_other += 1
continue
if year and parts_year(parts) != year:
skipped_year += 1
continue
eligible.append((p, parts))
else:
print(
f"--include-untagged ignored: only {tagged_count} tagged file(s), "
f"need ≥{untagged_threshold} for the folder to qualify as a screenshot dump"
)
skipped_other += len(untagged_candidates)
else:
if untagged_candidates:
print(
f"hint: {len(untagged_candidates)} untagged image/video file(s) skipped; "
f"pass --include-untagged to include them (date from btime/mtime)"
)
skipped_other += len(untagged_candidates)
if not eligible:
sys.exit(
f"no eligible files in {src} "
f"(skipped: {skipped_already} already-renamed, "
f"{skipped_year} wrong-year, "
f"{skipped_other} other"
+ (f", {refused_lib} in app libraries" if refused_lib else "")
+ ")"
)
summary = (
f"found {len(eligible)} eligible files "
f"(skipped: {skipped_already} already-renamed, "
f"{skipped_year} wrong-year, "
f"{skipped_other} other"
)
if refused_lib:
summary += f", {refused_lib} in app libraries"
summary += ")"
print(summary)
# Resize/extract for vision and write manifest.
manifest = WORK / "all.tsv"
with manifest.open("w") as out:
for f in files:
for f, _parts in eligible:
base = f.stem
ext = f.suffix.lower()
if ext in (".mp4", ".mov"):
if ext in VIDEO_EXTS:
frame = FRAMES / f"{base}.jpg"
if not frame.exists():
r = run(["ffmpeg", "-y", "-ss", "1", "-i", str(f),
"-frames:v", "1", "-q:v", "3", str(frame)])
if not frame.exists():
print(f"WARN ffmpeg failed: {f.name}", file=sys.stderr)
continue
run([
"ffmpeg", "-y", "-ss", "1", "-i", str(f),
"-frames:v", "1", "-q:v", "3", str(frame),
])
if not frame.exists():
print(f"WARN ffmpeg failed: {f.name}", file=sys.stderr)
continue
vision_src = frame
elif ext == ".pdf":
elif ext in PDF_EXTS:
frame = FRAMES / f"{base}.jpg"
if not frame.exists():
run(["sips", "-s", "format", "jpeg", str(f), "--out", str(frame)])
@@ -68,7 +377,7 @@ def prep(src: Path, batch_size: int, prefix: str) -> None:
print(f"WARN sips failed on pdf: {f.name}", file=sys.stderr)
continue
vision_src = frame
elif ext in (".png", ".gif", ".jpg", ".jpeg", ".webp"):
elif ext in IMAGE_EXTS:
vision_src = f
else:
print(f"SKIP unknown ext: {f.name}", file=sys.stderr)
@@ -76,20 +385,21 @@ def prep(src: Path, batch_size: int, prefix: str) -> None:
small = SMALL / f"{base}.jpg"
if not small.exists():
run(["sips", "-Z", "1568", "-s", "format", "jpeg",
str(vision_src), "--out", str(small)])
run([
"sips", "-Z", "1568", "-s", "format", "jpeg",
str(vision_src), "--out", str(small),
])
if not small.exists():
print(f"WARN resize failed: {f.name}", file=sys.stderr)
continue
out.write(f"{small}\t{f.name}\n")
# split into batches
for old in WORK.glob("full-batch-*"):
old.unlink()
lines = manifest.read_text().splitlines()
n_batches = max(1, (len(lines) + batch_size - 1) // batch_size)
for i in range(n_batches):
chunk = lines[i * batch_size:(i + 1) * batch_size]
chunk = lines[i * batch_size : (i + 1) * batch_size]
(WORK / f"full-batch-{i+1:02d}").write_text("\n".join(chunk) + "\n")
print(f"prepped {len(lines)} files into {n_batches} batches in {WORK}")
print(f"\nDispatch {n_batches} Haiku subagents (one per batch).")
@@ -98,79 +408,84 @@ def prep(src: Path, batch_size: int, prefix: str) -> None:
# ---------- plan ----------
def plan(src: Path, prefix: str, max_words: int) -> None:
def _find_alt_extension(orig: str, existing: set[str]) -> str | None:
"""Haiku sometimes returns the resized .jpg extension instead of the
real .png/.gif/.mp4. Try alt extensions of the same stem."""
stem, dotext = os.path.splitext(orig)
if not dotext:
return None
for alt in IMAGE_EXTS + VIDEO_EXTS + PDF_EXTS:
cand = stem + alt
if cand != orig and cand in existing:
return cand
return None
def plan(src: Path, max_words: int) -> None:
if not src.is_dir():
sys.exit(f"source not a directory: {src}")
descs = sorted(WORK.glob("desc-full-*.tsv"))
if not descs:
descs_paths = sorted(WORK.glob("desc-full-*.tsv"))
if not descs_paths:
sys.exit("no desc-full-*.tsv files found in /tmp/screenshot-rename")
all_lines = []
for p in descs:
all_lines.extend(p.read_text().splitlines())
print(f"aggregated {len(all_lines)} description lines from {len(descs)} batches")
descs = {}
bad_split = []
for p in descs_paths:
for lineno, line in enumerate(p.read_text().splitlines(), 1):
line = line.rstrip()
if not line:
continue
cols = line.split("\t", 1)
if len(cols) != 2:
bad_split.append(f"{p.name}:L{lineno}: {line!r}")
continue
descs[norm_ws(cols[0])] = cols[1].strip()
print(f"aggregated {len(descs)} description rows from {len(descs_paths)} batches")
existing = set(os.listdir(src))
plan_rows = []
errors = []
errors = list(bad_split)
seen = {}
for lineno, line in enumerate(all_lines, 1):
line = line.rstrip()
if not line:
continue
parts = line.split("\t", 1)
if len(parts) != 2:
errors.append(f"L{lineno}: bad split: {line!r}")
continue
orig_claimed, desc = parts
if not orig_claimed.startswith(prefix + " "):
errors.append(f"L{lineno}: prefix: {orig_claimed!r}")
continue
# Find the actual file — Haiku occasionally returns .jpg instead of .png
orig = orig_claimed
if orig not in existing:
base = os.path.splitext(orig_claimed)[0]
for ext in (".png", ".gif", ".mp4", ".pdf", ".jpg", ".jpeg", ".webp"):
cand = base + ext
if cand in existing:
orig = cand
break
else:
errors.append(f"L{lineno}: source not found: {orig_claimed!r}")
for orig in sorted(descs.keys()):
# Locate the actual file in src (may have an alt extension if Haiku
# echoed the resized .jpg).
if orig in existing:
actual = orig
else:
alt = _find_alt_extension(orig, existing)
if alt is None:
errors.append(f"src not found: {orig!r}")
continue
actual = alt
words = desc.split()
if len(words) < 6:
errors.append(f"L{lineno}: <6 words: {orig!r} -> {desc!r}")
parts = parse_filename(actual)
if parts is None:
parts = synthesize_untagged_parts(src / actual)
if parts is None:
errors.append(f"can't parse: {actual!r}")
continue
words = words[:max_words]
cleaned = []
for w in words:
cw = "".join(c for c in w if c.isalnum())
if cw:
cleaned.append(cw)
if len(cleaned) < 6:
errors.append(f"L{lineno}: <6 after sanitize: {desc!r}")
desc = descs[orig]
try:
new = build_new_name(parts, desc, max_words)
except ValueError as e:
errors.append(f"{actual!r}: {e}")
continue
cleaned = cleaned[:max_words]
titled = title_case(" ".join(cleaned))
rest = orig[len(prefix) + 1:] # everything after "Prefix "
new = f"{prefix} - {titled} - {rest}"
if new == orig:
errors.append(f"L{lineno}: same: {orig!r}")
if new == actual:
errors.append(f"same: {actual!r}")
continue
if new in existing:
errors.append(f"L{lineno}: target exists in DEST: {new!r}")
errors.append(f"target exists in DEST: {new!r}")
continue
if new in seen:
errors.append(f"L{lineno}: plan collision: {new!r} from {orig!r} and {seen[new]!r}")
errors.append(
f"plan collision: {new!r} from {actual!r} and {seen[new]!r}"
)
continue
seen[new] = orig
plan_rows.append((orig, new))
seen[new] = actual
plan_rows.append((actual, new))
print(f"plan: {len(plan_rows)} renames, {len(errors)} errors")
if errors:
@@ -185,16 +500,18 @@ def plan(src: Path, prefix: str, max_words: int) -> None:
for orig, new in plan_rows:
f.write(f"{orig}\t{new}\n")
print(f"\nplan saved: {plan_path}")
print(f"sample (every {max(1, len(plan_rows)//6)}th row):")
step = max(1, len(plan_rows) // 6)
for i in range(0, len(plan_rows), step):
orig, new = plan_rows[i]
print(f" {orig}\n{new}\n")
if plan_rows:
step = max(1, len(plan_rows) // 6)
print(f"sample (every {step}th row):")
for i in range(0, len(plan_rows), step):
orig, new = plan_rows[i]
print(f" {orig}\n{new}\n")
print(f"if plan looks good: pipeline.py execute --src '{src}'")
# ---------- execute ----------
def execute(src: Path) -> None:
if not src.is_dir():
sys.exit(f"source not a directory: {src}")
@@ -249,6 +566,7 @@ def execute(src: Path) -> None:
# ---------- main ----------
def main() -> None:
p = argparse.ArgumentParser(description=__doc__)
sub = p.add_subparsers(dest="cmd", required=True)
@@ -256,12 +574,33 @@ def main() -> None:
p_prep = sub.add_parser("prep", help="extract frames, resize, build batches")
p_prep.add_argument("--src", type=Path, required=True)
p_prep.add_argument("--batch-size", type=int, default=19)
p_prep.add_argument("--prefix", default="CleanShot",
help="filename prefix to match (default CleanShot)")
p_prep.add_argument(
"--year",
type=str,
default=None,
help="restrict to YYYY (matches embedded ts or btime)",
)
p_prep.add_argument(
"--include-untagged",
action="store_true",
help="include image files that lack a CleanShot/Screenshot prefix; "
"requires the folder to have ≥10 tagged files (configurable)",
)
p_prep.add_argument(
"--untagged-threshold",
type=int,
default=10,
help="minimum tagged-file count for a folder to be treated as a "
"screenshot dump (default 10)",
)
p_prep.add_argument(
"--allow-app-libraries",
action="store_true",
help="bypass the .photoslibrary / .lrlibrary etc. guard (DANGEROUS)",
)
p_plan = sub.add_parser("plan", help="build & validate rename plan")
p_plan.add_argument("--src", type=Path, required=True)
p_plan.add_argument("--prefix", default="CleanShot")
p_plan.add_argument("--max-words", type=int, default=8)
p_exec = sub.add_parser("execute", help="apply rename plan with safety checks")
@@ -269,9 +608,16 @@ def main() -> None:
args = p.parse_args()
if args.cmd == "prep":
prep(args.src, args.batch_size, args.prefix)
prep(
args.src,
args.batch_size,
year=args.year,
include_untagged=args.include_untagged,
allow_app_libraries=args.allow_app_libraries,
untagged_threshold=args.untagged_threshold,
)
elif args.cmd == "plan":
plan(args.src, args.prefix, args.max_words)
plan(args.src, args.max_words)
elif args.cmd == "execute":
execute(args.src)