diff --git a/docs/superpowers/specs/2026-08-08-card-cleanup-page-design.md b/docs/superpowers/specs/2026-08-08-card-cleanup-page-design.md new file mode 100644 index 000000000..01c48184e --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-card-cleanup-page-design.md @@ -0,0 +1,76 @@ +# Card cleanup gets its own page — design + +**Date:** 2026-08-08 +**Status:** Approved in discussion with maintainer (relocation follow-up to +PR #1436); lightweight spec — the feature's behavior is unchanged and +governed by `2026-08-07-card-cleanup-design.md`. +**Scope:** Move the "Free up card space" UI from a collapsed section on the +import page to a dedicated page with a navbar entry; add an inline +integrity-audit affordance; keep the post-import entry point as a link. +No backend behavior changes to scan/delete. + +## Problem + +The tool lives inside the import page although its task is the opposite of +importing, so a user arriving cold (days after an import) doesn't find it. +Worse, its enabling dependency — the integrity audit that stamps +`hash_status='ok'` — lives on the audit page, so the first-run experience +on a pre-existing archive dead-ends: everything lands in "kept — run the +integrity audit" with no way to do that from where you're standing +(observed in production 2026-08-08: 5,523/5,523 files kept for exactly +this reason). + +## Design + +1. **New page** `vireo/templates/card_cleanup.html`, served at + `/card-cleanup` (route follows the existing page-route pattern in + app.py). Standalone page in the codebase's one-file-per-page style + (includes `_navbar.html`, inline CSS/JS). Contains the entire + scan → preview → delete flow moved from import.html, preserving the + existing ids/function names (`card-cleanup-*` / `cardCleanup*`) and + ALL user-facing copy verbatim — including the byte-exact confirmation + dialog from the parent spec, the incomplete-preview banner, and the + honest summary states. The section is no longer collapsed: on its own + page it renders expanded. +2. **Support code the section leaned on in import.html** (folder browser, + `formatBytes`, progress/`field-error`/modal CSS): the new page brings + its OWN minimal copies scoped to its needs — a single-select folder + browser (the card-cleanup `browserMode` variant), `formatBytes`, and + the small CSS set. Deliberate duplication over a shared-include + refactor of import.html: the parent PR's reviewers accepted mirrored + code at this scale, and un-inlining import.html's browser is exactly + the kind of unrelated refactor the parent spec declined. +3. **Inline audit affordance.** When the rendered preview's kept bucket + contains the `KEEP_NOT_VERIFIED` reason ("not verified by a checksummed + import — run the integrity audit"), show a callout above the buckets: + how many kept files carry that reason, one sentence explaining that + the archive copies must be checksum-verified before deletion is + allowed, a **Verify archive hashes** button that starts the existing + `verify-hashes` job (`POST /api/jobs/verify-hashes`) with the page's + standard progress rendering, and — on completion — a **Re-scan card** + affordance. Include the SMB cost warning: verification re-reads + archive files; on a VPN'd mount this is slow, best run close to the + NAS. No new backend endpoints. +4. **Navbar**: add "Card cleanup" to `_navbar.html` in the tools/pages + list (match existing nav idiom and ordering conventions). +5. **Import page**: remove the moved section and its JS; keep the + card-safety-pill entry point, now a link to + `/card-cleanup?source=` (URL-encoded). The new page pre-fills + its source input from the `source` query parameter. Multi-source + imports pass the first source; the new page keeps the existing + "remaining sources" hint behavior. +6. **No API changes.** Scan/delete/manifest endpoints, job types, and + manifest format are untouched. + +## Testing + +- Page route test: `GET /card-cleanup` → 200, contains the section + markup (mirrors existing page-route tests). +- Existing card-cleanup API tests unchanged and green. +- Import page test (if any asserts the section's presence) updated; a + test asserts import.html no longer contains the section container and + DOES contain the pill link. +- JS of both changed templates re-checked with node --check; no + duplicate ids on either served page. +- Manual visual QA remains pending for the human (both themes), as with + the parent PR. diff --git a/vireo/app.py b/vireo/app.py index 9dcd9df36..e93048101 100644 --- a/vireo/app.py +++ b/vireo/app.py @@ -158,6 +158,8 @@ {"id": "dashboard", "label": "Dashboard", "href": "/dashboard"}, {"id": "storage", "label": "Storage", "href": "/storage"}, {"id": "audit", "label": "Audit", "href": "/audit"}, + {"id": "card_cleanup", "label": "Card cleanup", "href": "/card-cleanup", + "keywords": "card cleanup free space delete verified memory card format sd"}, {"id": "move", "label": "Move", "href": "/move"}, {"id": "id_conflicts", "label": "ID Conflicts", "href": "/id-conflicts", "keywords": "compare conflict prediction model disagreement species keyword classify review"}, diff --git a/vireo/card_cleanup.py b/vireo/card_cleanup.py index 5dc2c02a5..419724a05 100644 --- a/vireo/card_cleanup.py +++ b/vireo/card_cleanup.py @@ -241,9 +241,24 @@ def load_manifest(manifest_dir, scan_job_id, KEEP_NOT_IN_CATALOG = "not in catalog — not imported yet" +# The card-cleanup page keys its "Verify archive hashes" callout off this +# reason: card_cleanup.html matches the tail "run the integrity audit" in +# each kept entry to count the files an audit would unblock. Reword this +# and the callout silently stops appearing — change both together, and +# test_audit_callout_reason_stays_in_sync guards the pair. KEEP_NOT_VERIFIED = ( "not verified by a checksummed import — run the integrity audit" ) +# Codex P2: distinguished from KEEP_NOT_VERIFIED so the callout does NOT +# offer to re-verify these — the archive rows have already been checked +# and the verdict was modified/corrupt/unreadable. Re-running verify +# reproduces the same bad verdict rather than establishing an "ok" copy; +# the remedy lives on the Audit page (accept current hash, restore from +# backup, or investigate). The Audit-callout tail must NOT appear here or +# these files would be counted alongside the truly-unchecked ones. +KEEP_ARCHIVE_HASH_FAILED = ( + "archive copy failed a prior integrity check — see the Audit page" +) KEEP_INSIDE_SOURCE = "only catalog copy is inside the selected source" KEEP_ARCHIVE_UNREACHABLE = "archive file not reachable" KEEP_ARCHIVE_CHANGED = "archive file changed since verification" @@ -276,13 +291,28 @@ def qualify_rows(rows, source_root_real, card_path, contains_check=None): if contains_check is None: def contains_check(child_real): return path_guard.contains_resolved(source_root_real, child_real) - reason = KEEP_NOT_VERIFIED + # Codex P2: KEEP_NOT_VERIFIED reads to the user as "the audit hasn't + # run yet — running it would unblock this", so it must apply only when + # a NULL hash_status row is why the file is being kept. Rows with a + # non-NULL, non-"ok" status (modified/corrupt/unreadable) have already + # been verified; re-running verification reproduces the same verdict + # and the remedy lives on the Audit page, so those rows fall back to + # KEEP_ARCHIVE_HASH_FAILED instead. A specific reason from an "ok" + # row that failed a later gate (unreachable / changed / inside source) + # still wins over both — that's the closest-to-success signal we have. + saw_never_checked = False + saw_check_failed = False + reason = None try: card_st = os.stat(card_path) except OSError: return None, KEEP_UNREADABLE for row in rows: if row["hash_status"] != "ok": + if row["hash_status"] is None: + saw_never_checked = True + else: + saw_check_failed = True continue if not row["folder_path"]: continue @@ -337,6 +367,21 @@ def contains_check(child_real): reason = KEEP_INSIDE_SOURCE continue return archive_path, None + if reason is None: + # No "ok" row got as far as a specific rejection — fall back to + # the most accurate blame for what's on file. NULL wins the tie: + # a mix of never-checked and check-failed rows is still remediable + # by an audit (a never-checked row could turn "ok" and unlock the + # file), whereas the failed rows are just extra failed rows. + if saw_never_checked: + reason = KEEP_NOT_VERIFIED + elif saw_check_failed: + reason = KEEP_ARCHIVE_HASH_FAILED + else: + # No non-ok rows and no ok row got a specific reason — the + # only shapes left are ok rows with an empty folder_path. Kept + # for a stat-shaped reason we can't state precisely. + reason = KEEP_ARCHIVE_UNREACHABLE return None, reason diff --git a/vireo/db.py b/vireo/db.py index 388b39e71..0ea0cdd8b 100644 --- a/vireo/db.py +++ b/vireo/db.py @@ -376,7 +376,7 @@ def _chunks(values, size=_SQLITE_PARAM_CHUNK_SIZE): "import", "pipeline", "jobs", "pipeline_review", "pipeline_rapid_review", "review", "cull", "misses", "highlights", "life_list", "browse", "edit", "map", "location_review", "variants", - "dashboard", "storage", "audit", "move", "id_conflicts", + "dashboard", "storage", "audit", "card_cleanup", "move", "id_conflicts", "settings", "workspace", "lightroom", "shortcuts", "keywords", "duplicates", "logs", }) diff --git a/vireo/templates/_navbar.html b/vireo/templates/_navbar.html index bdb9945cb..af26664ef 100644 --- a/vireo/templates/_navbar.html +++ b/vireo/templates/_navbar.html @@ -2382,6 +2382,8 @@

