Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions docs/superpowers/specs/2026-08-08-card-cleanup-page-design.md
Original file line number Diff line number Diff line change
@@ -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=<path>` (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.
2 changes: 2 additions & 0 deletions vireo/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
47 changes: 46 additions & 1 deletion vireo/card_cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion vireo/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})
Expand Down
2 changes: 2 additions & 0 deletions vireo/templates/_navbar.html
Original file line number Diff line number Diff line change
Expand Up @@ -2382,6 +2382,8 @@ <h3>Keyboard Shortcuts <button class="shortcuts-sheet-close" onclick="closeShort
{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'},
Expand Down
Loading