Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
91 KiB
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}.tmpthenos.rename()— never write directly status: exportedon a group is immutable; no code path may change it- DaisyUI theme:
forestthroughout - No AI-generated text in any output
user/pages/written directly by export; caller runsmake content-pushafterwards- 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
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
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-memoriesservice todocker-compose.yml
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
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)
{
"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)
{
"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)
{
"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)
{
"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)
{
"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:
def test_health(base_url, page):
page.goto(f"{base_url}/health")
assert "ok" in page.content()
- Step 14: Run smoke test
cd services/travel-memories
pip install -r requirements.txt
playwright install chromium
pytest tests/test_smoke.py -v
Expected: PASS
- Step 15: Commit
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,Groupdataclasses with fields matching spec JSON -
Step 1: Write failing tests
# 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)
pytest tests/test_state.py -v
- Step 3: Create
app/state.py
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
pytest tests/test_state.py -v
- Step 5: Commit
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) -> dictget_thumbnail(asset_id) -> bytesget_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
# 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
pytest tests/test_immich.py -v
- Step 3: Create
app/immich.py
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
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
pytest tests/test_immich.py -v
- Step 7: Commit
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/savebody{"album_id": str, "notes": str}→{"ok": true} -
Produces:
POST /nav/phasebody{"album_id": str, "target_phase": str}→ redirect -
Produces:
base.htmlJinja2 template with blocks:content,extra_scripts; context vars:current_phase,album_id,notes_content -
Step 1: Write failing tests
# 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
pytest tests/test_notes.py -v
- Step 3: Create
app/routes/notes.py
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
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
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
<!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
@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
pytest tests/test_notes.py -v
- Step 10: Commit
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 /selectbodyalbum_ids[]=..., grav_trip_slug=...→ creates state, redirects to/triage?album_id=... -
Step 1: Write failing tests
# 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
pytest tests/test_phase1.py -v
- Step 3: Create
app/routes/albums.py
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
{% 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
pytest tests/test_phase1.py -v
- Step 7: Commit
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/tagbody{"album_id", "asset_id", "tag"}→{"ok": true} -
Produces:
POST /triage/donebody{"album_id"}→ redirect/curate -
Step 1: Write failing tests
# 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
pytest tests/test_phase2.py -v
- Step 3: Create
app/routes/triage.py
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
{% 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
triageblueprint inapp/__init__.py:
from .routes import albums, triage, proxy, notes, nav
app.register_blueprint(triage.bp)
- Step 6: Run tests — expect PASS
pytest tests/test_phase2.py -v
- Step 7: Commit
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/removebody{"album_id", "asset_id"}→{"ok": true} -
Produces:
POST /curate/retagbody{"album_id", "asset_id", "tag"}→{"ok": true} -
Produces:
POST /curate/reorderbody{"album_id", "ordered_ids": [...]}→{"ok": true} -
Produces:
POST /curate/donebody{"album_id"}→ redirect/group -
Step 1: Write failing tests
# 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
pytest tests/test_phase3.py -v
- Step 3: Create
app/routes/curate.py
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
{% 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
pytest tests/test_phase3.py -v
- Step 7: Commit
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/dividerbody{"album_id", "after_asset_id"}→{"ok": true} - Produces:
POST /group/remove-dividerbody{"album_id", "divider_id"}→{"ok": true} - Produces:
POST /group/labelbody{"album_id", "group_id", "label"}→{"ok": true} - Produces:
POST /group/donebody{"album_id"}→ redirect/write - Produces:
POST /group/from-notebody{"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
dividersandgroup_labelstoTripStateinapp/state.py:
@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
# 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
pytest tests/test_phase4.py -v
- Step 4: Create
app/routes/group.py
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:
@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):
{% 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
pytest tests/test_phase4.py -v
- Step 8: Commit
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/savebody{"album_id", "group_id", "title", "body", "location_city", "location_country", "date", "hero_photo_id", "shortcode_hints"}→{"ok": true} -
Produces:
POST /write/skipbody{"album_id", "group_id"}→{"ok": true} -
Step 1: Write failing tests
# 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
pytest tests/test_phase5.py -v
- Step 3: Create
app/routes/write.py
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
{% 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
pytest tests/test_phase5.py -v
- Step 7: Commit
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/runbody{"album_id", "overwrite_ids": [...]}→{"ok": true, "results": [...]}
Export logic per group (status=written only):
- Slugify title: lowercase, spaces→hyphens, strip non-alphanumeric except hyphens
- Compute dest path
- If dest exists and group.id not in overwrite_ids: return
{"needs_overwrite": true, "group_id": ...} - Download originals from Immich; save as
photo-N.ext - Write
entry.mdorstory.mdwith frontmatter - Set
group.status = "exported"
- Step 1: Write failing tests
# 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
pytest tests/test_phase6.py -v
- Step 3: Create
app/routes/export.py
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
{% 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
pytest tests/test_phase6.py -v
- Step 7: Commit
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
# 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
pytest tests/ -v
Expected: all PASS. Fix any failures before committing.
- Step 3: Commit
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:
- Album selection (Phase 1) with resume/start-over — Task 5
- Triage with J/S/X keyboard shortcuts — Task 6
- Curate with remove, retag, drag reorder — Task 7
- Group with entry-break dividers, labels — Task 8
- Write with autosave, journal/story modes, skip — Task 9
- Export with overwrite prompt, skip excluded, status=exported — Task 10
- Notes panel auto-save, convert-to-entry — Task 4 (notes.py) + Task 9 (inline display)
- Back-navigation + stale propagation — Task 4 (nav.py)
- Dismiss stale banner — Task 4 (nav.py dismiss-stale route)
- Atomic state writes — Task 2 (state.py)
- Hard refresh safety — Task 2 + Task 11 (test)
- Photo proxy server-side — Task 3 (proxy.py)
- Docker UID/GID — Task 1 (docker-compose.yml)
- Immich unreachable error banner — Task 5 (albums.py + phase1.html)
- Download failure per-asset logging — Task 10 (export.py)
- Export idempotency (overwrite prompt) — Task 10
- Notes auto-save indicator — Task 4 (app.js + base.html)
- No cascade to exported entries — Task 4 (nav.py), Task 10 (immutable status), Task 11 (test)
- Playwright tests per phase — Tasks 5–11
- Mock Immich server — Task 1 (conftest.py)
- State fixture per phase — Task 1 (fixtures/)
- Docker service + Dockerfile — Task 1
- .gitignore for state files — Task 1
- Multi-album merge + deduplication — Task 5 (albums.py /select)
Type consistency:
load_state(album_id, app)/save_state(state, app)— consistent across all tasksImmichClient(base_url, api_key)— consistent Tasks 3, 5, 10Group.statusvalues:draft / written / skipped / exported— consistent Tasks 2, 9, 10TripState.dividers/group_labelsadded in Task 8 Step 1 — fixture files updated in same stepGroup.labelfield 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.