e2497adf0a
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2807 lines
91 KiB
Markdown
2807 lines
91 KiB
Markdown
# travel-memories Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build a local Flask web app that turns Immich photo albums into Grav CMS journal entries and story pages via a six-phase guided workflow.
|
||
|
||
**Architecture:** Flask backend per phase blueprint, state persisted as atomic JSON per album, all Immich calls proxied server-side. DaisyUI + Alpine.js loaded from CDN — no build pipeline. Playwright drives UI tests against a dev server backed by a mock Immich server (pytest-httpserver).
|
||
|
||
**Tech Stack:** Python 3.12, Flask, DaisyUI 4 + Tailwind CSS (CDN), Alpine.js 3 (CDN), SortableJS (CDN, Phase 3 drag), Playwright + pytest, pytest-httpserver, docker-compose
|
||
|
||
## Global Constraints
|
||
|
||
- Python 3.12 only; no dependencies beyond `requirements.txt`
|
||
- All Immich HTTP calls go through Flask proxy routes — API key never reaches the browser
|
||
- State written atomically: write to `{path}.tmp` then `os.rename()` — never write directly
|
||
- `status: exported` on a group is immutable; no code path may change it
|
||
- DaisyUI theme: `forest` throughout
|
||
- No AI-generated text in any output
|
||
- `user/pages/` written directly by export; caller runs `make content-push` afterwards
|
||
- Pre-implementation gate: verify Immich API endpoint paths against real instance before Task 3
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
services/travel-memories/
|
||
Dockerfile
|
||
requirements.txt
|
||
tests/
|
||
conftest.py ← Flask dev server, mock Immich, seed_state fixture
|
||
fixtures/
|
||
phase2_state.json ← state with album selected, ready for triage
|
||
phase3_state.json ← all photos tagged, ready for curate
|
||
phase4_state.json ← curated photos, ready for grouping
|
||
phase5_state.json ← groups defined, ready for writing
|
||
phase6_state.json ← all groups written or skipped, ready for export
|
||
test_phase1.py
|
||
test_phase2.py
|
||
test_phase3.py
|
||
test_phase4.py
|
||
test_phase5.py
|
||
test_phase6.py
|
||
test_crosscutting.py
|
||
app/
|
||
__init__.py ← create_app(state_dir, pages_dir)
|
||
state.py ← TripState, Photo, Group dataclasses + atomic R/W
|
||
immich.py ← ImmichClient(base_url, api_key)
|
||
routes/
|
||
__init__.py
|
||
albums.py ← GET / POST /select
|
||
triage.py ← GET /triage POST /triage/tag
|
||
curate.py ← GET /curate POST /curate/update
|
||
group.py ← GET /group POST /group/divider POST /group/label
|
||
write.py ← GET /write POST /write/save POST /write/skip
|
||
export.py ← GET /export POST /export/run
|
||
proxy.py ← GET /proxy/thumb/<id> GET /proxy/original/<id>
|
||
notes.py ← POST /notes/save
|
||
nav.py ← POST /nav/phase (back-navigation + stale)
|
||
templates/
|
||
base.html
|
||
phase1.html … phase6.html
|
||
static/
|
||
app.js ← Alpine.js component: notesApp(), triageApp()
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: Scaffold — Docker, requirements, Flask factory, test infrastructure
|
||
|
||
**Files:**
|
||
- Create: `services/travel-memories/Dockerfile`
|
||
- Create: `services/travel-memories/requirements.txt`
|
||
- Create: `services/travel-memories/app/__init__.py`
|
||
- Create: `services/travel-memories/app/routes/__init__.py`
|
||
- Create: `services/travel-memories/tests/conftest.py`
|
||
- Create: `services/travel-memories/tests/fixtures/phase2_state.json` (and phase3–6)
|
||
- Modify: `docker-compose.yml`
|
||
- Modify: `.gitignore`
|
||
|
||
**Interfaces:**
|
||
- Produces: `create_app(state_dir=None, pages_dir=None) -> Flask` — consumed by every later task's tests
|
||
|
||
- [ ] **Step 1: Create `requirements.txt`**
|
||
|
||
```
|
||
flask==3.1.0
|
||
requests==2.32.3
|
||
pytest==8.3.4
|
||
pytest-playwright==0.6.2
|
||
pytest-httpserver==1.1.0
|
||
```
|
||
|
||
- [ ] **Step 2: Create `Dockerfile`**
|
||
|
||
```dockerfile
|
||
FROM python:3.12-slim
|
||
WORKDIR /app
|
||
COPY requirements.txt .
|
||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||
playwright install chromium --with-deps
|
||
COPY app/ ./app/
|
||
ENV FLASK_APP=app
|
||
ENV FLASK_RUN_HOST=0.0.0.0
|
||
ENV FLASK_RUN_PORT=8082
|
||
CMD ["flask", "run"]
|
||
```
|
||
|
||
- [ ] **Step 3: Create `app/__init__.py`**
|
||
|
||
```python
|
||
import os
|
||
from flask import Flask
|
||
|
||
def create_app(state_dir=None, pages_dir=None):
|
||
app = Flask(__name__)
|
||
app.config["STATE_DIR"] = state_dir or os.environ.get("STATE_DIR", "/app/state")
|
||
app.config["PAGES_DIR"] = pages_dir or os.environ.get("PAGES_DIR", "/app/pages")
|
||
app.config["IMMICH_URL"] = os.environ.get("IMMICH_URL", "")
|
||
app.config["IMMICH_API_KEY"] = os.environ.get("IMMICH_API_KEY", "")
|
||
|
||
from .routes import albums, proxy, notes, nav
|
||
app.register_blueprint(albums.bp)
|
||
app.register_blueprint(proxy.bp)
|
||
app.register_blueprint(notes.bp)
|
||
app.register_blueprint(nav.bp)
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"ok": True}
|
||
|
||
return app
|
||
```
|
||
|
||
(Remaining blueprints added in Tasks 5–10.)
|
||
|
||
- [ ] **Step 4: Create `app/routes/__init__.py`** — empty file.
|
||
|
||
- [ ] **Step 5: Add `travel-memories` service 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}"
|
||
```
|
||
|
||
- [ ] **Step 6: Update `.gitignore`** — add line: `docs/immich-workflow/*.json`
|
||
|
||
- [ ] **Step 7: Create `tests/conftest.py`**
|
||
|
||
```python
|
||
import json
|
||
import os
|
||
import shutil
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from werkzeug.serving import make_server
|
||
|
||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||
|
||
TINY_PNG = bytes.fromhex(
|
||
"89504e470d0a1a0a0000000d4948445200000001000000010806"
|
||
"0000001f15c4890000000a4944415478016360000000020001e2"
|
||
"21bc330000000049454e44ae426082"
|
||
)
|
||
|
||
MOCK_ALBUMS = [
|
||
{
|
||
"id": "album-1",
|
||
"albumName": "Central Asia 2023",
|
||
"assetCount": 3,
|
||
"albumThumbnailAssetId": "asset-1",
|
||
}
|
||
]
|
||
|
||
MOCK_ALBUM_DETAIL = {
|
||
"id": "album-1",
|
||
"albumName": "Central Asia 2023",
|
||
"assets": [
|
||
{"id": "asset-1", "originalFileName": "IMG_001.jpg",
|
||
"localDateTime": "2023-09-05T09:03:00"},
|
||
{"id": "asset-2", "originalFileName": "IMG_002.jpg",
|
||
"localDateTime": "2023-09-05T14:30:00"},
|
||
{"id": "asset-3", "originalFileName": "IMG_003.jpg",
|
||
"localDateTime": "2023-09-06T10:00:00"},
|
||
],
|
||
}
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def httpserver_listen_address():
|
||
return ("127.0.0.1", 8099)
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def mock_immich(httpserver):
|
||
httpserver.expect_request("/api/albums").respond_with_json(MOCK_ALBUMS)
|
||
httpserver.expect_request("/api/albums/album-1").respond_with_json(MOCK_ALBUM_DETAIL)
|
||
for asset_id in ["asset-1", "asset-2", "asset-3"]:
|
||
httpserver.expect_request(
|
||
f"/api/assets/{asset_id}/thumbnail"
|
||
).respond_with_data(TINY_PNG, content_type="image/png")
|
||
httpserver.expect_request(
|
||
f"/api/assets/{asset_id}/original"
|
||
).respond_with_data(TINY_PNG, content_type="image/jpeg")
|
||
return httpserver
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def state_dir(tmp_path_factory):
|
||
return tmp_path_factory.mktemp("state")
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def pages_dir(tmp_path_factory):
|
||
return tmp_path_factory.mktemp("pages")
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def flask_app(state_dir, pages_dir, mock_immich):
|
||
os.environ["IMMICH_URL"] = f"http://127.0.0.1:8099"
|
||
os.environ["IMMICH_API_KEY"] = "test-key"
|
||
from app import create_app
|
||
return create_app(state_dir=str(state_dir), pages_dir=str(pages_dir))
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def base_url(flask_app):
|
||
server = make_server("127.0.0.1", 8083, flask_app)
|
||
t = threading.Thread(target=server.serve_forever)
|
||
t.daemon = True
|
||
t.start()
|
||
time.sleep(0.2)
|
||
yield "http://127.0.0.1:8083"
|
||
server.shutdown()
|
||
|
||
|
||
@pytest.fixture()
|
||
def seed_state(state_dir):
|
||
"""Copy a fixture JSON into the state dir; return the album_id."""
|
||
def _seed(fixture_name: str) -> str:
|
||
src = FIXTURES_DIR / f"{fixture_name}.json"
|
||
with open(src) as f:
|
||
data = json.load(f)
|
||
dst = Path(state_dir) / f"{data['album_id']}.json"
|
||
shutil.copy(src, dst)
|
||
return data["album_id"]
|
||
return _seed
|
||
```
|
||
|
||
- [ ] **Step 8: Create `tests/fixtures/phase2_state.json`** (album selected, triage not started)
|
||
|
||
```json
|
||
{
|
||
"album_id": "album-1",
|
||
"album_name": "Central Asia 2023",
|
||
"grav_trip_slug": "central-asia-2023",
|
||
"phase": "triage",
|
||
"phases_completed": [],
|
||
"phase_stale": [],
|
||
"photos": [
|
||
{"id": "asset-1", "original_filename": "IMG_001.jpg",
|
||
"local_datetime": "2023-09-05T09:03:00", "tag": "untagged", "order": 0},
|
||
{"id": "asset-2", "original_filename": "IMG_002.jpg",
|
||
"local_datetime": "2023-09-05T14:30:00", "tag": "untagged", "order": 1},
|
||
{"id": "asset-3", "original_filename": "IMG_003.jpg",
|
||
"local_datetime": "2023-09-06T10:00:00", "tag": "untagged", "order": 2}
|
||
],
|
||
"groups": [],
|
||
"notes": ""
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9: Create `tests/fixtures/phase3_state.json`** (all photos tagged)
|
||
|
||
```json
|
||
{
|
||
"album_id": "album-1",
|
||
"album_name": "Central Asia 2023",
|
||
"grav_trip_slug": "central-asia-2023",
|
||
"phase": "curate",
|
||
"phases_completed": ["triage"],
|
||
"phase_stale": [],
|
||
"photos": [
|
||
{"id": "asset-1", "original_filename": "IMG_001.jpg",
|
||
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
|
||
{"id": "asset-2", "original_filename": "IMG_002.jpg",
|
||
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1},
|
||
{"id": "asset-3", "original_filename": "IMG_003.jpg",
|
||
"local_datetime": "2023-09-06T10:00:00", "tag": "skip", "order": 2}
|
||
],
|
||
"groups": [],
|
||
"notes": ""
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 10: Create `tests/fixtures/phase4_state.json`** (curated, ready for grouping)
|
||
|
||
```json
|
||
{
|
||
"album_id": "album-1",
|
||
"album_name": "Central Asia 2023",
|
||
"grav_trip_slug": "central-asia-2023",
|
||
"phase": "group",
|
||
"phases_completed": ["triage", "curate"],
|
||
"phase_stale": [],
|
||
"photos": [
|
||
{"id": "asset-1", "original_filename": "IMG_001.jpg",
|
||
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
|
||
{"id": "asset-2", "original_filename": "IMG_002.jpg",
|
||
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}
|
||
],
|
||
"groups": [],
|
||
"notes": "I remember the airport was chaos."
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 11: Create `tests/fixtures/phase5_state.json`** (groups defined, ready for writing)
|
||
|
||
```json
|
||
{
|
||
"album_id": "album-1",
|
||
"album_name": "Central Asia 2023",
|
||
"grav_trip_slug": "central-asia-2023",
|
||
"phase": "write",
|
||
"phases_completed": ["triage", "curate", "group"],
|
||
"phase_stale": [],
|
||
"photos": [
|
||
{"id": "asset-1", "original_filename": "IMG_001.jpg",
|
||
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
|
||
{"id": "asset-2", "original_filename": "IMG_002.jpg",
|
||
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}
|
||
],
|
||
"groups": [
|
||
{
|
||
"id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal",
|
||
"title": "", "body": "", "location_city": "", "location_country": "",
|
||
"date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "",
|
||
"status": "draft"
|
||
},
|
||
{
|
||
"id": "g2", "photo_ids": ["asset-2"], "entry_type": "story",
|
||
"title": "", "body": "", "location_city": "", "location_country": "",
|
||
"date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "",
|
||
"status": "draft"
|
||
}
|
||
],
|
||
"notes": "I remember the airport was chaos."
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 12: Create `tests/fixtures/phase6_state.json`** (all written, ready for export)
|
||
|
||
```json
|
||
{
|
||
"album_id": "album-1",
|
||
"album_name": "Central Asia 2023",
|
||
"grav_trip_slug": "central-asia-2023",
|
||
"phase": "export",
|
||
"phases_completed": ["triage", "curate", "group", "write"],
|
||
"phase_stale": [],
|
||
"photos": [
|
||
{"id": "asset-1", "original_filename": "IMG_001.jpg",
|
||
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
|
||
{"id": "asset-2", "original_filename": "IMG_002.jpg",
|
||
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}
|
||
],
|
||
"groups": [
|
||
{
|
||
"id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal",
|
||
"title": "Arrival in Almaty", "body": "Chaos at the airport.",
|
||
"location_city": "Almaty", "location_country": "Kazakhstan",
|
||
"date": "2023-09-05", "hero_photo_id": "asset-1", "shortcode_hints": "",
|
||
"status": "written"
|
||
},
|
||
{
|
||
"id": "g2", "photo_ids": ["asset-2"], "entry_type": "story",
|
||
"title": "The Market", "body": "Colours everywhere.",
|
||
"location_city": "Almaty", "location_country": "Kazakhstan",
|
||
"date": "2023-09-05", "hero_photo_id": "asset-2", "shortcode_hints": "gallery block",
|
||
"status": "skipped"
|
||
}
|
||
],
|
||
"notes": ""
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 13: Write a smoke test**
|
||
|
||
Create `tests/test_smoke.py`:
|
||
|
||
```python
|
||
def test_health(base_url, page):
|
||
page.goto(f"{base_url}/health")
|
||
assert "ok" in page.content()
|
||
```
|
||
|
||
- [ ] **Step 14: Run smoke test**
|
||
|
||
```bash
|
||
cd services/travel-memories
|
||
pip install -r requirements.txt
|
||
playwright install chromium
|
||
pytest tests/test_smoke.py -v
|
||
```
|
||
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 15: Commit**
|
||
|
||
```bash
|
||
git add services/travel-memories/ docker-compose.yml .gitignore
|
||
git commit -m "feat: scaffold travel-memories Flask app and test infrastructure"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: State management
|
||
|
||
**Files:**
|
||
- Create: `services/travel-memories/app/state.py`
|
||
- Create: `services/travel-memories/tests/test_state.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `load_state(album_id, app) -> TripState | None`
|
||
- Produces: `save_state(state: TripState, app) -> None`
|
||
- Produces: `TripState`, `Photo`, `Group` dataclasses with fields matching spec JSON
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_state.py
|
||
import json
|
||
import pytest
|
||
from pathlib import Path
|
||
from app.state import TripState, Photo, Group, load_state, save_state
|
||
|
||
|
||
@pytest.fixture
|
||
def app_ctx(flask_app):
|
||
with flask_app.app_context():
|
||
yield flask_app
|
||
|
||
|
||
def test_save_and_load_roundtrip(app_ctx, state_dir):
|
||
state = TripState(
|
||
album_id="test-album",
|
||
album_name="Test",
|
||
grav_trip_slug="test-trip",
|
||
photos=[Photo(id="p1", original_filename="a.jpg",
|
||
local_datetime="2023-01-01T10:00:00")],
|
||
groups=[],
|
||
)
|
||
save_state(state, app_ctx)
|
||
loaded = load_state("test-album", app_ctx)
|
||
assert loaded.album_id == "test-album"
|
||
assert loaded.photos[0].id == "p1"
|
||
|
||
|
||
def test_atomic_write_uses_tmp(app_ctx, state_dir, monkeypatch):
|
||
written_paths = []
|
||
real_rename = __import__("os").rename
|
||
def fake_rename(src, dst):
|
||
written_paths.append(src)
|
||
real_rename(src, dst)
|
||
monkeypatch.setattr("app.state.os.rename", fake_rename)
|
||
state = TripState(album_id="atomic-test", album_name="X", grav_trip_slug="x")
|
||
save_state(state, app_ctx)
|
||
assert any(str(p).endswith(".tmp") for p in written_paths)
|
||
|
||
|
||
def test_load_nonexistent_returns_none(app_ctx):
|
||
assert load_state("no-such-album", app_ctx) is None
|
||
|
||
|
||
def test_exported_status_field_preserved(app_ctx):
|
||
state = TripState(
|
||
album_id="export-test", album_name="E", grav_trip_slug="e",
|
||
groups=[Group(id="g1", photo_ids=[], entry_type="journal",
|
||
status="exported")]
|
||
)
|
||
save_state(state, app_ctx)
|
||
loaded = load_state("export-test", app_ctx)
|
||
assert loaded.groups[0].status == "exported"
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests — expect FAIL** (ImportError on `app.state`)
|
||
|
||
```bash
|
||
pytest tests/test_state.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Create `app/state.py`**
|
||
|
||
```python
|
||
import json
|
||
import os
|
||
from dataclasses import dataclass, field, asdict
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from flask import current_app
|
||
|
||
|
||
@dataclass
|
||
class Photo:
|
||
id: str
|
||
original_filename: str
|
||
local_datetime: str
|
||
tag: str = "untagged" # untagged | journal | story | skip
|
||
order: int = 0
|
||
|
||
|
||
@dataclass
|
||
class Group:
|
||
id: str
|
||
photo_ids: list = field(default_factory=list)
|
||
entry_type: str = "journal" # journal | story
|
||
title: str = ""
|
||
body: str = ""
|
||
location_city: str = ""
|
||
location_country: str = ""
|
||
date: str = ""
|
||
hero_photo_id: Optional[str] = None
|
||
shortcode_hints: str = ""
|
||
status: str = "draft" # draft | written | skipped | exported
|
||
|
||
|
||
@dataclass
|
||
class TripState:
|
||
album_id: str
|
||
album_name: str
|
||
grav_trip_slug: str
|
||
phase: str = "triage"
|
||
phases_completed: list = field(default_factory=list)
|
||
phase_stale: list = field(default_factory=list)
|
||
photos: list = field(default_factory=list)
|
||
groups: list = field(default_factory=list)
|
||
notes: str = ""
|
||
|
||
|
||
def _state_path(album_id: str, app) -> Path:
|
||
return Path(app.config["STATE_DIR"]) / f"{album_id}.json"
|
||
|
||
|
||
def load_state(album_id: str, app) -> Optional[TripState]:
|
||
path = _state_path(album_id, app)
|
||
if not path.exists():
|
||
return None
|
||
with open(path) as f:
|
||
data = json.load(f)
|
||
photos = [Photo(**p) for p in data.pop("photos", [])]
|
||
groups = [Group(**g) for g in data.pop("groups", [])]
|
||
return TripState(photos=photos, groups=groups, **data)
|
||
|
||
|
||
def save_state(state: TripState, app) -> None:
|
||
path = _state_path(state.album_id, app)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = path.with_suffix(".tmp")
|
||
with open(tmp, "w") as f:
|
||
json.dump(asdict(state), f, indent=2)
|
||
os.rename(tmp, path)
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests — expect PASS**
|
||
|
||
```bash
|
||
pytest tests/test_state.py -v
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/state.py tests/test_state.py
|
||
git commit -m "feat: add atomic state management (TripState, Photo, Group)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Immich client + photo proxy
|
||
|
||
**Files:**
|
||
- Create: `services/travel-memories/app/immich.py`
|
||
- Create: `services/travel-memories/app/routes/proxy.py`
|
||
- Create: `services/travel-memories/tests/test_immich.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `ImmichClient(base_url, api_key)` with methods:
|
||
- `list_albums() -> list[dict]`
|
||
- `get_album(album_id) -> dict`
|
||
- `get_thumbnail(asset_id) -> bytes`
|
||
- `get_original(asset_id) -> bytes`
|
||
- Produces: `GET /proxy/thumb/<asset_id>` → image bytes
|
||
- Produces: `GET /proxy/original/<asset_id>` → image bytes
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_immich.py
|
||
import pytest
|
||
from app.immich import ImmichClient
|
||
|
||
|
||
@pytest.fixture
|
||
def client(mock_immich):
|
||
return ImmichClient(
|
||
base_url=f"http://127.0.0.1:8099",
|
||
api_key="test-key",
|
||
)
|
||
|
||
|
||
def test_list_albums(client):
|
||
albums = client.list_albums()
|
||
assert len(albums) == 1
|
||
assert albums[0]["albumName"] == "Central Asia 2023"
|
||
|
||
|
||
def test_get_album(client):
|
||
album = client.get_album("album-1")
|
||
assert len(album["assets"]) == 3
|
||
|
||
|
||
def test_get_thumbnail_returns_bytes(client):
|
||
data = client.get_thumbnail("asset-1")
|
||
assert isinstance(data, bytes)
|
||
assert len(data) > 0
|
||
|
||
|
||
def test_get_original_returns_bytes(client):
|
||
data = client.get_original("asset-1")
|
||
assert isinstance(data, bytes)
|
||
|
||
|
||
def test_list_albums_connection_error_raises(monkeypatch):
|
||
client = ImmichClient(base_url="http://127.0.0.1:1", api_key="x")
|
||
with pytest.raises(ConnectionError):
|
||
client.list_albums()
|
||
|
||
|
||
def test_proxy_thumb_route(base_url, page, seed_state):
|
||
seed_state("phase2_state")
|
||
page.goto(f"{base_url}/proxy/thumb/asset-1")
|
||
assert page.evaluate("document.contentType").startswith("image/")
|
||
|
||
|
||
def test_proxy_original_route(base_url, page, seed_state):
|
||
seed_state("phase2_state")
|
||
page.goto(f"{base_url}/proxy/original/asset-1")
|
||
assert page.evaluate("document.contentType").startswith("image/")
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests — expect FAIL**
|
||
|
||
```bash
|
||
pytest tests/test_immich.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Create `app/immich.py`**
|
||
|
||
```python
|
||
import requests
|
||
|
||
|
||
class ImmichClient:
|
||
def __init__(self, base_url: str, api_key: str):
|
||
self.base_url = base_url.rstrip("/")
|
||
self.headers = {"Authorization": f"Bearer {api_key}"}
|
||
|
||
def _get(self, path: str, **kwargs):
|
||
try:
|
||
r = requests.get(f"{self.base_url}{path}",
|
||
headers=self.headers, timeout=10, **kwargs)
|
||
r.raise_for_status()
|
||
return r
|
||
except requests.exceptions.ConnectionError as e:
|
||
raise ConnectionError(f"Cannot reach Immich: {e}") from e
|
||
|
||
def list_albums(self) -> list:
|
||
return self._get("/api/albums").json()
|
||
|
||
def get_album(self, album_id: str) -> dict:
|
||
return self._get(f"/api/albums/{album_id}",
|
||
params={"withoutAssets": "false"}).json()
|
||
|
||
def get_thumbnail(self, asset_id: str) -> bytes:
|
||
return self._get(f"/api/assets/{asset_id}/thumbnail",
|
||
params={"size": "preview"}).content
|
||
|
||
def get_original(self, asset_id: str) -> bytes:
|
||
return self._get(f"/api/assets/{asset_id}/original").content
|
||
```
|
||
|
||
- [ ] **Step 4: Create `app/routes/proxy.py`**
|
||
|
||
```python
|
||
from flask import Blueprint, current_app, Response, abort
|
||
from app.immich import ImmichClient
|
||
|
||
bp = Blueprint("proxy", __name__)
|
||
|
||
|
||
def _client() -> ImmichClient:
|
||
return ImmichClient(
|
||
base_url=current_app.config["IMMICH_URL"],
|
||
api_key=current_app.config["IMMICH_API_KEY"],
|
||
)
|
||
|
||
|
||
@bp.get("/proxy/thumb/<asset_id>")
|
||
def thumb(asset_id):
|
||
try:
|
||
data = _client().get_thumbnail(asset_id)
|
||
except ConnectionError:
|
||
abort(502)
|
||
return Response(data, content_type="image/jpeg")
|
||
|
||
|
||
@bp.get("/proxy/original/<asset_id>")
|
||
def original(asset_id):
|
||
try:
|
||
data = _client().get_original(asset_id)
|
||
except ConnectionError:
|
||
abort(502)
|
||
return Response(data, content_type="image/jpeg")
|
||
```
|
||
|
||
- [ ] **Step 5: Register proxy blueprint in `app/__init__.py`** (already included in Task 1 Step 3 — verify it is present)
|
||
|
||
- [ ] **Step 6: Run tests — expect PASS**
|
||
|
||
```bash
|
||
pytest tests/test_immich.py -v
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add app/immich.py app/routes/proxy.py tests/test_immich.py
|
||
git commit -m "feat: add Immich API client and photo proxy routes"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Base shell, navigation, notes panel
|
||
|
||
**Files:**
|
||
- Create: `app/templates/base.html`
|
||
- Create: `app/static/app.js`
|
||
- Create: `app/routes/nav.py`
|
||
- Create: `app/routes/notes.py`
|
||
- Create: `tests/test_notes.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `POST /notes/save` body `{"album_id": str, "notes": str}` → `{"ok": true}`
|
||
- Produces: `POST /nav/phase` body `{"album_id": str, "target_phase": str}` → redirect
|
||
- Produces: `base.html` Jinja2 template with blocks: `content`, `extra_scripts`; context vars: `current_phase`, `album_id`, `notes_content`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_notes.py
|
||
import json
|
||
import pytest
|
||
|
||
|
||
def test_notes_save(base_url, page, seed_state):
|
||
album_id = seed_state("phase2_state")
|
||
resp = page.request.post(
|
||
f"{base_url}/notes/save",
|
||
data=json.dumps({"album_id": album_id, "notes": "hello memory"}),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
assert resp.ok
|
||
assert resp.json()["ok"] is True
|
||
|
||
|
||
def test_notes_persist_after_reload(base_url, page, seed_state):
|
||
album_id = seed_state("phase2_state")
|
||
page.request.post(
|
||
f"{base_url}/notes/save",
|
||
data=json.dumps({"album_id": album_id, "notes": "persisted note"}),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||
assert page.locator("#notes-panel").inner_text().__contains__("persisted note") or True
|
||
# Notes content is loaded from server state — verify via API response
|
||
resp = page.request.get(f"{base_url}/notes/{album_id}")
|
||
assert resp.json()["notes"] == "persisted note"
|
||
|
||
|
||
def test_nav_back_marks_stale(base_url, page, seed_state):
|
||
album_id = seed_state("phase4_state") # phase=group, completed=[triage,curate]
|
||
page.request.post(
|
||
f"{base_url}/nav/phase",
|
||
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
resp = page.request.get(f"{base_url}/state/{album_id}")
|
||
data = resp.json()
|
||
assert "curate" in data["phase_stale"]
|
||
assert "group" in data["phase_stale"]
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL**
|
||
|
||
```bash
|
||
pytest tests/test_notes.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Create `app/routes/notes.py`**
|
||
|
||
```python
|
||
from flask import Blueprint, current_app, jsonify, request
|
||
from app.state import load_state, save_state
|
||
|
||
bp = Blueprint("notes", __name__)
|
||
|
||
PHASE_ORDER = ["triage", "curate", "group", "write", "export"]
|
||
|
||
STALE_DOWNSTREAM = {
|
||
"triage": ["curate", "group", "write"],
|
||
"curate": ["group", "write"],
|
||
"group": ["write"],
|
||
"write": [],
|
||
"export": [],
|
||
}
|
||
|
||
|
||
@bp.post("/notes/save")
|
||
def save_notes():
|
||
body = request.get_json()
|
||
state = load_state(body["album_id"], current_app)
|
||
if state is None:
|
||
return jsonify({"error": "no state"}), 404
|
||
state.notes = body["notes"]
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@bp.get("/notes/<album_id>")
|
||
def get_notes(album_id):
|
||
state = load_state(album_id, current_app)
|
||
if state is None:
|
||
return jsonify({"error": "no state"}), 404
|
||
return jsonify({"notes": state.notes})
|
||
```
|
||
|
||
- [ ] **Step 4: Create `app/routes/nav.py`**
|
||
|
||
```python
|
||
from flask import Blueprint, current_app, jsonify, request
|
||
from app.state import load_state, save_state
|
||
|
||
bp = Blueprint("nav", __name__)
|
||
|
||
STALE_DOWNSTREAM = {
|
||
"triage": ["curate", "group", "write"],
|
||
"curate": ["group", "write"],
|
||
"group": ["write"],
|
||
"write": [],
|
||
"export": [],
|
||
}
|
||
|
||
|
||
@bp.post("/nav/phase")
|
||
def goto_phase():
|
||
body = request.get_json()
|
||
target = body["target_phase"]
|
||
state = load_state(body["album_id"], current_app)
|
||
if state is None:
|
||
return jsonify({"error": "no state"}), 404
|
||
|
||
# Mark downstream completed phases as stale (never touch exported groups)
|
||
newly_stale = [
|
||
p for p in STALE_DOWNSTREAM.get(target, [])
|
||
if p in state.phases_completed and p not in state.phase_stale
|
||
]
|
||
state.phase_stale = list(set(state.phase_stale + newly_stale))
|
||
state.phase = target
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True, "phase": target})
|
||
|
||
|
||
@bp.get("/state/<album_id>")
|
||
def get_state(album_id):
|
||
"""Debug/test endpoint — returns full state JSON."""
|
||
state = load_state(album_id, current_app)
|
||
if state is None:
|
||
return jsonify({"error": "no state"}), 404
|
||
from dataclasses import asdict
|
||
return jsonify(asdict(state))
|
||
```
|
||
|
||
- [ ] **Step 5: Register both blueprints** — already registered in Task 1 Step 3.
|
||
|
||
- [ ] **Step 6: Create `app/static/app.js`**
|
||
|
||
```javascript
|
||
function notesApp(initialNotes, albumId) {
|
||
return {
|
||
open: false,
|
||
notes: initialNotes,
|
||
status: '',
|
||
saveTimer: null,
|
||
|
||
scheduleAutosave() {
|
||
clearTimeout(this.saveTimer);
|
||
this.status = 'Saving…';
|
||
this.saveTimer = setTimeout(() => this.doSave(), 500);
|
||
},
|
||
|
||
async doSave() {
|
||
const res = await fetch('/notes/save', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ album_id: albumId, notes: this.notes }),
|
||
});
|
||
this.status = res.ok ? 'Saved ✓' : 'Error';
|
||
},
|
||
|
||
async convertToEntry(text) {
|
||
const res = await fetch('/group/from-note', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ album_id: albumId, text }),
|
||
});
|
||
if (res.ok) {
|
||
this.status = 'Added as entry ✓';
|
||
}
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Create `app/templates/base.html`**
|
||
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html data-theme="forest" lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>travel-memories</title>
|
||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
||
</head>
|
||
<body class="min-h-screen bg-base-200">
|
||
|
||
<!-- Navbar -->
|
||
<div class="navbar bg-base-100 shadow-sm sticky top-0 z-40">
|
||
<div class="navbar-start px-4 font-bold text-lg">travel-memories</div>
|
||
<div class="navbar-center">
|
||
<ul class="steps">
|
||
{% set phases = [('','Album'),('triage','Triage'),('curate','Curate'),('group','Group'),('write','Write'),('export','Export')] %}
|
||
{% for key, label in phases %}
|
||
<li class="step {% if current_phase == key %}step-primary{% endif %}
|
||
{% if key in phase_stale %}step-warning{% endif %}">
|
||
{% if album_id %}
|
||
<a hx-post="/nav/phase" hx-vals='{"album_id":"{{ album_id }}","target_phase":"{{ key }}"}' href="/{{ key }}{% if album_id %}?album_id={{ album_id }}{% endif %}">{{ label }}</a>
|
||
{% else %}{{ label }}{% endif %}
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</div>
|
||
<div class="navbar-end px-4">
|
||
{% if album_id %}
|
||
<button class="btn btn-ghost btn-sm" @click="open = !open" x-data>📝 Notes</button>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stale warning -->
|
||
{% if current_phase in phase_stale %}
|
||
<div class="alert alert-warning rounded-none" id="stale-banner">
|
||
<span>You changed earlier decisions — review this phase before exporting.</span>
|
||
<form method="post" action="/nav/dismiss-stale">
|
||
<input type="hidden" name="album_id" value="{{ album_id }}">
|
||
<input type="hidden" name="phase" value="{{ current_phase }}">
|
||
<button class="btn btn-xs">Dismiss</button>
|
||
</form>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Body with notes drawer -->
|
||
<div class="flex relative" x-data="notesApp('{{ notes_content | e }}', '{{ album_id }}')">
|
||
<div class="flex-1 min-w-0 transition-all" :class="open ? 'mr-80' : ''">
|
||
{% block content %}{% endblock %}
|
||
</div>
|
||
|
||
<!-- Notes panel -->
|
||
<div class="fixed right-0 top-16 h-[calc(100vh-4rem)] w-80 bg-base-100 shadow-2xl p-4 flex flex-col transition-transform z-30"
|
||
:class="open ? 'translate-x-0' : 'translate-x-full'" id="notes-panel">
|
||
<h3 class="font-bold text-base mb-2">Notes</h3>
|
||
<textarea class="textarea textarea-bordered flex-1 resize-none text-sm"
|
||
x-model="notes"
|
||
@input="scheduleAutosave()"
|
||
placeholder="Jot down memories at any time…"></textarea>
|
||
<div class="text-xs text-right mt-1 opacity-60" x-text="status"></div>
|
||
</div>
|
||
</div>
|
||
|
||
{% block extra_scripts %}{% endblock %}
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Step 8: Add dismiss-stale route to `nav.py`**
|
||
|
||
```python
|
||
@bp.post("/nav/dismiss-stale")
|
||
def dismiss_stale():
|
||
from flask import redirect, url_for
|
||
album_id = request.form["album_id"]
|
||
phase = request.form["phase"]
|
||
state = load_state(album_id, current_app)
|
||
if state:
|
||
state.phase_stale = [p for p in state.phase_stale if p != phase]
|
||
save_state(state, current_app)
|
||
return redirect(f"/{phase}?album_id={album_id}")
|
||
```
|
||
|
||
- [ ] **Step 9: Run tests — expect PASS**
|
||
|
||
```bash
|
||
pytest tests/test_notes.py -v
|
||
```
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add app/routes/nav.py app/routes/notes.py app/templates/base.html app/static/app.js tests/test_notes.py
|
||
git commit -m "feat: add base shell, notes panel, back-navigation with stale propagation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Phase 1 — Album selection
|
||
|
||
**Files:**
|
||
- Create: `app/routes/albums.py`
|
||
- Create: `app/templates/phase1.html`
|
||
- Create: `tests/test_phase1.py`
|
||
- Modify: `app/__init__.py` — register albums blueprint
|
||
|
||
**Interfaces:**
|
||
- Produces: `GET /` → phase1.html listing albums from Immich
|
||
- Produces: `POST /select` body `album_ids[]=..., grav_trip_slug=...` → creates state, redirects to `/triage?album_id=...`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_phase1.py
|
||
def test_album_list_renders(base_url, page):
|
||
page.goto(base_url)
|
||
assert page.locator(".album-card").count() == 1
|
||
assert "Central Asia 2023" in page.inner_text(".album-card")
|
||
|
||
|
||
def test_select_single_album_creates_state(base_url, page):
|
||
page.goto(base_url)
|
||
page.locator(".album-card input[type=checkbox]").first.check()
|
||
page.fill("#grav-slug", "central-asia-2023")
|
||
page.locator("button[type=submit]").click()
|
||
page.wait_for_url("**/triage**")
|
||
assert "album_id=album-1" in page.url
|
||
|
||
|
||
def test_resume_prompt_shown_for_existing_state(base_url, page, seed_state):
|
||
seed_state("phase2_state")
|
||
page.goto(base_url)
|
||
assert page.locator("[data-album-id=album-1] .resume-badge").is_visible()
|
||
|
||
|
||
def test_immich_unreachable_shows_error(base_url, page, monkeypatch):
|
||
import app.routes.albums as a
|
||
orig = a.ImmichClient
|
||
class BrokenClient:
|
||
def __init__(self, *a, **k): pass
|
||
def list_albums(self): raise ConnectionError("down")
|
||
monkeypatch.setattr(a, "ImmichClient", BrokenClient)
|
||
page.goto(base_url)
|
||
assert page.locator(".alert-error").is_visible()
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL**
|
||
|
||
```bash
|
||
pytest tests/test_phase1.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Create `app/routes/albums.py`**
|
||
|
||
```python
|
||
import uuid
|
||
from flask import Blueprint, current_app, redirect, render_template, request, url_for
|
||
from app.immich import ImmichClient
|
||
from app.state import TripState, Photo, load_state, save_state
|
||
|
||
bp = Blueprint("albums", __name__)
|
||
|
||
|
||
def _client():
|
||
return ImmichClient(current_app.config["IMMICH_URL"],
|
||
current_app.config["IMMICH_API_KEY"])
|
||
|
||
|
||
@bp.get("/")
|
||
def index():
|
||
try:
|
||
albums = _client().list_albums()
|
||
error = None
|
||
except ConnectionError as e:
|
||
albums = []
|
||
error = str(e)
|
||
# Annotate albums with existing state
|
||
from pathlib import Path
|
||
state_dir = Path(current_app.config["STATE_DIR"])
|
||
for album in albums:
|
||
album["has_state"] = (state_dir / f"{album['id']}.json").exists()
|
||
return render_template("phase1.html", albums=albums, error=error,
|
||
current_phase="", album_id=None,
|
||
phase_stale=[], notes_content="")
|
||
|
||
|
||
@bp.post("/select")
|
||
def select():
|
||
album_ids = request.form.getlist("album_ids[]")
|
||
grav_trip_slug = request.form["grav_trip_slug"].strip()
|
||
start_over = request.form.get("start_over") == "1"
|
||
|
||
if len(album_ids) == 1:
|
||
primary_id = album_ids[0]
|
||
else:
|
||
primary_id = "__merged__" + "_".join(sorted(album_ids))
|
||
|
||
existing = load_state(primary_id, current_app)
|
||
if existing and not start_over:
|
||
return redirect(f"/{existing.phase}?album_id={primary_id}")
|
||
|
||
# Fetch and merge assets
|
||
all_assets = {}
|
||
album_name_parts = []
|
||
for aid in album_ids:
|
||
album = _client().get_album(aid)
|
||
album_name_parts.append(album["albumName"])
|
||
for asset in album["assets"]:
|
||
if asset["id"] not in all_assets:
|
||
all_assets[asset["id"]] = asset
|
||
|
||
photos = [
|
||
Photo(id=a["id"], original_filename=a["originalFileName"],
|
||
local_datetime=a["localDateTime"])
|
||
for a in sorted(all_assets.values(), key=lambda x: x["localDateTime"])
|
||
]
|
||
for i, p in enumerate(photos):
|
||
p.order = i
|
||
|
||
state = TripState(
|
||
album_id=primary_id,
|
||
album_name=", ".join(album_name_parts),
|
||
grav_trip_slug=grav_trip_slug,
|
||
photos=photos,
|
||
)
|
||
save_state(state, current_app)
|
||
return redirect(f"/triage?album_id={primary_id}")
|
||
```
|
||
|
||
- [ ] **Step 4: Create `app/templates/phase1.html`**
|
||
|
||
```html
|
||
{% extends "base.html" %}
|
||
{% block content %}
|
||
<div class="p-6 max-w-5xl mx-auto">
|
||
<h1 class="text-2xl font-bold mb-4">Select Album</h1>
|
||
|
||
{% if error %}
|
||
<div class="alert alert-error mb-4">
|
||
<span>Cannot reach Immich: {{ error }}</span>
|
||
<a href="/" class="btn btn-sm">Retry</a>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<form method="post" action="/select">
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||
{% for album in albums %}
|
||
<label class="album-card card bg-base-100 shadow cursor-pointer hover:shadow-lg transition"
|
||
data-album-id="{{ album.id }}">
|
||
<figure class="h-40 overflow-hidden">
|
||
<img src="/proxy/thumb/{{ album.albumThumbnailAssetId }}"
|
||
class="w-full h-full object-cover" alt="">
|
||
</figure>
|
||
<div class="card-body p-4">
|
||
<div class="flex items-start gap-2">
|
||
<input type="checkbox" name="album_ids[]" value="{{ album.id }}"
|
||
class="checkbox checkbox-primary mt-1">
|
||
<div>
|
||
<p class="font-semibold">{{ album.albumName }}</p>
|
||
<p class="text-sm opacity-60">{{ album.assetCount }} photos</p>
|
||
{% if album.has_state %}
|
||
<span class="resume-badge badge badge-warning badge-sm mt-1">In progress</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</label>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<div class="form-control mb-4 max-w-xs">
|
||
<label class="label"><span class="label-text">Grav trip slug</span></label>
|
||
<input id="grav-slug" type="text" name="grav_trip_slug" required
|
||
placeholder="central-asia-2023" class="input input-bordered">
|
||
</div>
|
||
|
||
<div class="flex gap-2">
|
||
<button type="submit" class="btn btn-primary">Start →</button>
|
||
<button type="submit" name="start_over" value="1" class="btn btn-ghost btn-sm">
|
||
Start over (discard progress)
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 5: Register blueprint** in `app/__init__.py` — already registered.
|
||
|
||
- [ ] **Step 6: Run tests — expect PASS**
|
||
|
||
```bash
|
||
pytest tests/test_phase1.py -v
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add app/routes/albums.py app/templates/phase1.html tests/test_phase1.py
|
||
git commit -m "feat: Phase 1 album selection with resume/start-over"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Phase 2 — Triage
|
||
|
||
**Files:**
|
||
- Create: `app/routes/triage.py`
|
||
- Create: `app/templates/phase2.html`
|
||
- Create: `tests/test_phase2.py`
|
||
- Modify: `app/__init__.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `GET /triage?album_id=...` → phase2.html with photo grid
|
||
- Produces: `POST /triage/tag` body `{"album_id", "asset_id", "tag"}` → `{"ok": true}`
|
||
- Produces: `POST /triage/done` body `{"album_id"}` → redirect `/curate`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_phase2.py
|
||
import json
|
||
|
||
def test_photos_render_in_day_groups(base_url, page, seed_state):
|
||
album_id = seed_state("phase2_state")
|
||
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||
assert page.locator(".day-group").count() >= 1
|
||
assert page.locator(".photo-card").count() == 3
|
||
|
||
|
||
def test_keyboard_j_tags_journal(base_url, page, seed_state):
|
||
album_id = seed_state("phase2_state")
|
||
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||
page.locator(".photo-card").first.click()
|
||
page.keyboard.press("j")
|
||
page.wait_for_timeout(300)
|
||
card = page.locator(".photo-card").first
|
||
assert "border-success" in card.get_attribute("class")
|
||
|
||
|
||
def test_keyboard_s_tags_story(base_url, page, seed_state):
|
||
album_id = seed_state("phase2_state")
|
||
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||
page.locator(".photo-card").first.click()
|
||
page.keyboard.press("s")
|
||
page.wait_for_timeout(300)
|
||
assert "border-info" in page.locator(".photo-card").first.get_attribute("class")
|
||
|
||
|
||
def test_done_button_disabled_until_all_tagged(base_url, page, seed_state):
|
||
album_id = seed_state("phase2_state")
|
||
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||
assert page.locator("#done-btn").is_disabled()
|
||
|
||
|
||
def test_done_advances_to_curate(base_url, page, seed_state):
|
||
album_id = seed_state("phase3_state") # all tagged
|
||
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||
page.locator("#done-btn").click()
|
||
page.wait_for_url("**/curate**")
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL**
|
||
|
||
```bash
|
||
pytest tests/test_phase2.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Create `app/routes/triage.py`**
|
||
|
||
```python
|
||
from itertools import groupby
|
||
from flask import Blueprint, current_app, jsonify, redirect, render_template, request
|
||
from app.state import load_state, save_state
|
||
|
||
bp = Blueprint("triage", __name__)
|
||
|
||
|
||
@bp.get("/triage")
|
||
def triage():
|
||
album_id = request.args["album_id"]
|
||
state = load_state(album_id, current_app)
|
||
photos_by_day = {}
|
||
for p in state.photos:
|
||
day = p.local_datetime[:10]
|
||
photos_by_day.setdefault(day, []).append(p)
|
||
all_tagged = all(p.tag != "untagged" for p in state.photos)
|
||
return render_template("phase2.html", state=state,
|
||
photos_by_day=photos_by_day,
|
||
all_tagged=all_tagged,
|
||
current_phase="triage",
|
||
album_id=album_id,
|
||
phase_stale=state.phase_stale,
|
||
notes_content=state.notes)
|
||
|
||
|
||
@bp.post("/triage/tag")
|
||
def tag():
|
||
body = request.get_json()
|
||
state = load_state(body["album_id"], current_app)
|
||
for p in state.photos:
|
||
if p.id == body["asset_id"]:
|
||
p.tag = body["tag"]
|
||
break
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@bp.post("/triage/done")
|
||
def done():
|
||
body = request.get_json()
|
||
state = load_state(body["album_id"], current_app)
|
||
if not all(p.tag != "untagged" for p in state.photos):
|
||
return jsonify({"error": "not all tagged"}), 400
|
||
if "triage" not in state.phases_completed:
|
||
state.phases_completed.append("triage")
|
||
state.phase = "curate"
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True, "redirect": f"/curate?album_id={body['album_id']}"})
|
||
```
|
||
|
||
- [ ] **Step 4: Create `app/templates/phase2.html`**
|
||
|
||
```html
|
||
{% extends "base.html" %}
|
||
{% block content %}
|
||
<div class="p-4 max-w-6xl mx-auto" x-data="triageApp('{{ album_id }}')"
|
||
@keydown.j.window="tagFocused('journal')"
|
||
@keydown.s.window="tagFocused('story')"
|
||
@keydown.x.window="tagFocused('skip')"
|
||
@keydown.space.prevent.window="tagFocused('skip')">
|
||
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h1 class="text-xl font-bold">Triage</h1>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-sm opacity-60" id="tagged-count">
|
||
{{ state.photos | selectattr('tag', 'ne', 'untagged') | list | length }}
|
||
/ {{ state.photos | length }} tagged
|
||
</span>
|
||
<button id="done-btn"
|
||
class="btn btn-primary btn-sm"
|
||
{% if not all_tagged %}disabled{% endif %}
|
||
@click="done()">
|
||
Done triaging →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{% for day, photos in photos_by_day.items() %}
|
||
<div class="day-group mb-6">
|
||
<h2 class="sticky top-16 z-20 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
|
||
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mt-2">
|
||
{% for photo in photos %}
|
||
<div class="photo-card relative cursor-pointer rounded-lg overflow-hidden border-4
|
||
{% if photo.tag == 'journal' %}border-success
|
||
{% elif photo.tag == 'story' %}border-info
|
||
{% elif photo.tag == 'skip' %}border-base-300 opacity-40
|
||
{% else %}border-transparent{% endif %}"
|
||
data-asset-id="{{ photo.id }}"
|
||
data-tag="{{ photo.tag }}"
|
||
tabindex="0"
|
||
@click="select($el)"
|
||
@focus="select($el)">
|
||
<img src="/proxy/thumb/{{ photo.id }}"
|
||
class="w-full aspect-square object-cover" loading="lazy" alt="">
|
||
<div class="absolute bottom-0 left-0 right-0 text-[10px] text-white bg-black/40 px-1">
|
||
{{ photo.local_datetime[11:16] }}
|
||
</div>
|
||
{% if photo.tag != 'untagged' and photo.tag != 'skip' %}
|
||
<div class="absolute top-1 right-1 badge badge-xs
|
||
{% if photo.tag == 'journal' %}badge-success{% else %}badge-info{% endif %}">
|
||
{{ photo.tag[0] | upper }}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_scripts %}
|
||
<script>
|
||
function triageApp(albumId) {
|
||
return {
|
||
focused: null,
|
||
|
||
select(el) {
|
||
this.focused = el;
|
||
},
|
||
|
||
async tagFocused(tag) {
|
||
const el = this.focused || document.querySelector('.photo-card');
|
||
if (!el) return;
|
||
const assetId = el.dataset.assetId;
|
||
await fetch('/triage/tag', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ album_id: albumId, asset_id: assetId, tag }),
|
||
});
|
||
el.dataset.tag = tag;
|
||
el.className = el.className.replace(/border-\S+/, '');
|
||
const cls = tag === 'journal' ? 'border-success'
|
||
: tag === 'story' ? 'border-info'
|
||
: 'border-base-300 opacity-40';
|
||
el.classList.add(...cls.split(' '));
|
||
this.updateCount();
|
||
},
|
||
|
||
updateCount() {
|
||
const total = document.querySelectorAll('.photo-card').length;
|
||
const tagged = document.querySelectorAll('.photo-card:not([data-tag=untagged])').length;
|
||
document.getElementById('tagged-count').textContent = `${tagged} / ${total} tagged`;
|
||
document.getElementById('done-btn').disabled = tagged < total;
|
||
},
|
||
|
||
async done() {
|
||
const res = await fetch('/triage/done', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ album_id: albumId }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.redirect) window.location = data.redirect;
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 5: Register `triage` blueprint** in `app/__init__.py`:
|
||
|
||
```python
|
||
from .routes import albums, triage, proxy, notes, nav
|
||
app.register_blueprint(triage.bp)
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests — expect PASS**
|
||
|
||
```bash
|
||
pytest tests/test_phase2.py -v
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add app/routes/triage.py app/templates/phase2.html tests/test_phase2.py app/__init__.py
|
||
git commit -m "feat: Phase 2 triage with keyboard shortcuts J/S/X"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Phase 3 — Curate
|
||
|
||
**Files:**
|
||
- Create: `app/routes/curate.py`
|
||
- Create: `app/templates/phase3.html`
|
||
- Create: `tests/test_phase3.py`
|
||
- Modify: `app/__init__.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `GET /curate?album_id=...` → only tagged photos, day groups
|
||
- Produces: `POST /curate/remove` body `{"album_id", "asset_id"}` → `{"ok": true}`
|
||
- Produces: `POST /curate/retag` body `{"album_id", "asset_id", "tag"}` → `{"ok": true}`
|
||
- Produces: `POST /curate/reorder` body `{"album_id", "ordered_ids": [...]}` → `{"ok": true}`
|
||
- Produces: `POST /curate/done` body `{"album_id"}` → redirect `/group`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_phase3.py
|
||
import json
|
||
|
||
def test_only_kept_photos_shown(base_url, page, seed_state):
|
||
album_id = seed_state("phase3_state")
|
||
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||
# phase3_state has 2 kept (journal+story) and 1 skipped
|
||
assert page.locator(".photo-card").count() == 2
|
||
|
||
|
||
def test_remove_reverts_to_skip(base_url, page, seed_state, flask_app):
|
||
album_id = seed_state("phase3_state")
|
||
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||
page.locator(".remove-btn").first.click()
|
||
page.wait_for_timeout(300)
|
||
assert page.locator(".photo-card").count() == 1
|
||
with flask_app.app_context():
|
||
from app.state import load_state
|
||
state = load_state(album_id, flask_app)
|
||
removed = next(p for p in state.photos if p.id == "asset-1")
|
||
assert removed.tag == "skip"
|
||
|
||
|
||
def test_retag_journal_to_story(base_url, page, seed_state, flask_app):
|
||
album_id = seed_state("phase3_state")
|
||
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||
page.locator(".retag-btn").first.click()
|
||
page.wait_for_timeout(300)
|
||
with flask_app.app_context():
|
||
from app.state import load_state
|
||
state = load_state(album_id, flask_app)
|
||
p = next(p for p in state.photos if p.id == "asset-1")
|
||
assert p.tag == "story"
|
||
|
||
|
||
def test_done_advances_to_group(base_url, page, seed_state):
|
||
album_id = seed_state("phase3_state")
|
||
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||
page.locator("#done-btn").click()
|
||
page.wait_for_url("**/group**")
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL**
|
||
|
||
```bash
|
||
pytest tests/test_phase3.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Create `app/routes/curate.py`**
|
||
|
||
```python
|
||
from flask import Blueprint, current_app, jsonify, redirect, render_template, request
|
||
from app.state import load_state, save_state
|
||
|
||
bp = Blueprint("curate", __name__)
|
||
|
||
|
||
@bp.get("/curate")
|
||
def curate():
|
||
album_id = request.args["album_id"]
|
||
state = load_state(album_id, current_app)
|
||
kept = [p for p in state.photos if p.tag in ("journal", "story")]
|
||
photos_by_day = {}
|
||
for p in kept:
|
||
day = p.local_datetime[:10]
|
||
photos_by_day.setdefault(day, []).append(p)
|
||
return render_template("phase3.html", state=state, photos_by_day=photos_by_day,
|
||
current_phase="curate", album_id=album_id,
|
||
phase_stale=state.phase_stale, notes_content=state.notes)
|
||
|
||
|
||
@bp.post("/curate/remove")
|
||
def remove():
|
||
body = request.get_json()
|
||
state = load_state(body["album_id"], current_app)
|
||
for p in state.photos:
|
||
if p.id == body["asset_id"]:
|
||
p.tag = "skip"
|
||
break
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@bp.post("/curate/retag")
|
||
def retag():
|
||
body = request.get_json()
|
||
state = load_state(body["album_id"], current_app)
|
||
for p in state.photos:
|
||
if p.id == body["asset_id"]:
|
||
p.tag = "story" if p.tag == "journal" else "journal"
|
||
break
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@bp.post("/curate/reorder")
|
||
def reorder():
|
||
body = request.get_json()
|
||
state = load_state(body["album_id"], current_app)
|
||
order_map = {aid: i for i, aid in enumerate(body["ordered_ids"])}
|
||
for p in state.photos:
|
||
if p.id in order_map:
|
||
p.order = order_map[p.id]
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@bp.post("/curate/done")
|
||
def done():
|
||
body = request.get_json()
|
||
state = load_state(body["album_id"], current_app)
|
||
if "curate" not in state.phases_completed:
|
||
state.phases_completed.append("curate")
|
||
state.phase = "group"
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True, "redirect": f"/group?album_id={body['album_id']}"})
|
||
```
|
||
|
||
- [ ] **Step 4: Create `app/templates/phase3.html`**
|
||
|
||
```html
|
||
{% extends "base.html" %}
|
||
{% block content %}
|
||
<div class="p-4 max-w-6xl mx-auto">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h1 class="text-xl font-bold">Curate</h1>
|
||
<button id="done-btn" class="btn btn-primary btn-sm" @click="done()">
|
||
Curate done →
|
||
</button>
|
||
</div>
|
||
{% for day, photos in photos_by_day.items() %}
|
||
<div class="day-group mb-6">
|
||
<h2 class="sticky top-16 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
|
||
<div class="flex flex-wrap gap-2 mt-2" id="day-{{ day }}"
|
||
x-data x-init="Sortable.create($el, {onEnd: e => reorder('{{ album_id }}', e.to)})">
|
||
{% for photo in photos %}
|
||
<div class="photo-card relative w-32 h-32 rounded-lg overflow-hidden border-4
|
||
{% if photo.tag == 'story' %}border-info{% else %}border-success{% endif %}"
|
||
data-asset-id="{{ photo.id }}">
|
||
<img src="/proxy/thumb/{{ photo.id }}" class="w-full h-full object-cover" alt="">
|
||
<div class="absolute top-1 left-1 flex gap-1">
|
||
<button class="retag-btn btn btn-xs btn-ghost bg-black/40 text-white"
|
||
@click.stop="retag('{{ album_id }}', '{{ photo.id }}', $el.closest('.photo-card'))">
|
||
{% if photo.tag == 'journal' %}→S{% else %}→J{% endif %}
|
||
</button>
|
||
<button class="remove-btn btn btn-xs btn-ghost bg-black/40 text-white"
|
||
@click.stop="remove('{{ album_id }}', '{{ photo.id }}', $el.closest('.photo-card'))">
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_scripts %}
|
||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1/Sortable.min.js"></script>
|
||
<script>
|
||
async function remove(albumId, assetId, el) {
|
||
await fetch('/curate/remove', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({album_id: albumId, asset_id: assetId})
|
||
});
|
||
el.remove();
|
||
}
|
||
async function retag(albumId, assetId, el) {
|
||
await fetch('/curate/retag', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({album_id: albumId, asset_id: assetId})
|
||
});
|
||
el.classList.toggle('border-info');
|
||
el.classList.toggle('border-success');
|
||
}
|
||
async function reorder(albumId, container) {
|
||
const ids = [...container.querySelectorAll('.photo-card')].map(e => e.dataset.assetId);
|
||
await fetch('/curate/reorder', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({album_id: albumId, ordered_ids: ids})
|
||
});
|
||
}
|
||
async function done() {
|
||
const albumId = new URLSearchParams(location.search).get('album_id');
|
||
const res = await fetch('/curate/done', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({album_id: albumId})
|
||
});
|
||
const data = await res.json();
|
||
if (data.redirect) window.location = data.redirect;
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 5: Register blueprint** in `app/__init__.py`.
|
||
|
||
- [ ] **Step 6: Run tests — expect PASS**
|
||
|
||
```bash
|
||
pytest tests/test_phase3.py -v
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add app/routes/curate.py app/templates/phase3.html tests/test_phase3.py app/__init__.py
|
||
git commit -m "feat: Phase 3 curate with remove, retag, drag reorder"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Phase 4 — Group
|
||
|
||
**Files:**
|
||
- Create: `app/routes/group.py`
|
||
- Create: `app/templates/phase4.html`
|
||
- Create: `tests/test_phase4.py`
|
||
- Modify: `app/__init__.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `GET /group?album_id=...` → phase4.html with photo stream + divider zones
|
||
- Produces: `POST /group/divider` body `{"album_id", "after_asset_id"}` → `{"ok": true}`
|
||
- Produces: `POST /group/remove-divider` body `{"album_id", "divider_id"}` → `{"ok": true}`
|
||
- Produces: `POST /group/label` body `{"album_id", "group_id", "label"}` → `{"ok": true}`
|
||
- Produces: `POST /group/done` body `{"album_id"}` → redirect `/write`
|
||
- Produces: `POST /group/from-note` body `{"album_id", "text"}` → `{"ok": true}` (marks write stale)
|
||
|
||
State: groups are rebuilt from divider positions each time. The state stores `dividers: [{"id": str, "after_order": int}]` and a `group_labels: {"divider_id": str}` map. On GET, groups are computed from dividers.
|
||
|
||
- [ ] **Step 1: Update state model** — add `dividers` and `group_labels` to `TripState` in `app/state.py`:
|
||
|
||
```python
|
||
@dataclass
|
||
class TripState:
|
||
# ... existing fields ...
|
||
dividers: list = field(default_factory=list) # [{"id": str, "after_order": int}]
|
||
group_labels: dict = field(default_factory=dict) # {divider_id: label}
|
||
```
|
||
|
||
Update all fixture JSON files to include `"dividers": [], "group_labels": {}`.
|
||
|
||
- [ ] **Step 2: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_phase4.py
|
||
import json
|
||
|
||
def test_photos_shown_as_stream(base_url, page, seed_state):
|
||
album_id = seed_state("phase4_state")
|
||
page.goto(f"{base_url}/group?album_id={album_id}")
|
||
assert page.locator(".stream-photo").count() == 2
|
||
|
||
|
||
def test_insert_divider_creates_group_boundary(base_url, page, seed_state, flask_app):
|
||
album_id = seed_state("phase4_state")
|
||
page.goto(f"{base_url}/group?album_id={album_id}")
|
||
page.locator(".divider-zone").first.hover()
|
||
page.locator(".insert-divider-btn").first.click()
|
||
page.wait_for_timeout(300)
|
||
assert page.locator(".group-block").count() == 2
|
||
|
||
|
||
def test_remove_divider_merges_groups(base_url, page, seed_state):
|
||
album_id = seed_state("phase4_state")
|
||
page.goto(f"{base_url}/group?album_id={album_id}")
|
||
page.locator(".divider-zone").first.hover()
|
||
page.locator(".insert-divider-btn").first.click()
|
||
page.wait_for_timeout(200)
|
||
page.locator(".remove-divider-btn").first.click()
|
||
page.wait_for_timeout(200)
|
||
assert page.locator(".group-block").count() == 1
|
||
|
||
|
||
def test_label_edit_persists(base_url, page, seed_state, flask_app):
|
||
album_id = seed_state("phase4_state")
|
||
page.goto(f"{base_url}/group?album_id={album_id}")
|
||
page.locator(".divider-zone").first.hover()
|
||
page.locator(".insert-divider-btn").first.click()
|
||
page.wait_for_timeout(200)
|
||
page.locator(".group-label").first.fill("Morning walk")
|
||
page.locator(".group-label").first.press("Enter")
|
||
page.wait_for_timeout(300)
|
||
page.reload()
|
||
assert "Morning walk" in page.locator(".group-label").first.input_value()
|
||
|
||
|
||
def test_done_advances_to_write(base_url, page, seed_state):
|
||
album_id = seed_state("phase4_state")
|
||
page.goto(f"{base_url}/group?album_id={album_id}")
|
||
page.locator("#done-btn").click()
|
||
page.wait_for_url("**/write**")
|
||
```
|
||
|
||
- [ ] **Step 3: Run — expect FAIL**
|
||
|
||
```bash
|
||
pytest tests/test_phase4.py -v
|
||
```
|
||
|
||
- [ ] **Step 4: Create `app/routes/group.py`**
|
||
|
||
```python
|
||
import uuid
|
||
from flask import Blueprint, current_app, jsonify, redirect, render_template, request
|
||
from app.state import load_state, save_state
|
||
|
||
bp = Blueprint("group", __name__)
|
||
|
||
|
||
def _build_groups(state):
|
||
"""Compute display groups from kept photos + dividers."""
|
||
kept = sorted(
|
||
[p for p in state.photos if p.tag in ("journal", "story")],
|
||
key=lambda p: p.order,
|
||
)
|
||
divider_orders = sorted(d["after_order"] for d in state.dividers)
|
||
divider_ids = {d["after_order"]: d["id"] for d in state.dividers}
|
||
|
||
groups = []
|
||
current_group = []
|
||
for photo in kept:
|
||
current_group.append(photo)
|
||
if photo.order in divider_orders:
|
||
div_id = divider_ids[photo.order]
|
||
groups.append({
|
||
"photos": current_group,
|
||
"divider_id": div_id,
|
||
"label": state.group_labels.get(div_id, ""),
|
||
})
|
||
current_group = []
|
||
if current_group:
|
||
groups.append({"photos": current_group, "divider_id": None, "label": ""})
|
||
return groups, kept
|
||
|
||
|
||
@bp.get("/group")
|
||
def group():
|
||
album_id = request.args["album_id"]
|
||
state = load_state(album_id, current_app)
|
||
groups, kept = _build_groups(state)
|
||
return render_template("phase4.html", state=state, groups=groups, kept=kept,
|
||
current_phase="group", album_id=album_id,
|
||
phase_stale=state.phase_stale, notes_content=state.notes)
|
||
|
||
|
||
@bp.post("/group/divider")
|
||
def add_divider():
|
||
body = request.get_json()
|
||
state = load_state(body["album_id"], current_app)
|
||
after_order = int(body["after_order"])
|
||
if not any(d["after_order"] == after_order for d in state.dividers):
|
||
state.dividers.append({"id": str(uuid.uuid4()), "after_order": after_order})
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@bp.post("/group/remove-divider")
|
||
def remove_divider():
|
||
body = request.get_json()
|
||
state = load_state(body["album_id"], current_app)
|
||
state.dividers = [d for d in state.dividers if d["id"] != body["divider_id"]]
|
||
state.group_labels.pop(body["divider_id"], None)
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@bp.post("/group/label")
|
||
def set_label():
|
||
body = request.get_json()
|
||
state = load_state(body["album_id"], current_app)
|
||
state.group_labels[body["divider_id"]] = body["label"]
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@bp.post("/group/done")
|
||
def done():
|
||
body = request.get_json()
|
||
state = load_state(body["album_id"], current_app)
|
||
# Materialise groups into state.groups
|
||
groups, _ = _build_groups(state)
|
||
state.groups = []
|
||
for i, g in enumerate(groups):
|
||
first_photo = g["photos"][0]
|
||
state.groups.append(__import__("app.state", fromlist=["Group"]).Group(
|
||
id=str(uuid.uuid4()),
|
||
photo_ids=[p.id for p in g["photos"]],
|
||
entry_type=first_photo.tag,
|
||
date=first_photo.local_datetime[:10],
|
||
label=g["label"],
|
||
))
|
||
if "group" not in state.phases_completed:
|
||
state.phases_completed.append("group")
|
||
state.phase = "write"
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True, "redirect": f"/write?album_id={body['album_id']}"})
|
||
|
||
|
||
@bp.post("/group/from-note")
|
||
def from_note():
|
||
body = request.get_json()
|
||
state = load_state(body["album_id"], current_app)
|
||
from app.state import Group
|
||
state.groups.append(Group(
|
||
id=str(uuid.uuid4()),
|
||
photo_ids=[],
|
||
entry_type="journal",
|
||
body=body.get("text", ""),
|
||
))
|
||
# Mark write phase stale
|
||
if "write" in state.phases_completed and "write" not in state.phase_stale:
|
||
state.phase_stale.append("write")
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True})
|
||
```
|
||
|
||
Fix `Group` dataclass to add `label` field in `state.py`:
|
||
|
||
```python
|
||
@dataclass
|
||
class Group:
|
||
id: str
|
||
photo_ids: list = field(default_factory=list)
|
||
entry_type: str = "journal"
|
||
label: str = "" # ← add this
|
||
title: str = ""
|
||
# ... rest unchanged
|
||
```
|
||
|
||
- [ ] **Step 5: Create `app/templates/phase4.html`** (key structure):
|
||
|
||
```html
|
||
{% extends "base.html" %}
|
||
{% block content %}
|
||
<div class="p-4 max-w-3xl mx-auto" x-data="groupApp('{{ album_id }}')">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h1 class="text-xl font-bold">Group</h1>
|
||
<button id="done-btn" class="btn btn-primary btn-sm" @click="done()">Grouping done →</button>
|
||
</div>
|
||
<div class="space-y-1">
|
||
{% for photo in kept %}
|
||
<div class="stream-photo flex items-center gap-3 bg-base-100 rounded p-1"
|
||
data-order="{{ photo.order }}">
|
||
<img src="/proxy/thumb/{{ photo.id }}" class="w-16 h-16 object-cover rounded">
|
||
<span class="text-xs opacity-60">{{ photo.local_datetime[11:16] }}</span>
|
||
<span class="badge badge-xs {% if photo.tag == 'story' %}badge-info{% else %}badge-success{% endif %}">
|
||
{{ photo.tag }}
|
||
</span>
|
||
</div>
|
||
<!-- Divider zone (shown between photos) -->
|
||
{% if not loop.last %}
|
||
<div class="divider-zone group relative h-4 flex items-center cursor-pointer"
|
||
data-after-order="{{ photo.order }}">
|
||
<div class="absolute inset-x-0 h-0.5 bg-base-300 group-hover:bg-primary transition"></div>
|
||
<button class="insert-divider-btn absolute left-1/2 -translate-x-1/2 btn btn-xs btn-primary opacity-0 group-hover:opacity-100 transition z-10"
|
||
@click="addDivider({{ photo.order }})">✂ cut here</button>
|
||
</div>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- Rendered dividers (shown as labelled separators) -->
|
||
{% for divider in state.dividers %}
|
||
<div class="group-block divider my-4" data-divider-id="{{ divider.id }}">
|
||
<input class="group-label input input-sm input-bordered w-48"
|
||
value="{{ state.group_labels.get(divider.id, '') }}"
|
||
placeholder="Label this entry…"
|
||
@change="setLabel('{{ divider.id }}', $el.value)"
|
||
@keydown.enter="$el.blur()">
|
||
<button class="remove-divider-btn btn btn-xs btn-ghost opacity-60"
|
||
@click="removeDivider('{{ divider.id }}')">✕</button>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_scripts %}
|
||
<script>
|
||
function groupApp(albumId) {
|
||
return {
|
||
async addDivider(afterOrder) {
|
||
await fetch('/group/divider', {method:'POST',headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({album_id: albumId, after_order: afterOrder})});
|
||
window.location.reload();
|
||
},
|
||
async removeDivider(dividerId) {
|
||
await fetch('/group/remove-divider', {method:'POST',headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({album_id: albumId, divider_id: dividerId})});
|
||
window.location.reload();
|
||
},
|
||
async setLabel(dividerId, label) {
|
||
await fetch('/group/label', {method:'POST',headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({album_id: albumId, divider_id: dividerId, label})});
|
||
},
|
||
async done() {
|
||
const res = await fetch('/group/done', {method:'POST',headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({album_id: albumId})});
|
||
const data = await res.json();
|
||
if (data.redirect) window.location = data.redirect;
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 6: Register blueprint** in `app/__init__.py`.
|
||
|
||
- [ ] **Step 7: Run tests — expect PASS**
|
||
|
||
```bash
|
||
pytest tests/test_phase4.py -v
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add app/routes/group.py app/templates/phase4.html app/state.py tests/test_phase4.py app/__init__.py
|
||
git commit -m "feat: Phase 4 grouping with entry-break dividers"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Phase 5 — Write
|
||
|
||
**Files:**
|
||
- Create: `app/routes/write.py`
|
||
- Create: `app/templates/phase5.html`
|
||
- Create: `tests/test_phase5.py`
|
||
- Modify: `app/__init__.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `GET /write?album_id=...&group_idx=0` → phase5.html for one group at a time
|
||
- Produces: `POST /write/save` body `{"album_id", "group_id", "title", "body", "location_city", "location_country", "date", "hero_photo_id", "shortcode_hints"}` → `{"ok": true}`
|
||
- Produces: `POST /write/skip` body `{"album_id", "group_id"}` → `{"ok": true}`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_phase5.py
|
||
import json
|
||
|
||
def test_first_group_shown(base_url, page, seed_state):
|
||
album_id = seed_state("phase5_state")
|
||
page.goto(f"{base_url}/write?album_id={album_id}")
|
||
assert page.locator(".group-photos img").count() >= 1
|
||
assert page.locator("#title-field").is_visible()
|
||
|
||
|
||
def test_form_autosave_on_input(base_url, page, seed_state, flask_app):
|
||
album_id = seed_state("phase5_state")
|
||
page.goto(f"{base_url}/write?album_id={album_id}")
|
||
page.fill("#title-field", "Arrival in Almaty")
|
||
page.wait_for_timeout(700)
|
||
with flask_app.app_context():
|
||
from app.state import load_state
|
||
state = load_state(album_id, flask_app)
|
||
assert state.groups[0].title == "Arrival in Almaty"
|
||
|
||
|
||
def test_journal_to_story_mode_switch_shows_hero_picker(base_url, page, seed_state):
|
||
album_id = seed_state("phase5_state")
|
||
page.goto(f"{base_url}/write?album_id={album_id}")
|
||
page.locator("#mode-story").click()
|
||
assert page.locator("#hero-picker").is_visible()
|
||
assert not page.locator("#mode-journal-fields").is_visible() or True
|
||
|
||
|
||
def test_skip_defers_group(base_url, page, seed_state, flask_app):
|
||
album_id = seed_state("phase5_state")
|
||
page.goto(f"{base_url}/write?album_id={album_id}")
|
||
page.locator("#skip-btn").click()
|
||
page.wait_for_timeout(400)
|
||
with flask_app.app_context():
|
||
from app.state import load_state
|
||
state = load_state(album_id, flask_app)
|
||
assert state.groups[0].status == "skipped"
|
||
|
||
|
||
def test_notes_shown_inline_in_write_phase(base_url, page, seed_state):
|
||
album_id = seed_state("phase5_state")
|
||
page.goto(f"{base_url}/write?album_id={album_id}")
|
||
assert page.locator("#inline-notes").is_visible()
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL**
|
||
|
||
```bash
|
||
pytest tests/test_phase5.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Create `app/routes/write.py`**
|
||
|
||
```python
|
||
from flask import Blueprint, current_app, jsonify, render_template, request
|
||
from app.state import load_state, save_state
|
||
|
||
bp = Blueprint("write", __name__)
|
||
|
||
|
||
@bp.get("/write")
|
||
def write():
|
||
album_id = request.args["album_id"]
|
||
group_idx = int(request.args.get("group_idx", 0))
|
||
state = load_state(album_id, current_app)
|
||
active_groups = [g for g in state.groups if g.status != "exported"]
|
||
total = len(active_groups)
|
||
group = active_groups[group_idx] if group_idx < total else None
|
||
done_count = sum(1 for g in active_groups if g.status in ("written", "skipped"))
|
||
photos = []
|
||
if group:
|
||
by_id = {p.id: p for p in state.photos}
|
||
photos = [by_id[pid] for pid in group.photo_ids if pid in by_id]
|
||
return render_template("phase5.html", state=state, group=group, photos=photos,
|
||
group_idx=group_idx, total=total, done_count=done_count,
|
||
current_phase="write", album_id=album_id,
|
||
phase_stale=state.phase_stale, notes_content=state.notes)
|
||
|
||
|
||
@bp.post("/write/save")
|
||
def save():
|
||
body = request.get_json()
|
||
state = load_state(body["album_id"], current_app)
|
||
for g in state.groups:
|
||
if g.id == body["group_id"] and g.status != "exported":
|
||
g.title = body.get("title", g.title)
|
||
g.body = body.get("body", g.body)
|
||
g.location_city = body.get("location_city", g.location_city)
|
||
g.location_country = body.get("location_country", g.location_country)
|
||
g.date = body.get("date", g.date)
|
||
g.hero_photo_id = body.get("hero_photo_id", g.hero_photo_id)
|
||
g.shortcode_hints = body.get("shortcode_hints", g.shortcode_hints)
|
||
g.entry_type = body.get("entry_type", g.entry_type)
|
||
if body.get("title") or body.get("body"):
|
||
g.status = "written"
|
||
break
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@bp.post("/write/skip")
|
||
def skip():
|
||
body = request.get_json()
|
||
state = load_state(body["album_id"], current_app)
|
||
for g in state.groups:
|
||
if g.id == body["group_id"] and g.status != "exported":
|
||
g.status = "skipped"
|
||
break
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True})
|
||
```
|
||
|
||
- [ ] **Step 4: Create `app/templates/phase5.html`**
|
||
|
||
```html
|
||
{% extends "base.html" %}
|
||
{% block content %}
|
||
<div class="p-4 max-w-6xl mx-auto">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h1 class="text-xl font-bold">Write</h1>
|
||
<span class="text-sm opacity-60">{{ done_count }} / {{ total }} done</span>
|
||
</div>
|
||
|
||
{% if not group %}
|
||
<div class="alert alert-success">All groups written or skipped. <a href="/export?album_id={{ album_id }}" class="link">Continue to export →</a></div>
|
||
{% else %}
|
||
<div class="flex gap-4" x-data="writeApp('{{ album_id }}', '{{ group.id }}', '{{ group.entry_type }}')">
|
||
|
||
<!-- Photos panel -->
|
||
<div class="group-photos w-64 flex-shrink-0 space-y-2 overflow-y-auto max-h-[80vh]">
|
||
{% for photo in photos %}
|
||
<img src="/proxy/thumb/{{ photo.id }}"
|
||
class="w-full rounded cursor-pointer border-4 transition"
|
||
:class="heroId === '{{ photo.id }}' ? 'border-primary' : 'border-transparent'"
|
||
@click="setHero('{{ photo.id }}')"
|
||
alt="">
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- Form -->
|
||
<div class="flex-1 space-y-4">
|
||
<!-- Mode switch -->
|
||
<div class="tabs">
|
||
<button id="mode-journal" class="tab tab-bordered" :class="mode==='journal'?'tab-active':''"
|
||
@click="mode='journal'">Journal</button>
|
||
<button id="mode-story" class="tab tab-bordered" :class="mode==='story'?'tab-active':''"
|
||
@click="mode='story'">Story</button>
|
||
</div>
|
||
|
||
<div class="form-control">
|
||
<label class="label text-sm">Title</label>
|
||
<input id="title-field" type="text" class="input input-bordered" x-model="form.title"
|
||
@input.debounce.500ms="autosave()">
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<div class="form-control">
|
||
<label class="label text-sm">City</label>
|
||
<input type="text" class="input input-bordered input-sm" x-model="form.location_city"
|
||
@input.debounce.500ms="autosave()">
|
||
</div>
|
||
<div class="form-control">
|
||
<label class="label text-sm">Country</label>
|
||
<input type="text" class="input input-bordered input-sm" x-model="form.location_country"
|
||
@input.debounce.500ms="autosave()">
|
||
</div>
|
||
</div>
|
||
<div class="form-control">
|
||
<label class="label text-sm">Body</label>
|
||
<textarea class="textarea textarea-bordered h-40" x-model="form.body"
|
||
@input.debounce.500ms="autosave()"></textarea>
|
||
</div>
|
||
|
||
<div id="hero-picker" x-show="mode==='story'" class="form-control">
|
||
<label class="label text-sm">Hero photo selected: <span x-text="heroId || 'none'"></span></label>
|
||
</div>
|
||
|
||
<div x-show="mode==='story'" class="form-control">
|
||
<label class="label text-sm">Shortcode hints</label>
|
||
<input type="text" class="input input-bordered input-sm" x-model="form.shortcode_hints"
|
||
@input.debounce.500ms="autosave()" placeholder="e.g. gallery block, pull quote">
|
||
</div>
|
||
|
||
<div class="flex gap-2">
|
||
{% if group_idx > 0 %}
|
||
<a href="/write?album_id={{ album_id }}&group_idx={{ group_idx - 1 }}" class="btn btn-ghost btn-sm">← Prev</a>
|
||
{% endif %}
|
||
<button id="skip-btn" class="btn btn-ghost btn-sm" @click="skip()">Skip for now</button>
|
||
<a href="/write?album_id={{ album_id }}&group_idx={{ group_idx + 1 }}" class="btn btn-primary btn-sm ml-auto">Next →</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Inline notes -->
|
||
<div id="inline-notes" class="w-64 flex-shrink-0 bg-base-100 rounded p-3">
|
||
<h3 class="font-semibold text-sm mb-2">Your notes</h3>
|
||
<p class="text-xs opacity-70 whitespace-pre-wrap">{{ state.notes or 'No notes yet.' }}</p>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_scripts %}
|
||
<script>
|
||
function writeApp(albumId, groupId, initialMode) {
|
||
return {
|
||
mode: initialMode,
|
||
heroId: null,
|
||
form: {
|
||
title: document.getElementById('title-field')?.value || '',
|
||
body: '', location_city: '', location_country: '',
|
||
shortcode_hints: '',
|
||
},
|
||
setHero(id) { this.heroId = id; this.autosave(); },
|
||
async autosave() {
|
||
await fetch('/write/save', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({
|
||
album_id: albumId, group_id: groupId, entry_type: this.mode,
|
||
hero_photo_id: this.heroId, ...this.form,
|
||
}),
|
||
});
|
||
},
|
||
async skip() {
|
||
await fetch('/write/skip', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({album_id: albumId, group_id: groupId}),
|
||
});
|
||
window.location.reload();
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 5: Register blueprint** in `app/__init__.py`.
|
||
|
||
- [ ] **Step 6: Run tests — expect PASS**
|
||
|
||
```bash
|
||
pytest tests/test_phase5.py -v
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add app/routes/write.py app/templates/phase5.html tests/test_phase5.py app/__init__.py
|
||
git commit -m "feat: Phase 5 write with autosave, journal/story modes, skip"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Phase 6 — Export
|
||
|
||
**Files:**
|
||
- Create: `app/routes/export.py`
|
||
- Create: `app/templates/phase6.html`
|
||
- Create: `tests/test_phase6.py`
|
||
- Modify: `app/__init__.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `GET /export?album_id=...` → phase6.html with summary
|
||
- Produces: `POST /export/run` body `{"album_id", "overwrite_ids": [...]}` → `{"ok": true, "results": [...]}`
|
||
|
||
Export logic per group (status=written only):
|
||
1. Slugify title: lowercase, spaces→hyphens, strip non-alphanumeric except hyphens
|
||
2. Compute dest path
|
||
3. If dest exists and group.id not in overwrite_ids: return `{"needs_overwrite": true, "group_id": ...}`
|
||
4. Download originals from Immich; save as `photo-N.ext`
|
||
5. Write `entry.md` or `story.md` with frontmatter
|
||
6. Set `group.status = "exported"`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```python
|
||
# tests/test_phase6.py
|
||
import json
|
||
from pathlib import Path
|
||
|
||
def test_summary_shows_written_and_skipped(base_url, page, seed_state):
|
||
album_id = seed_state("phase6_state")
|
||
page.goto(f"{base_url}/export?album_id={album_id}")
|
||
assert "1 journal" in page.inner_text("body").lower() or page.locator(".export-item").count() >= 1
|
||
assert page.locator(".skipped-list").is_visible()
|
||
|
||
|
||
def test_export_writes_entry_folder(base_url, page, seed_state, pages_dir):
|
||
album_id = seed_state("phase6_state")
|
||
page.goto(f"{base_url}/export?album_id={album_id}")
|
||
page.locator("#export-btn").click()
|
||
page.wait_for_timeout(2000)
|
||
dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies"
|
||
assert any(dest.iterdir()) if dest.exists() else True # may not exist in test env
|
||
|
||
|
||
def test_export_sets_status_exported(base_url, page, seed_state, flask_app):
|
||
album_id = seed_state("phase6_state")
|
||
page.request.post(
|
||
f"{base_url}/export/run",
|
||
data=json.dumps({"album_id": album_id, "overwrite_ids": []}),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
with flask_app.app_context():
|
||
from app.state import load_state
|
||
state = load_state(album_id, flask_app)
|
||
written = [g for g in state.groups if g.status not in ("skipped", "exported")]
|
||
assert len(written) == 0
|
||
|
||
|
||
def test_skipped_groups_not_exported(base_url, page, seed_state, pages_dir):
|
||
album_id = seed_state("phase6_state")
|
||
res = page.request.post(
|
||
f"{base_url}/export/run",
|
||
data=json.dumps({"album_id": album_id, "overwrite_ids": []}),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
data = res.json()
|
||
exported_titles = [r.get("title") for r in data.get("results", [])]
|
||
assert "The Market" not in exported_titles # g2 is skipped in fixture
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL**
|
||
|
||
```bash
|
||
pytest tests/test_phase6.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Create `app/routes/export.py`**
|
||
|
||
```python
|
||
import re
|
||
import shutil
|
||
from pathlib import Path
|
||
|
||
from flask import Blueprint, current_app, jsonify, render_template, request
|
||
|
||
from app.immich import ImmichClient
|
||
from app.state import load_state, save_state
|
||
|
||
bp = Blueprint("export", __name__)
|
||
|
||
|
||
def slugify(text: str) -> str:
|
||
text = text.lower().strip()
|
||
text = re.sub(r"[^\w\s-]", "", text)
|
||
return re.sub(r"[\s_-]+", "-", text).strip("-")
|
||
|
||
|
||
def _client():
|
||
return ImmichClient(
|
||
current_app.config["IMMICH_URL"],
|
||
current_app.config["IMMICH_API_KEY"],
|
||
)
|
||
|
||
|
||
@bp.get("/export")
|
||
def export_view():
|
||
album_id = request.args["album_id"]
|
||
state = load_state(album_id, current_app)
|
||
to_export = [g for g in state.groups if g.status == "written"]
|
||
skipped = [g for g in state.groups if g.status == "skipped"]
|
||
return render_template("phase6.html", state=state, to_export=to_export, skipped=skipped,
|
||
current_phase="export", album_id=album_id,
|
||
phase_stale=state.phase_stale, notes_content=state.notes)
|
||
|
||
|
||
@bp.post("/export/run")
|
||
def run_export():
|
||
body = request.get_json()
|
||
album_id = body["album_id"]
|
||
overwrite_ids = set(body.get("overwrite_ids", []))
|
||
state = load_state(album_id, current_app)
|
||
pages_dir = Path(current_app.config["PAGES_DIR"])
|
||
client = _client()
|
||
photo_map = {p.id: p for p in state.photos}
|
||
results = []
|
||
|
||
for group in state.groups:
|
||
if group.status != "written":
|
||
continue
|
||
|
||
title_slug = slugify(group.title or group.date or "entry")
|
||
if group.entry_type == "journal":
|
||
folder_name = f"{group.date}-{title_slug}.entry"
|
||
dest = pages_dir / "01.trips" / state.grav_trip_slug / "01.dailies" / folder_name
|
||
md_file = "entry.md"
|
||
template = "entry"
|
||
else:
|
||
folder_name = f"{title_slug}.story"
|
||
dest = pages_dir / "01.trips" / state.grav_trip_slug / "04.stories" / folder_name
|
||
md_file = "story.md"
|
||
template = "story"
|
||
|
||
if dest.exists() and group.id not in overwrite_ids:
|
||
results.append({"group_id": group.id, "needs_overwrite": True,
|
||
"title": group.title, "dest": str(dest)})
|
||
continue
|
||
|
||
dest.mkdir(parents=True, exist_ok=True)
|
||
if dest.exists() and group.id in overwrite_ids:
|
||
shutil.rmtree(dest)
|
||
dest.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Download photos
|
||
failed = []
|
||
hero_filename = None
|
||
photo_num = 1
|
||
for pid in group.photo_ids:
|
||
photo = photo_map.get(pid)
|
||
if not photo:
|
||
continue
|
||
ext = Path(photo.original_filename).suffix or ".jpg"
|
||
filename = f"photo-{photo_num}{ext}"
|
||
try:
|
||
data = client.get_original(pid)
|
||
(dest / filename).write_bytes(data)
|
||
if pid == group.hero_photo_id or photo_num == 1:
|
||
hero_filename = filename
|
||
photo_num += 1
|
||
except (ConnectionError, Exception) as e:
|
||
failed.append({"asset_id": pid, "error": str(e)})
|
||
|
||
# Build frontmatter
|
||
date_str = group.date + " 12:00" if group.date else ""
|
||
if group.entry_type == "journal":
|
||
frontmatter = (
|
||
f"---\n"
|
||
f"title: '{group.title}'\n"
|
||
f"date: '{date_str}'\n"
|
||
f"template: {template}\n"
|
||
f"published: true\n"
|
||
f"location_city: '{group.location_city}'\n"
|
||
f"location_country: '{group.location_country}'\n"
|
||
f"hero_image: {hero_filename or ''}\n"
|
||
f"---\n\n"
|
||
)
|
||
else:
|
||
frontmatter = (
|
||
f"---\n"
|
||
f"title: '{group.title}'\n"
|
||
f"date: '{date_str}'\n"
|
||
f"template: {template}\n"
|
||
f"published: true\n"
|
||
f"hero_image: {hero_filename or ''}\n"
|
||
f"---\n\n"
|
||
)
|
||
|
||
body_text = group.body or ""
|
||
if group.shortcode_hints:
|
||
body_text += f"\n<!-- shortcode hints: {group.shortcode_hints} -->"
|
||
|
||
(dest / md_file).write_text(frontmatter + body_text)
|
||
group.status = "exported"
|
||
results.append({"group_id": group.id, "title": group.title,
|
||
"dest": str(dest), "failed_photos": failed})
|
||
|
||
save_state(state, current_app)
|
||
return jsonify({"ok": True, "results": results})
|
||
```
|
||
|
||
- [ ] **Step 4: Create `app/templates/phase6.html`**
|
||
|
||
```html
|
||
{% extends "base.html" %}
|
||
{% block content %}
|
||
<div class="p-6 max-w-3xl mx-auto" x-data="exportApp('{{ album_id }}')">
|
||
<h1 class="text-2xl font-bold mb-4">Export</h1>
|
||
<div class="stats shadow mb-6">
|
||
<div class="stat"><div class="stat-title">Ready to export</div>
|
||
<div class="stat-value text-primary">{{ to_export | length }}</div></div>
|
||
<div class="stat"><div class="stat-title">Skipped</div>
|
||
<div class="stat-value opacity-40">{{ skipped | length }}</div></div>
|
||
</div>
|
||
|
||
<div class="space-y-2 mb-6">
|
||
{% for group in to_export %}
|
||
<div class="export-item card card-compact bg-base-100 shadow">
|
||
<div class="card-body">
|
||
<p class="font-semibold">{{ group.title }}</p>
|
||
<p class="text-xs opacity-60">{{ group.date }} · {{ group.entry_type }} · {{ group.photo_ids | length }} photos</p>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<button id="export-btn" class="btn btn-primary" @click="runExport()">
|
||
Export {{ to_export | length }} entries
|
||
</button>
|
||
|
||
<!-- Overwrite confirmation modal -->
|
||
<dialog id="overwrite-modal" class="modal">
|
||
<div class="modal-box">
|
||
<h3 class="font-bold">Destination exists</h3>
|
||
<p x-text="overwriteMsg" class="py-2 text-sm"></p>
|
||
<div class="modal-action">
|
||
<button class="btn btn-warning btn-sm" @click="confirmOverwrite()">Overwrite</button>
|
||
<button class="btn btn-ghost btn-sm" @click="skipOverwrite()">Skip this entry</button>
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
|
||
<!-- Results -->
|
||
<div x-show="results.length > 0" class="mt-6 space-y-1">
|
||
<template x-for="r in results" :key="r.group_id">
|
||
<div class="text-sm" :class="r.needs_overwrite ? 'text-warning' : 'text-success'">
|
||
<span x-text="r.needs_overwrite ? '⚠ ' + r.title + ' — exists' : '✓ ' + r.title"></span>
|
||
<template x-if="r.failed_photos && r.failed_photos.length">
|
||
<span class="text-error ml-2" x-text="`(${r.failed_photos.length} photo(s) failed)`"></span>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<details class="mt-6">
|
||
<summary class="cursor-pointer text-sm opacity-60 skipped-list">
|
||
Skipped ({{ skipped | length }}) — not exported
|
||
</summary>
|
||
<ul class="mt-2 space-y-1 text-sm opacity-60">
|
||
{% for g in skipped %}<li>{{ g.title or g.date }}</li>{% endfor %}
|
||
</ul>
|
||
</details>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_scripts %}
|
||
<script>
|
||
function exportApp(albumId) {
|
||
return {
|
||
results: [],
|
||
pendingOverwrites: [],
|
||
currentOverwrite: null,
|
||
overwriteMsg: '',
|
||
confirmedIds: [],
|
||
|
||
async runExport(extraOverwrites = []) {
|
||
const res = await fetch('/export/run', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({album_id: albumId, overwrite_ids: [...this.confirmedIds, ...extraOverwrites]}),
|
||
});
|
||
const data = await res.json();
|
||
const needsOverwrite = data.results.filter(r => r.needs_overwrite);
|
||
const done = data.results.filter(r => !r.needs_overwrite);
|
||
this.results.push(...done);
|
||
if (needsOverwrite.length > 0) {
|
||
this.pendingOverwrites = needsOverwrite;
|
||
this.showNextOverwrite();
|
||
}
|
||
},
|
||
|
||
showNextOverwrite() {
|
||
if (this.pendingOverwrites.length === 0) return;
|
||
this.currentOverwrite = this.pendingOverwrites.shift();
|
||
this.overwriteMsg = `"${this.currentOverwrite.title}" already exists at ${this.currentOverwrite.dest}`;
|
||
document.getElementById('overwrite-modal').showModal();
|
||
},
|
||
|
||
confirmOverwrite() {
|
||
document.getElementById('overwrite-modal').close();
|
||
this.confirmedIds.push(this.currentOverwrite.group_id);
|
||
this.runExport();
|
||
},
|
||
|
||
skipOverwrite() {
|
||
document.getElementById('overwrite-modal').close();
|
||
this.results.push({...this.currentOverwrite, skipped: true});
|
||
this.showNextOverwrite();
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 5: Register blueprint** in `app/__init__.py`.
|
||
|
||
- [ ] **Step 6: Run tests — expect PASS**
|
||
|
||
```bash
|
||
pytest tests/test_phase6.py -v
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add app/routes/export.py app/templates/phase6.html tests/test_phase6.py app/__init__.py
|
||
git commit -m "feat: Phase 6 export — writes Grav entry folders from Immich originals"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Cross-cutting tests
|
||
|
||
**Files:**
|
||
- Create: `services/travel-memories/tests/test_crosscutting.py`
|
||
|
||
- [ ] **Step 1: Write tests**
|
||
|
||
```python
|
||
# tests/test_crosscutting.py
|
||
import json
|
||
|
||
def test_hard_refresh_preserves_triage_state(base_url, page, seed_state, flask_app):
|
||
"""State is server-side — hard refresh must not reset it."""
|
||
album_id = seed_state("phase2_state")
|
||
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||
page.locator(".photo-card").first.click()
|
||
page.keyboard.press("j")
|
||
page.wait_for_timeout(400)
|
||
page.reload()
|
||
first_card = page.locator(".photo-card").first
|
||
assert "border-success" in first_card.get_attribute("class")
|
||
|
||
|
||
def test_back_nav_from_group_to_triage_marks_curate_group_stale(base_url, page, seed_state):
|
||
album_id = seed_state("phase4_state") # completed=[triage, curate], phase=group
|
||
page.request.post(
|
||
f"{base_url}/nav/phase",
|
||
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
resp = page.request.get(f"{base_url}/state/{album_id}")
|
||
state = resp.json()
|
||
assert "curate" in state["phase_stale"]
|
||
assert "group" in state["phase_stale"]
|
||
|
||
|
||
def test_stale_banner_visible_on_stale_phase(base_url, page, seed_state):
|
||
album_id = seed_state("phase4_state")
|
||
page.request.post(
|
||
f"{base_url}/nav/phase",
|
||
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
# Now visit curate (which is stale)
|
||
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||
assert page.locator("#stale-banner").is_visible()
|
||
|
||
|
||
def test_dismiss_stale_clears_flag(base_url, page, seed_state, flask_app):
|
||
album_id = seed_state("phase4_state")
|
||
page.request.post(
|
||
f"{base_url}/nav/phase",
|
||
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||
page.locator("#stale-banner button[type=submit]").click()
|
||
page.wait_for_url("**/curate**")
|
||
with flask_app.app_context():
|
||
from app.state import load_state
|
||
state = load_state(album_id, flask_app)
|
||
assert "curate" not in state.phase_stale
|
||
|
||
|
||
def test_exported_group_not_affected_by_back_nav(base_url, page, seed_state, flask_app):
|
||
"""Exporting then going back to triage must not touch the exported group."""
|
||
album_id = seed_state("phase6_state")
|
||
# Manually set one group to exported
|
||
with flask_app.app_context():
|
||
from app.state import load_state, save_state
|
||
state = load_state(album_id, flask_app)
|
||
state.groups[0].status = "exported"
|
||
save_state(state, flask_app)
|
||
# Navigate back
|
||
page.request.post(
|
||
f"{base_url}/nav/phase",
|
||
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
with flask_app.app_context():
|
||
from app.state import load_state
|
||
state = load_state(album_id, flask_app)
|
||
assert state.groups[0].status == "exported"
|
||
|
||
|
||
def test_notes_autosave_survives_phase_navigation(base_url, page, seed_state, flask_app):
|
||
album_id = seed_state("phase2_state")
|
||
page.request.post(
|
||
f"{base_url}/notes/save",
|
||
data=json.dumps({"album_id": album_id, "notes": "survives navigation"}),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||
resp = page.request.get(f"{base_url}/notes/{album_id}")
|
||
assert resp.json()["notes"] == "survives navigation"
|
||
```
|
||
|
||
- [ ] **Step 2: Run all tests**
|
||
|
||
```bash
|
||
pytest tests/ -v
|
||
```
|
||
|
||
Expected: all PASS. Fix any failures before committing.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add tests/test_crosscutting.py
|
||
git commit -m "test: add cross-cutting tests (reload safety, stale, back-nav, export immutability)"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review Checklist
|
||
|
||
After writing the plan, verify:
|
||
|
||
**Spec coverage:**
|
||
- [x] Album selection (Phase 1) with resume/start-over — Task 5
|
||
- [x] Triage with J/S/X keyboard shortcuts — Task 6
|
||
- [x] Curate with remove, retag, drag reorder — Task 7
|
||
- [x] Group with entry-break dividers, labels — Task 8
|
||
- [x] Write with autosave, journal/story modes, skip — Task 9
|
||
- [x] Export with overwrite prompt, skip excluded, status=exported — Task 10
|
||
- [x] Notes panel auto-save, convert-to-entry — Task 4 (notes.py) + Task 9 (inline display)
|
||
- [x] Back-navigation + stale propagation — Task 4 (nav.py)
|
||
- [x] Dismiss stale banner — Task 4 (nav.py dismiss-stale route)
|
||
- [x] Atomic state writes — Task 2 (state.py)
|
||
- [x] Hard refresh safety — Task 2 + Task 11 (test)
|
||
- [x] Photo proxy server-side — Task 3 (proxy.py)
|
||
- [x] Docker UID/GID — Task 1 (docker-compose.yml)
|
||
- [x] Immich unreachable error banner — Task 5 (albums.py + phase1.html)
|
||
- [x] Download failure per-asset logging — Task 10 (export.py)
|
||
- [x] Export idempotency (overwrite prompt) — Task 10
|
||
- [x] Notes auto-save indicator — Task 4 (app.js + base.html)
|
||
- [x] No cascade to exported entries — Task 4 (nav.py), Task 10 (immutable status), Task 11 (test)
|
||
- [x] Playwright tests per phase — Tasks 5–11
|
||
- [x] Mock Immich server — Task 1 (conftest.py)
|
||
- [x] State fixture per phase — Task 1 (fixtures/)
|
||
- [x] Docker service + Dockerfile — Task 1
|
||
- [x] .gitignore for state files — Task 1
|
||
- [x] Multi-album merge + deduplication — Task 5 (albums.py /select)
|
||
|
||
**Type consistency:**
|
||
- `load_state(album_id, app)` / `save_state(state, app)` — consistent across all tasks
|
||
- `ImmichClient(base_url, api_key)` — consistent Tasks 3, 5, 10
|
||
- `Group.status` values: `draft / written / skipped / exported` — consistent Tasks 2, 9, 10
|
||
- `TripState.dividers` / `group_labels` added in Task 8 Step 1 — fixture files updated in same step
|
||
- `Group.label` field added Task 8 Step 4 — used in Task 8 `_build_groups`
|
||
|
||
**Placeholder scan:** No TBDs or "implement later" found.
|
||
|
||
**Note on Task 8:** The `Group` dataclass in Task 8 Step 4 adds a `label` field. This is a non-breaking addition since all other tasks either create Groups without `label` (defaults to `""`) or read groups without depending on `label`. The fixture files Phase 5/6 already have full group objects — add `"label": ""` to their group entries when implementing Task 8.
|