Compare commits
3 Commits
87654866f3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 030a40aa4b | |||
| 0728ae6592 | |||
| 3a9997e990 |
@@ -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/) · [SKILL.md](SKILL.md) · [pipeline.py](pipeline.py) · [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.
|
||||
- **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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">&</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 run<b>20<span class="small"> files</span></b></div>
|
||||
<div class="stat">Renamed<b>20<span class="small"> ✓</span></b></div>
|
||||
<div class="stat">Prefix variants<b>3<span class="small"> CleanShot · Screenshot · keyword</span></b></div>
|
||||
<div class="stat">Idempotent rerun<b>safe<span class="small"> already-renamed skipped</span></b></div>
|
||||
</div>
|
||||
|
||||
<div class="colophon">Set in Fraunces & JetBrains Mono. Plate i of ii.</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 654 KiB |
@@ -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 session, <span class="ampersand">end to 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> → <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> → audit <b><span class="num">195</span></b> = <b><span class="num">195</span></b> · <span style="color:#d8a4b3;">189 ✓</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">/ 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 & JetBrains Mono. Plate ii of ii.</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 455 KiB |
+243
-6
@@ -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 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 ↗</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
|
||||
— 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 — 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 — 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 & run</div>
|
||||
<div class="section-label"><span class="num">08</span>Install & 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
|
||||
|
||||
+424
-78
@@ -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)])
|
||||
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,14 +385,15 @@ 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()
|
||||
@@ -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")
|
||||
|
||||
existing = set(os.listdir(src))
|
||||
plan_rows = []
|
||||
errors = []
|
||||
seen = {}
|
||||
|
||||
for lineno, line in enumerate(all_lines, 1):
|
||||
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
|
||||
parts = line.split("\t", 1)
|
||||
if len(parts) != 2:
|
||||
errors.append(f"L{lineno}: bad split: {line!r}")
|
||||
cols = line.split("\t", 1)
|
||||
if len(cols) != 2:
|
||||
bad_split.append(f"{p.name}:L{lineno}: {line!r}")
|
||||
continue
|
||||
orig_claimed, desc = parts
|
||||
descs[norm_ws(cols[0])] = cols[1].strip()
|
||||
print(f"aggregated {len(descs)} description rows from {len(descs_paths)} batches")
|
||||
|
||||
if not orig_claimed.startswith(prefix + " "):
|
||||
errors.append(f"L{lineno}: prefix: {orig_claimed!r}")
|
||||
continue
|
||||
existing = set(os.listdir(src))
|
||||
plan_rows = []
|
||||
errors = list(bad_split)
|
||||
seen = {}
|
||||
|
||||
# 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
|
||||
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:
|
||||
errors.append(f"L{lineno}: source not found: {orig_claimed!r}")
|
||||
alt = _find_alt_extension(orig, existing)
|
||||
if alt is None:
|
||||
errors.append(f"src not found: {orig!r}")
|
||||
continue
|
||||
actual = alt
|
||||
|
||||
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 = desc.split()
|
||||
if len(words) < 6:
|
||||
errors.append(f"L{lineno}: <6 words: {orig!r} -> {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
|
||||
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}")
|
||||
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,8 +500,9 @@ 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):")
|
||||
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")
|
||||
@@ -195,6 +511,7 @@ def plan(src: Path, prefix: str, max_words: int) -> None:
|
||||
|
||||
# ---------- 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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user