Keyboard Shortcuts + + + + +
+ +
+
+ + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + + diff --git a/vireo/templates/import.html b/vireo/templates/import.html index 43d04c238..52089c2d6 100644 --- a/vireo/templates/import.html +++ b/vireo/templates/import.html @@ -224,26 +224,6 @@ .advanced-import { margin-top: 10px; font-size: 12px; color: var(--text-secondary); } .advanced-import summary { cursor: pointer; } .metadata-repair { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-primary); } -/* Free up card space. The section is a
, so its - summary has to carry the same weight as the other cards'

. */ -#card-cleanup-section > summary { - cursor: pointer; font-size: 14px; font-weight: 600; color: var(--text-primary); -} -#card-cleanup-section[open] > summary { margin-bottom: 10px; } -.card-cleanup-bucket { margin-top: 6px; } -.card-cleanup-bucket > summary { cursor: pointer; font-size: 12px; color: var(--text-primary); } -/* Same callout shape as .managed-archive-callout, in warning colours: an - incomplete preview must not read as a quiet footnote. */ -.card-cleanup-warning { - font-size: 12px; color: var(--text-primary); margin: 8px 0; - padding: 8px 10px; border-radius: 6px; overflow-wrap: anywhere; - background: color-mix(in srgb, var(--warning, #bf8700) 12%, transparent); - border: 1px solid color-mix(in srgb, var(--warning, #bf8700) 45%, transparent); -} -/* Taller than .staging-details: these lists run to thousands of files. */ -#card-cleanup-section .staging-details { max-height: 220px; } -.card-cleanup-confirm-list { margin: 0; padding-left: 18px; font-size: 13px; color: var(--text-primary); } -.card-cleanup-confirm-list li { margin-bottom: 10px; overflow-wrap: anywhere; } @@ -499,10 +479,11 @@

Result

+ path to hand to the scanner. Opens the Card cleanup page with + this import's card folder pre-filled. --> + onclick="openCardCleanupPage()">Free up card space…
@@ -520,72 +501,6 @@

Result

- -
- Free up card space -
- Checks a memory card against the archive and deletes only the files - whose archive copy is verified. Everything else stays on the card. -
-
- - -
- -
- -
-
- - -
- - - - -
- @@ -612,33 +527,6 @@

Select Folder

- -
- -
-