Bootstrap project and safe work-item workflow

This commit is contained in:
2026-07-13 13:51:41 +02:00
commit 80447092e9
90 changed files with 10562 additions and 0 deletions

285
WEBAPP_CONCEPT.md Normal file
View File

@@ -0,0 +1,285 @@
# Photo Analyzer — Web App UX Concept
Transform `photo_analyzer.py` from a terminal pipeline into a **local web app** that
reuses the `nsfwtag` review-app design language and architecture. Concept first —
implementation follows after review.
> **North star:** the analyzed data (descriptions, tags, mood, people, year, …) is
> currently invisible — it lives in a SQLite DB nobody can see. The web app's *primary*
> job is to surface it (browse + FTS5 search); the analysis pipeline becomes a
> monitored background job instead of a foreground terminal takeover.
Design validated with the `ui-ux-pro-max` skill: **Data-Dense Dashboard** pattern
(KPI cards, status colors green/amber/red, filtering-first, hover tooltips, row
highlight), debounced search with a real "no results" state, list virtualization for
25k+ photos, and — to stay dependency-free — **CSS progress bars with values-always-
visible** for stats rather than a chart library (the skill's AAA-accessible option).
---
## 1. Architecture (reuse nsfwtag's, unchanged in spirit)
- One **stdlib `ThreadingHTTPServer`** on `127.0.0.1:<random-free-port>`, opened in the
browser. No framework, no build step, no external assets (matches nsfwtag).
- One **token-injected HTML page** (`analyzer.html`, `%%TOKEN%%` replacement) + JSON
endpoints. Same "real HTML file, not a Python string" rule.
- **SQLite stays the single source of truth.** The server never owns pipeline state;
it reads the DB and an in-memory run-state object (exactly like the terminal
`_Dashboard` reads dicts + a deque). A render/HTTP crash can never corrupt the DB.
- **Path args validated against the DB** on every request (nsfwtag validates against
the scanned candidate set) — the server can't be pointed at arbitrary files.
- The existing analysis engine (`_run_folder_loop`, workers, retry/backoff, `_db_lock`,
variant grouping, nsfw-skip, `_IGNORE` exclusion) is **reused verbatim**, driven from
a background thread. The web layer is a new view + control surface, not a rewrite.
### Endpoints
| Method | Route | Purpose |
|--------|-------|---------|
| GET | `/` | The app page |
| GET | `/img?path=` | Thumbnail bytes *(reuse nsfwtag)* |
| GET | `/exif?path=` | Raw EXIF + decimal GPS + Maps URL *(reuse nsfwtag)* |
| GET | `/photo?path=` | Full analyzed record for one photo (all DB fields) |
| GET | `/search?q=&setting=&tod=&season=&people=&year_min=&year_max=&status=&album=&sort=&offset=` | Paged result set (FTS5 when `q` present) → `{rows:[…], total, offset}` |
| GET | `/facets` | Distinct filter values + counts (for the toolbar) |
| GET | `/stats` | Aggregates for the Stats view |
| GET | `/progress` | Live run state: `{running, totals, tokens, eta, albums:[…], feed:[…]}` |
| POST | `/run` | Start analysis with a config body (the CLI flags) |
| POST | `/stop` | Signal the current run to stop (reuses the existing `_stop` event) |
| GET | `/balance` · `/quota-check` | Proxy the existing functions → JSON |
| GET | `/log` | Activity log *(reuse nsfwtag)* |
Live updates use **polling** (`/progress` ~1.5 s, `/log` ~2 s) — same pattern as
nsfwtag's log panel. No SSE/WebSocket dependency.
---
## 2. Information architecture
Flat, three top-level views behind a header **segmented control** (deep-linked via
`#library` / `#analyze` / `#stats`; active state highlighted per the skill's
`nav-state-active`). An **Activity-log** slide-in panel is available from any view.
```
Header (sticky): [◧ logo] Photo Analyzer ⟨ Library · Analyze · Stats ⟩ [ total 25,318 · analyzed 25,316 · errors 8 ] [☰ log]
^ segmented, active = --text on --elev
```
- **Library** (default) — browse + search the analyzed photos. The payoff.
- **Analyze** — run controls + the live dashboard (album progress, feed, tokens/ETA).
- **Stats** — aggregates, breakdowns, error list.
---
## 3. View: Library *(browse + FTS5 search)*
```
┌ Header ─ view: Library ──────────────────────────────────────────────────────────┐
├ Toolbar (sticky) ─────────────────────────────────────────────────────────────────┤
│ 🔍 Search description, tags, mood, place… Setting▾ Time▾ Season▾ People▾ │
│ Year 1998 ●──────● 2019 ☐ has location Status▾ Sort: Relevance▾ │
├──────────────┬────────────────────────────────────────────────────────────────────┤
│ Folders │ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ← virtualized / infinite │
│ ▸ Urlaub │ │ [img] │ │ [img] │ │ [img] │ │ [img] │ scroll, server-paged │
│ 2015 USA │ │ '15 👤2│ │ '04 │ │ '11 👤5│ │ ⚠err │ │
│ ▸ job │ └───────┘ └───────┘ └───────┘ └───────┘ │
│ ▸ selfies │ beach sunset… cathedral… wedding… (error: timeout) │
│ … │ … 25,316 results … │
└──────────────┴────────────────────────────────────────────────────────────────────┘
```
- **Search** (`.field`): debounced 250 ms → `/search` FTS5 over `description, tags,
mood, location_hint`. Autocomplete-style live results (skill `Search/Autocomplete`).
Empty query = browse-all (paged). **No-results state** shows a message + suggestions
("try a broader term, or clear filters"), never a blank grid.
- **Facet filters** map every analyzed field:
- Setting (indoor/outdoor) · Time of day · Season → `select` or `.chip` toggles.
- People → buckets `0 / 1 / 2 / 3+`.
- Year → dual-handle range slider (`approx_year` min–max), tabular numbers.
- Has-location → toggle (`location_hint` not null).
- Status → analyzed / exif_written / error / pending.
- Filters AND with the search; each updates the live result count.
- **Folder sidebar** (reuse nsfwtag `.sidebar`/`.fitem`): album = **leaf folder**
(reuse `album_label`), with counts; filter to a folder + its subfolders.
- **Card** (reuse `.card`/`.thumb`): lazy `/img` thumbnail; overlays — **year badge**
+ **people badge** (reuse `.score` badge styling), a **status dot** (error=red,
pending=faint), and the first line of the description under the thumbnail. Tag chips
on hover.
- **Grid scale:** 25k+ photos → **server-paged infinite scroll** (append on scroll),
not load-all (skill `virtualize-lists`, `content-jumping`). Reserve card height to
avoid layout shift.
- **Lightbox** (reuse nsfwtag lightbox + keyboard + prev/next within the current result
set): left = full image; right = a two-section panel:
1. **Analysis** — description, tags, mood, setting, time-of-day, season, people,
year, location. (The EXIF caption Immich will index, made human-readable.)
2. **EXIF** — raw fields + GPS **Google Maps** link (reuse `/exif`).
Per-photo action: **Re-analyze this one** (POST `/run` scoped to one path).
---
## 4. View: Analyze *(run controls + live dashboard)*
Mirrors the terminal `_Dashboard` (see `DASHBOARD_PLAN.md`) as a web surface.
```
┌ Header ─ view: Analyze ───────────────────────────────────────────────────────────┐
├ Run bar ──────────────────────────────────────────────────────────────────────────┤
│ Library [ /…/pictures ▾ ] ☐ Dry-run ☐ Re-analyze ☐ No-EXIF ☐ EXIF-only ☐ Variants │
│ [ Balance ] [ Quota check ] [▶ Start] │
├ Overall ──────────────────────────────────────────────────────────────────────────┤
│ ████████████████░░░░░░░░ 238 / 550 43 % ok 235 · err 3 │
│ tokens 284,192 · est. remaining ~340,000 · ETA 14:23 [■ Stop] (danger) │
├ Albums (data-dense) ──────────────────┬ Recent feed ───────────────────────────────┤
│ Camera Roll 238/550 ███████░░ 43% │ ✓ IMG_4821.jpg │
│ Urlaub/Rom 45/ 45 ████████ ✓ │ A golden retriever on a sunny beach │
│ Urlaub/Venedig 12/ 60 ██░░░░░░ 20% │ ✗ DSC_0042.jpg — 4 retries: timeout │
│ WhatsApp Images 0/120 ░░░░░░░░ 0% │ ✓ IMG_4820.jpg │
│ … │ Two children building a sandcastle │
└───────────────────────────────────────┴─────────────────────────────────────────────┘
```
- **Run bar** = the CLI flags as controls:
Library path (default from `LIBRARY` env) · **Dry-run · Re-analyze · No-EXIF ·
EXIF-only · Group-variants** toggles · **Start** (primary) / **Stop** (danger).
One run at a time (guarded); Start on an interrupted library simply **resumes**
(DB is truth) — resume-safety is automatic, no separate control.
- **Overall panel**: big progress bar, ok/err counts, tokens used + estimated
remaining + ETA (all tabular). Status colors: green done, amber running, red error.
- **Albums panel**: one row per **leaf-folder album** with a mini progress bar + `✓`
when fully done (the DASHBOARD_PLAN target, now clickable → jumps to that album in
Library). Future albums shown at 0 %.
- **Recent feed**: newest-first list — thumbnail + filename + one-line description;
errors in red with the message. Reuses the activity-log line styling.
- **Balance / Quota-check**: small buttons → `/balance`, `/quota-check` → toast/inline
result. **Debug log**: available via the Activity-log panel.
- Everything here is driven by polling `/progress`; the panel degrades to "Idle — no
run in progress" with a Start hint when nothing is running (empty state with action).
---
## 5. View: Stats *(aggregates + errors, no chart lib)*
```
┌ KPI cards ────────────────────────────────────────────────────────────────────────┐
│ 25,318 total 25,316 analyzed 25,316 EXIF-written 8 errors 2 pending │
├ Breakdowns (CSS bars, counts always visible) ───────────────────────────────────────┤
│ Setting outdoor ██████████████ 14,902 indoor ████████ 9,120 │
│ Season summer ████████ · autumn ██████ · spring ████ · winter ███ · unknown ██ │
│ People 0 ████████ · 1 ██████ · 2 ████ · 3+ ███ │
│ By year 1998▁ 2003▃ 2004▅ 2010▇ 2011█ 2015█ … (histogram of bars) │
│ Top tags beach 1,204 · wedding 980 · city 900 · food 610 … │
├ Errors ─────────────────────────────────────────────────────────────────────────────┤
│ DSC_0042.jpg — connection timeout (4 retries) [Retry] │
│ … │
└──────────────────────────────────────────────────────────────────────────────────────┘
```
- KPI cards reuse `.metric`. Breakdowns are **horizontal CSS bars with the count
printed** (skill: values-always-visible, AAA; no dependency, no CSP issue).
- Error table lists `status='error'` rows with `error_message` + a **Retry** action
(re-queues that path for the next run — matches "re-running retries error records").
---
## 6. Component inventory (reuse existing tokens)
Everything below already exists in `nsfwtag/review.html`'s `:root` token set and class
library — the web app inherits it wholesale (same dark OLED palette, radii, focus
rings, reduced-motion guard).
| Component | Source | New/changed |
|---|---|---|
| Header `.app`/`.brand`/`.logo`/`.titles` | reuse | title copy |
| KPI `.metrics`/`.metric` | reuse | used for header counts + Stats cards |
| View switcher | reuse `.seg` | active-state = current view |
| Toolbar `.toolbar` | reuse | — |
| Search `.field` | reuse | — |
| Facet `.chip` / `select` / `.seg` | reuse | People/Setting/Season/Status |
| Range slider `input[type=range]` | reuse | **dual-handle** year range (small JS) |
| Sidebar `.sidebar`/`.fitem` | reuse | album tree |
| Grid `.grid`/`.card`/`.thumb` | reuse | + year/people badges, status dot |
| Tag chips | reuse `.chip` (mini) | — |
| Lightbox + EXIF panel | reuse | + Analysis section |
| Buttons `.btn`/`.primary`/`.danger`/`.ghost` | reuse | Start/Stop/Balance |
| Toast `.toast` | reuse | — |
| Activity log panel | reuse | run events too |
| **Progress bar** | **new** `.bar` (fill = `--primary`) | overall + per-album + stat bars |
| **Run toggles** | **new** switch styled from tokens | the 5 flags |
**One new token:** `--warn:#f59e0b` (amber) for "running / partial" states — the only
gap in the current green/red/faint status set.
---
## 7. Interaction model
- **View switching**: header segmented control; state deep-linked in the URL hash and
preserved on back/forward (skill `deep-linking`, `state-preservation`).
- **Search + filters**: debounced; combine as AND; the URL hash encodes `q` + active
filters so a search is shareable and survives reload. Result count updates live.
- **Browse at scale**: infinite scroll appends server-paged results; card heights
reserved (no CLS); thumbnails lazy-load.
- **Lightbox**: `←/→` navigate the current result set, `Esc`/backdrop close; EXIF and
full record fetched lazily on open.
- **Analyze**: Start → POST `/run` (config) → server spawns the run on a background
thread → dashboard polls `/progress`. Stop → POST `/stop` → sets the existing
`_stop` event; in-flight requests drain, DB stays consistent, restart resumes.
- **Accessibility/motion**: keyboard nav + visible focus rings (inherited), status
never by color alone (badges carry text/icons), `prefers-reduced-motion` respected
(token media query already present), all touch targets ≥ 36–44 px.
---
## 8. CLI → Web feature parity
| CLI feature | Web surface | Preserved how |
|---|---|---|
| `--library` | Analyze › Run bar path (default `LIBRARY` env) | same discovery |
| `--dry-run` | Run toggle | passed to engine |
| `--reanalyze` | Run toggle | passed to engine |
| `--no-exif` | Run toggle | passed to engine |
| `--exif-only` | Run toggle | passed to engine |
| `--group-variants` | Run toggle | reuses variant grouping |
| `--stats` | **Stats view** | `/stats` aggregates |
| `--balance` | Balance button | `/balance` |
| `--quota-check` | Quota-check button | `/quota-check` |
| `--debug` | Activity-log panel | server log |
| resume-safety | automatic (Start resumes) | DB source of truth |
| `_IGNORE/` exclusion | server-side discovery | `discover_photos` unchanged |
| nsfw-skip | automatic in every run | `filter_nsfw_tagged` unchanged |
| FTS5 search *(CLI had none)* | **Library search** | new surface for existing FTS5 table |
| DB (all fields) | Library cards + lightbox + facets | new surface for existing schema |
Nothing is dropped. The only genuinely new capability is **surfacing** what the DB
already stores (search/browse/stats) — everything else is the same engine behind a
web control panel instead of a terminal.
---
## 9. Proposed file layout (mirrors `nsfwtag/`)
```
webapp/ (or analyzer_web/)
├── __main__.py # CLI: python -m webapp → starts server, opens browser
├── server.py # ThreadingHTTPServer + routes (reuse nsfwtag/server.py shape)
├── page.py # render analyzer.html with %%TOKENS%% (reuse webapp.py shape)
├── analyzer.html # the single-page frontend (inherits nsfwtag tokens/classes)
├── query.py # DB read helpers: search (FTS5), facets, stats, one-photo
└── runner.py # drive photo_analyzer's engine on a background thread + run-state
```
`photo_analyzer.py` stays the pipeline library; `runner.py` imports and drives it. No
duplication of analysis logic.
---
## 10. Build order (for after approval)
1. **Read-only Library first** — server + page + `/search` + `/facets` + grid +
lightbox over the *existing* DB. Immediately useful, zero risk to the pipeline.
2. **Stats view** — `/stats` + CSS breakdown bars.
3. **Analyze view** — `/run` + `/stop` + `/progress` polling + run-state object,
driving the existing engine on a thread.
4. **Balance/quota/log** wiring + polish (empty states, deep-link hash, reduced-motion).
Each step ships independently; the pipeline keeps working from the CLI throughout.