diff --git a/docs/working/specs/2026-06-21-travel-memories-design.md b/docs/working/specs/2026-06-21-travel-memories-design.md new file mode 100644 index 0000000..eb8c729 --- /dev/null +++ b/docs/working/specs/2026-06-21-travel-memories-design.md @@ -0,0 +1,321 @@ +# travel-memories — Design Spec + +**Date:** 2026-06-21 +**Status:** Draft + +## Overview + +`travel-memories` is a personal local web app for turning Immich photo albums into Grav CMS journal entries and story pages. It runs in Docker alongside the existing Grav dev environment, connects to an Immich instance on the local network, and guides the user through a six-phase workflow: select album → triage photos → curate selection → group into entries → write content → export to Grav. Progress is saved continuously so work can be paused and resumed at any stage. + +--- + +## Scope + +### What this covers + +- Immich album browsing and photo selection (read-only access to Immich) +- A structured six-phase workflow with pause/resume at any phase +- Journal entries and story drafts output as Grav-compatible entry folders +- Persistent state with hard-refresh safety +- Back-navigation with stale warnings (no auto-deletion of downstream work) +- A notes panel for capturing memories at any stage throughout the workflow +- Playwright UI test suite covering all phases + +### What this does not cover + +- AI-assisted writing, title generation, or typo correction (post-export luxury step, separate tool) +- Writing to Grav via the API — export writes files directly; `make content-push` handles sync as usual +- Multi-user support +- Mobile layout (desktop tool; tablet usable) +- Any modification of Immich albums or assets + +--- + +## Architecture + +### Docker + +New service added to `docker-compose.yml`: + +```yaml +travel-memories: + build: ./services/travel-memories + ports: + - "8082:8082" + volumes: + - ./docs/immich-workflow:/app/state + - ./user/pages:/app/pages + env_file: .env + user: "${UID}:${GID}" +``` + +`UID` and `GID` must be set in the shell environment (or in a `.env` fragment) so the container user matches the host user — this prevents permission errors when writing into `user/pages/`. + +### Service source layout + +``` +services/travel-memories/ + Dockerfile + requirements.txt + app/ + __init__.py ← Flask app factory + routes/ + albums.py ← Phase 1: album listing + selection + triage.py ← Phase 2 + curate.py ← Phase 3 + group.py ← Phase 4 + write.py ← Phase 5 + export.py ← Phase 6 + proxy.py ← Immich photo proxy (thumbs + originals) + notes.py ← Notes panel save endpoint + state.py ← Atomic JSON read/write helpers + immich.py ← Immich API client + templates/ + base.html ← DaisyUI shell, notes panel, nav + phase1.html + phase2.html + phase3.html + phase4.html + phase5.html + phase6.html + static/ + app.js ← Alpine.js component definitions +``` + +### Tech stack + +- **Backend:** Python 3.12 + Flask +- **Frontend:** Tailwind CSS + DaisyUI + Alpine.js — all loaded from CDN, no build pipeline +- **DaisyUI theme:** `forest` (closest to the blog's teal palette) +- **State:** One JSON file per album at `/app/state/{album-id}.json` (maps to `docs/immich-workflow/` on host) +- **Photo serving:** All Immich requests proxied through Flask — the Immich API key is never sent to the browser + +--- + +## Immich API + +All requests use `Authorization: Bearer {IMMICH_API_KEY}` and base URL from `IMMICH_URL` env var. + +| Purpose | Method | Endpoint | +|---|---|---| +| List albums | GET | `/api/albums` | +| Get album with assets | GET | `/api/albums/{id}?withoutAssets=false` | +| Get asset metadata | GET | `/api/assets/{id}` | +| Get thumbnail | GET | `/api/assets/{id}/thumbnail?size=preview` | +| Download original | GET | `/api/assets/{id}/original` | + +> **Pre-implementation gate:** verify these endpoint paths against the running Immich instance before writing any code. Immich API paths have changed between versions. + +--- + +## State model + +One JSON file per album. Written atomically: always written to `{file}.tmp` then `os.rename()` to prevent corruption from crashes. + +```json +{ + "album_id": "abc123", + "album_name": "Central Asia 2023", + "grav_trip_slug": "central-asia-2023", + "created_at": "2026-06-21T10:00:00", + "updated_at": "2026-06-21T14:32:00", + "phase": "group", + "phase_stale": ["write"], + "photos": [ + { + "id": "asset-uuid", + "original_filename": "IMG_1234.jpg", + "local_datetime": "2023-09-05T14:32:00", + "tag": "journal", + "order": 3 + } + ], + "groups": [ + { + "id": "g1", + "photo_ids": ["asset-uuid", "asset-uuid-2"], + "entry_type": "journal", + "title": "", + "body": "", + "location_city": "", + "location_country": "", + "date": "2023-09-05", + "hero_photo_id": null, + "status": "draft" + } + ], + "notes": "Arrived in Almaty — chaos at the airport. Lost one bag. The smell of the market..." +} +``` + +`status` values for a group: `draft`, `written`, `skipped`, `exported`. `exported` is immutable — never modified by upstream phase changes. + +--- + +## Phases + +### Phase 1 — Album selection + +App fetches album list from Immich on load and displays them as cards (cover thumbnail, name, photo count). User selects one or more albums. Multi-select merges all assets into one workspace, deduplicated by asset ID. After selection the app fetches all asset metadata, initialises the state file, and advances to Phase 2. + +If the state file for this album already exists, the app offers to resume or start over. "Start over" deletes the existing state file and reinitialises from the album metadata — exported entry files on disk are not touched. + +### Phase 2 — Triage + +Photos displayed in a responsive grid, ordered by `localDateTime`, with sticky day-group headers. Each photo shows its thumbnail, date, and time. + +Click or keyboard to tag: +- **J** → journal (green border) +- **S** → story (blue border) +- **X** or **space** → skip (dimmed) + +All photos must be tagged before "Done triaging" is enabled. Progress shown as a counter ("847 / 847 tagged"). + +### Phase 3 — Curate + +Shows only journal- and story-tagged photos, in chronological order with day-group headers. Actions per photo: remove (revert to skip), swap tag (journal ↔ story). Drag to reorder within a day group. "Curate done" completes the phase. + +### Phase 4 — Group + +All kept photos shown as a flat chronological stream. User inserts **entry-break dividers** between photos to define group boundaries. Each resulting segment is one entry. Groups get an auto-suggested working label (`2023-09-05 · Journal`, editable). Split a group by adding a divider; merge by removing one. "Grouping done" completes the phase. + +### Phase 5 — Write + +One group at a time; progress indicator ("3 of 12 groups done"). The notes panel is shown inline alongside the form for this phase. + +**Layout:** photos (scrollable) on the left, form on the right. + +**Journal mode fields:** Title, Date (pre-filled from first photo), City, Country, Body (plain text). + +**Story mode adds:** Hero photo picker (click to select from group photos), Shortcode hints (optional free-text field for notes like "gallery block here" — written to the story body as an HTML comment block at export, not processed as actual shortcodes). + +"Skip for now" defers a group; it can be revisited. Every form change auto-saves to state (debounced 500ms). Phase complete when all groups are written or explicitly skipped. + +### Phase 6 — Export + +Summary view: N journal entries, M stories ready to export. Skipped groups shown in a collapsible list (not exported). + +"Export" downloads full-resolution originals from Immich and writes Grav-compatible folders under the mounted `user/pages/` volume. + +**Output paths:** +- Journal: `user/pages/01.trips/{slug}/01.dailies/{YYYY-MM-DD}-{title-slug}.entry/` +- Story: `user/pages/01.trips/{slug}/04.stories/{title-slug}.story/` + +**Entry file structure** (journal example): +``` +2023-09-05-arrival-in-almaty.entry/ + entry.md ← frontmatter + body + photo-1.jpg + photo-2.jpg +``` + +**Frontmatter written (journal):** +```yaml +title: 'Arrival in Almaty' +date: '2023-09-05 14:32' +template: entry +published: true +location_city: 'Almaty' +location_country: 'Kazakhstan' +hero_image: photo-1.jpg +``` + +**Frontmatter written (story):** +```yaml +title: 'The Silk Road Begins' +date: '2023-09-05 14:32' +template: story +published: true +hero_image: photo-1.jpg +``` + +If a destination folder already exists, a per-entry overwrite prompt is shown before writing. After export, group status is set to `exported` in state. Exported entries are never touched by subsequent upstream changes. + +--- + +## Notes panel + +A persistent drawer on the right side of every phase. Free-text, auto-saved (debounced 500ms) with a "Saved ✓" / "Saving…" indicator always visible. + +In Phase 5 (Write), the notes panel content is shown inline next to the active group's form as a memory aid. + +A "Convert to entry" action on any selected note text promotes it to a new group appended to the Phase 4 grouping and marks Phase 5 as stale. The user stays on the current phase — no automatic navigation. + +--- + +## Back-navigation + +A phase nav bar is always visible. Clicking an earlier phase is always allowed. + +Going back to Phase N marks all completed phases above N as **stale**. Stale phases show a yellow warning banner: *"You changed earlier decisions — review this phase before exporting."* + +Stale does not delete content. The user re-confirms by redoing the phase or by dismissing the banner explicitly (which clears the stale flag without redoing the work). + +**Stale propagation:** + +| Navigate back to | Marks stale | +|---|---| +| Phase 2 (triage) | Phases 3, 4, 5 | +| Phase 3 (curate) | Phases 4, 5 | +| Phase 4 (group) | Phase 5 | +| Phase 5 (write) | Nothing (export is always fresh) | + +Exported entries (`status: exported`) are never affected by stale propagation. + +--- + +## Robustness constraints + +1. **Atomic writes** — state always written via `os.rename()` from a `.tmp` file; crash during write cannot corrupt existing state +2. **Reload safety** — all authoritative state is server-side JSON; a hard browser refresh re-fetches from disk, nothing is lost +3. **Photo proxy** — all Immich asset requests route through `/proxy/thumb/{id}` and `/proxy/original/{id}` on the Flask backend; the API key never reaches the browser +4. **Docker UID/GID** — `user: "${UID}:${GID}"` in docker-compose ensures container writes are owned by the host user; without this, export writes fail or produce root-owned files +5. **Immich unreachable** — album list and photo grid show an error banner with a retry button; the app does not crash or show a Python traceback +6. **Download failure on export** — failed asset downloads are logged per-asset; export continues for remaining assets; post-export summary lists any failed assets +7. **Export idempotency** — if a destination folder already exists, a per-entry overwrite prompt is shown; no silent overwrites +8. **Notes auto-save** — debounced 500ms; "Saved ✓" indicator always visible; no save button needed +9. **No cascade to exported entries** — `status: exported` is immutable in state; removing photos upstream or going back never modifies exported entry files or their state record + +--- + +## Test strategy + +Playwright (Python API). Tests run against the containerised app. A lightweight mock Immich server (Flask or `pytest-httpserver`) serves pre-canned fixture responses — no real Immich instance required. + +One state fixture JSON per phase so individual phase tests do not require clicking through all earlier phases. + +**Coverage per phase:** + +| Phase | Tests | +|---|---| +| 1 — Album selection | Album list renders; single select initialises state; multi-select merges and deduplicates; resume prompt shown for existing state | +| 2 — Triage | Photos render in day groups; J/S/X keyboard shortcuts apply correct tag; completion gate requires all photos tagged | +| 3 — Curate | Only tagged photos shown; remove reverts to skip; drag reorder updates order in state | +| 4 — Group | Divider inserts create new group; divider removal merges groups; label edit persists | +| 5 — Write | Form auto-saves on change; notes panel saves; journal/story mode switch changes visible fields; skip-for-now defers group | +| 6 — Export | Overwrite prompt shown for existing folder; skipped groups excluded; exported status set in state after write | +| Cross-cutting | Hard refresh at each phase preserves state; back-nav stale banner appears; stale banner dismissal clears flag | + +--- + +## New environment variables + +Add to `.env`: + +``` +IMMICH_URL=http://:2283 +IMMICH_API_KEY= +``` + +--- + +## File map + +| Path | Action | +|---|---| +| `services/travel-memories/` | **Create** — Flask app source | +| `services/travel-memories/Dockerfile` | **Create** | +| `services/travel-memories/requirements.txt` | **Create** | +| `docker-compose.yml` | **Update** — add `travel-memories` service | +| `docs/immich-workflow/` | **Create** — state files per album (host-mounted); add `docs/immich-workflow/*.json` to `.gitignore` | +| `.env` | **Update** — add `IMMICH_URL` and `IMMICH_API_KEY` |