From a80b0a90fcb6d66e31f2162b71b6deef574f317a Mon Sep 17 00:00:00 2001 From: Mischa Date: Fri, 26 Jun 2026 18:23:54 +0200 Subject: [PATCH] chore: remove travel-memories service from repo (moved to separate project) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr --- services/travel-memories/.gitignore | 4 - services/travel-memories/Dockerfile | 10 - services/travel-memories/app/__init__.py | 26 - services/travel-memories/app/immich.py | 30 - .../travel-memories/app/routes/__init__.py | 0 services/travel-memories/app/routes/albums.py | 79 --- services/travel-memories/app/routes/curate.py | 71 -- services/travel-memories/app/routes/export.py | 229 ------- services/travel-memories/app/routes/group.py | 116 ---- services/travel-memories/app/routes/nav.py | 51 -- services/travel-memories/app/routes/notes.py | 23 - services/travel-memories/app/routes/proxy.py | 29 - services/travel-memories/app/routes/triage.py | 51 -- services/travel-memories/app/routes/write.py | 103 --- services/travel-memories/app/state.py | 71 -- services/travel-memories/app/static/app.js | 34 - .../travel-memories/app/templates/base.html | 68 -- .../travel-memories/app/templates/phase1.html | 55 -- .../travel-memories/app/templates/phase2.html | 628 ------------------ .../travel-memories/app/templates/phase3.html | 91 --- .../travel-memories/app/templates/phase4.html | 99 --- .../travel-memories/app/templates/phase5.html | 202 ------ .../travel-memories/app/templates/phase6.html | 104 --- services/travel-memories/pytest.ini | 2 - services/travel-memories/requirements.txt | 5 - services/travel-memories/tests/conftest.py | 101 --- .../tests/fixtures/phase2_state.json | 20 - .../tests/fixtures/phase3_state.json | 20 - .../tests/fixtures/phase4_state.json | 18 - .../tests/fixtures/phase5_state.json | 31 - .../tests/fixtures/phase6_state.json | 33 - .../tests/test_crosscutting.py | 87 --- services/travel-memories/tests/test_immich.py | 50 -- services/travel-memories/tests/test_notes.py | 40 -- services/travel-memories/tests/test_phase1.py | 30 - services/travel-memories/tests/test_phase2.py | 40 -- services/travel-memories/tests/test_phase3.py | 40 -- services/travel-memories/tests/test_phase4.py | 47 -- services/travel-memories/tests/test_phase5.py | 44 -- services/travel-memories/tests/test_phase6.py | 74 --- services/travel-memories/tests/test_smoke.py | 3 - services/travel-memories/tests/test_state.py | 52 -- 42 files changed, 2911 deletions(-) delete mode 100644 services/travel-memories/.gitignore delete mode 100644 services/travel-memories/Dockerfile delete mode 100644 services/travel-memories/app/__init__.py delete mode 100644 services/travel-memories/app/immich.py delete mode 100644 services/travel-memories/app/routes/__init__.py delete mode 100644 services/travel-memories/app/routes/albums.py delete mode 100644 services/travel-memories/app/routes/curate.py delete mode 100644 services/travel-memories/app/routes/export.py delete mode 100644 services/travel-memories/app/routes/group.py delete mode 100644 services/travel-memories/app/routes/nav.py delete mode 100644 services/travel-memories/app/routes/notes.py delete mode 100644 services/travel-memories/app/routes/proxy.py delete mode 100644 services/travel-memories/app/routes/triage.py delete mode 100644 services/travel-memories/app/routes/write.py delete mode 100644 services/travel-memories/app/state.py delete mode 100644 services/travel-memories/app/static/app.js delete mode 100644 services/travel-memories/app/templates/base.html delete mode 100644 services/travel-memories/app/templates/phase1.html delete mode 100644 services/travel-memories/app/templates/phase2.html delete mode 100644 services/travel-memories/app/templates/phase3.html delete mode 100644 services/travel-memories/app/templates/phase4.html delete mode 100644 services/travel-memories/app/templates/phase5.html delete mode 100644 services/travel-memories/app/templates/phase6.html delete mode 100644 services/travel-memories/pytest.ini delete mode 100644 services/travel-memories/requirements.txt delete mode 100644 services/travel-memories/tests/conftest.py delete mode 100644 services/travel-memories/tests/fixtures/phase2_state.json delete mode 100644 services/travel-memories/tests/fixtures/phase3_state.json delete mode 100644 services/travel-memories/tests/fixtures/phase4_state.json delete mode 100644 services/travel-memories/tests/fixtures/phase5_state.json delete mode 100644 services/travel-memories/tests/fixtures/phase6_state.json delete mode 100644 services/travel-memories/tests/test_crosscutting.py delete mode 100644 services/travel-memories/tests/test_immich.py delete mode 100644 services/travel-memories/tests/test_notes.py delete mode 100644 services/travel-memories/tests/test_phase1.py delete mode 100644 services/travel-memories/tests/test_phase2.py delete mode 100644 services/travel-memories/tests/test_phase3.py delete mode 100644 services/travel-memories/tests/test_phase4.py delete mode 100644 services/travel-memories/tests/test_phase5.py delete mode 100644 services/travel-memories/tests/test_phase6.py delete mode 100644 services/travel-memories/tests/test_smoke.py delete mode 100644 services/travel-memories/tests/test_state.py diff --git a/services/travel-memories/.gitignore b/services/travel-memories/.gitignore deleted file mode 100644 index c7f8f75..0000000 --- a/services/travel-memories/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -__pycache__/ -*.py[cod] -.venv/ -.pytest_cache/ diff --git a/services/travel-memories/Dockerfile b/services/travel-memories/Dockerfile deleted file mode 100644 index be9065e..0000000 --- a/services/travel-memories/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -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"] diff --git a/services/travel-memories/app/__init__.py b/services/travel-memories/app/__init__.py deleted file mode 100644 index 5d5a1e8..0000000 --- a/services/travel-memories/app/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -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, triage, proxy, notes, nav, curate, group, write, export - app.register_blueprint(albums.bp) - app.register_blueprint(triage.bp) - app.register_blueprint(proxy.bp) - app.register_blueprint(notes.bp) - app.register_blueprint(nav.bp) - app.register_blueprint(curate.bp) - app.register_blueprint(group.bp) - app.register_blueprint(write.bp) - app.register_blueprint(export.bp) - - @app.get("/health") - def health(): - return {"ok": True} - - return app diff --git a/services/travel-memories/app/immich.py b/services/travel-memories/app/immich.py deleted file mode 100644 index 5e34a0e..0000000 --- a/services/travel-memories/app/immich.py +++ /dev/null @@ -1,30 +0,0 @@ -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 diff --git a/services/travel-memories/app/routes/__init__.py b/services/travel-memories/app/routes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/travel-memories/app/routes/albums.py b/services/travel-memories/app/routes/albums.py deleted file mode 100644 index 96441e2..0000000 --- a/services/travel-memories/app/routes/albums.py +++ /dev/null @@ -1,79 +0,0 @@ -import re -from pathlib import Path - -from flask import Blueprint, current_app, redirect, render_template, request -from app.immich import ImmichClient -from app.state import TripState, Photo, load_state, save_state - -bp = Blueprint("albums", __name__) - - -def _sanitise_slug(s: str) -> str: - s = s.strip().lower() - s = re.sub(r'[^a-z0-9-]+', '-', s) - return s.strip('-') - - -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) - 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 = _sanitise_slug(request.form["grav_trip_slug"]) - 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, deduplicating by asset ID - 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}") - diff --git a/services/travel-memories/app/routes/curate.py b/services/travel-memories/app/routes/curate.py deleted file mode 100644 index 1e96d23..0000000 --- a/services/travel-memories/app/routes/curate.py +++ /dev/null @@ -1,71 +0,0 @@ -from flask import Blueprint, current_app, jsonify, 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) - photo = next((p for p in state.photos if p.id == body["asset_id"]), None) - if photo is None: - return jsonify({"ok": False, "error": "photo not found"}), 404 - photo.tag = "skip" - save_state(state, current_app) - return jsonify({"ok": True}) - - -@bp.post("/curate/swap") -def swap(): - body = request.get_json() - state = load_state(body["album_id"], current_app) - photo = next((p for p in state.photos if p.id == body["asset_id"]), None) - if photo is None: - return jsonify({"ok": False, "error": "photo not found"}), 404 - photo.tag = "story" if photo.tag == "journal" else "journal" - save_state(state, current_app) - return jsonify({"ok": True, "new_tag": photo.tag}) - - -@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["order"])} - 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']}"}) diff --git a/services/travel-memories/app/routes/export.py b/services/travel-memories/app/routes/export.py deleted file mode 100644 index 0cec923..0000000 --- a/services/travel-memories/app/routes/export.py +++ /dev/null @@ -1,229 +0,0 @@ -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 _yaml_str(s: str) -> str: - return s.replace("'", "''") - - -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"] - 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} - exported = 0 - all_failed = [] - - 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(): - save_state(state, current_app) - return jsonify({"conflict": True, "path": str(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 - filename = f"photo-{photo_num}.jpg" - 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 Exception as e: - current_app.logger.warning("Failed to download asset %s: %s", pid, e) - failed.append(pid) - - # Build frontmatter - date_str = (group.date + " 12:00") if group.date else "" - if group.entry_type == "journal": - frontmatter = ( - f"---\n" - f"title: '{_yaml_str(group.title)}'\n" - f"date: '{date_str}'\n" - f"template: {template}\n" - f"published: true\n" - f"location_city: '{_yaml_str(group.location_city)}'\n" - f"location_country: '{_yaml_str(group.location_country)}'\n" - f"hero_image: {hero_filename or ''}\n" - f"---\n" - ) - else: - frontmatter = ( - f"---\n" - f"title: '{_yaml_str(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" - ) - - body_text = group.body or "" - if group.shortcode_hints: - body_text += f"\n" - - (dest / md_file).write_text(frontmatter + "\n" + body_text) - group.status = "exported" - exported += 1 - all_failed.extend(failed) - - save_state(state, current_app) - return jsonify({"ok": True, "exported": exported, "failed": all_failed}) - - -@bp.post("/export/overwrite") -def overwrite_export(): - body = request.get_json() - album_id = body["album_id"] - conflict_path = Path(body["path"]) - 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} - - # Remove the conflicting folder so the run loop can proceed past it - if conflict_path.exists(): - shutil.rmtree(conflict_path) - - exported = 0 - all_failed = [] - - 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(): - save_state(state, current_app) - return jsonify({"conflict": True, "path": str(dest)}) - - dest.mkdir(parents=True, exist_ok=True) - - failed = [] - hero_filename = None - photo_num = 1 - for pid in group.photo_ids: - photo = photo_map.get(pid) - if not photo: - continue - filename = f"photo-{photo_num}.jpg" - 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 Exception as e: - current_app.logger.warning("Failed to download asset %s: %s", pid, e) - failed.append(pid) - - date_str = (group.date + " 12:00") if group.date else "" - if group.entry_type == "journal": - frontmatter = ( - f"---\n" - f"title: '{_yaml_str(group.title)}'\n" - f"date: '{date_str}'\n" - f"template: {template}\n" - f"published: true\n" - f"location_city: '{_yaml_str(group.location_city)}'\n" - f"location_country: '{_yaml_str(group.location_country)}'\n" - f"hero_image: {hero_filename or ''}\n" - f"---\n" - ) - else: - frontmatter = ( - f"---\n" - f"title: '{_yaml_str(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" - ) - - body_text = group.body or "" - if group.shortcode_hints: - body_text += f"\n" - - (dest / md_file).write_text(frontmatter + "\n" + body_text) - group.status = "exported" - exported += 1 - all_failed.extend(failed) - - save_state(state, current_app) - return jsonify({"ok": True, "exported": exported, "failed": all_failed}) diff --git a/services/travel-memories/app/routes/group.py b/services/travel-memories/app/routes/group.py deleted file mode 100644 index c7a5952..0000000 --- a/services/travel-memories/app/routes/group.py +++ /dev/null @@ -1,116 +0,0 @@ -import uuid -from flask import Blueprint, current_app, jsonify, redirect, render_template, request -from app.state import Group, 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) - groups, _ = _build_groups(state) - state.groups = [] - for g in groups: - first_photo = g["photos"][0] - state.groups.append(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) - state.groups.append(Group( - id=str(uuid.uuid4()), - photo_ids=[], - entry_type="journal", - body=body.get("text", ""), - )) - 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}) diff --git a/services/travel-memories/app/routes/nav.py b/services/travel-memories/app/routes/nav.py deleted file mode 100644 index 1df3be5..0000000 --- a/services/travel-memories/app/routes/nav.py +++ /dev/null @@ -1,51 +0,0 @@ -from flask import Blueprint, current_app, jsonify, redirect, 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 and the current phase as stale - downstream = STALE_DOWNSTREAM.get(target, []) - candidates = set(downstream) & (set(state.phases_completed) | {state.phase}) - newly_stale = [p for p in candidates if 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.post("/nav/dismiss-stale") -def dismiss_stale(): - 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}") - - -@bp.get("/state/") -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)) diff --git a/services/travel-memories/app/routes/notes.py b/services/travel-memories/app/routes/notes.py deleted file mode 100644 index 987cc4c..0000000 --- a/services/travel-memories/app/routes/notes.py +++ /dev/null @@ -1,23 +0,0 @@ -from flask import Blueprint, current_app, jsonify, request -from app.state import load_state, save_state - -bp = Blueprint("notes", __name__) - - -@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/") -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}) diff --git a/services/travel-memories/app/routes/proxy.py b/services/travel-memories/app/routes/proxy.py deleted file mode 100644 index 5a7f520..0000000 --- a/services/travel-memories/app/routes/proxy.py +++ /dev/null @@ -1,29 +0,0 @@ -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/") -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/") -def original(asset_id): - try: - data = _client().get_original(asset_id) - except ConnectionError: - abort(502) - return Response(data, content_type="image/jpeg") diff --git a/services/travel-memories/app/routes/triage.py b/services/travel-memories/app/routes/triage.py deleted file mode 100644 index ab96664..0000000 --- a/services/travel-memories/app/routes/triage.py +++ /dev/null @@ -1,51 +0,0 @@ -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) - tagged_count = sum(1 for p in state.photos if p.tag != "untagged") - return jsonify({"ok": True, "tagged_count": tagged_count, "total": len(state.photos)}) - - -@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']}"}) diff --git a/services/travel-memories/app/routes/write.py b/services/travel-memories/app/routes/write.py deleted file mode 100644 index bbba9f6..0000000 --- a/services/travel-memories/app/routes/write.py +++ /dev/null @@ -1,103 +0,0 @@ -from flask import Blueprint, current_app, jsonify, redirect, render_template, request, url_for -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")) - if group is None: - all_done = all(g.status in ("written", "skipped", "exported") for g in active_groups) - if not all_done: - first_incomplete = next(i for i, g in enumerate(active_groups) if g.status == "draft") - return redirect(url_for("write.write", album_id=album_id, group_idx=first_incomplete)) - 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/autosave") -def autosave(): - 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) - if body.get("entry_type"): - g.entry_type = body["entry_type"] - break - save_state(state, current_app) - return jsonify({"ok": True}) - - -@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) - if body.get("entry_type"): - g.entry_type = body["entry_type"] - 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}) - - -@bp.post("/write/done") -def write_done(): - album_id = request.form["album_id"] - state = load_state(album_id, current_app) - if state is None: - return jsonify({"ok": False, "error": "not found"}), 404 - if "write" not in state.phases_completed: - state.phases_completed.append("write") - state.phase = "export" - save_state(state, current_app) - return redirect(f"/export?album_id={album_id}") diff --git a/services/travel-memories/app/state.py b/services/travel-memories/app/state.py deleted file mode 100644 index 09de90e..0000000 --- a/services/travel-memories/app/state.py +++ /dev/null @@ -1,71 +0,0 @@ -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 - label: str = "" - 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 = "" - dividers: list = field(default_factory=list) # [{"id": str, "after_order": int}] - group_labels: dict = field(default_factory=dict) # {divider_id: label} - - -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) diff --git a/services/travel-memories/app/static/app.js b/services/travel-memories/app/static/app.js deleted file mode 100644 index 843d5a1..0000000 --- a/services/travel-memories/app/static/app.js +++ /dev/null @@ -1,34 +0,0 @@ -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 βœ“'; - } - }, - }; -} diff --git a/services/travel-memories/app/templates/base.html b/services/travel-memories/app/templates/base.html deleted file mode 100644 index db6c909..0000000 --- a/services/travel-memories/app/templates/base.html +++ /dev/null @@ -1,68 +0,0 @@ - - - - - travel-memories - - - - - - - - - - - {% if current_phase in phase_stale %} -
- You changed earlier decisions β€” review this phase before exporting. -
- - - -
-
- {% endif %} - - -
-
- {% block content %}{% endblock %} -
- - -
-

Notes

- -
-
-
- - - {% block extra_scripts %}{% endblock %} - - diff --git a/services/travel-memories/app/templates/phase1.html b/services/travel-memories/app/templates/phase1.html deleted file mode 100644 index 41c33fd..0000000 --- a/services/travel-memories/app/templates/phase1.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-

Select Album

- - {% if error %} -
- Cannot reach Immich: {{ error }} - Retry -
- {% endif %} - -
-
- {% for album in albums %} - - {% endfor %} -
- -
- - -
- - -
- - -
-
-
-{% endblock %} diff --git a/services/travel-memories/app/templates/phase2.html b/services/travel-memories/app/templates/phase2.html deleted file mode 100644 index d934398..0000000 --- a/services/travel-memories/app/templates/phase2.html +++ /dev/null @@ -1,628 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
- -
-

Triage

-
- - {{ state.photos | selectattr('tag', 'ne', 'untagged') | list | length }} - / {{ state.photos | length }} tagged - - - -
-
- - {# ── Desktop grid (hidden on mobile) ── #} -
- {% for day, photos in photos_by_day.items() %} -
-

{{ day }}

-
- {% for photo in photos %} -
- -
- {{ photo.local_datetime[11:16] }} -
- {% if photo.tag == 'journal' %} -
J
- {% elif photo.tag == 'story' %} -
S
- {% elif photo.tag == 'skip' %} -
X
- {% endif %} -
- {% endfor %} -
-
- {% endfor %} -
- - {# ── Lightbox overlay (desktop) ── #} - - - {# ── Mobile card UI (hidden on desktop) ── #} - - -
-{% endblock %} - -{% block extra_scripts %} - - -{% endblock %} diff --git a/services/travel-memories/app/templates/phase3.html b/services/travel-memories/app/templates/phase3.html deleted file mode 100644 index 8a528d7..0000000 --- a/services/travel-memories/app/templates/phase3.html +++ /dev/null @@ -1,91 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-
-

Curate

- -
- {% for day, photos in photos_by_day.items() %} -
-

{{ day }}

-
- {% for photo in photos %} -
- -
- - -
-
- {% endfor %} -
-
- {% endfor %} -
-{% endblock %} - -{% block extra_scripts %} - - -{% endblock %} diff --git a/services/travel-memories/app/templates/phase4.html b/services/travel-memories/app/templates/phase4.html deleted file mode 100644 index 3a47067..0000000 --- a/services/travel-memories/app/templates/phase4.html +++ /dev/null @@ -1,99 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-
-

Group

- -
- -
- {% for grp in groups %} -
- {% if grp.label %} -
{{ grp.label }}
- {% endif %} - {% for photo in grp.photos %} -
- - {{ photo.local_datetime[11:16] }} - - {{ photo.tag }} - -
- {% if not loop.last %} -
-
- -
- {% endif %} - {% endfor %} -
- - {% if grp.divider_id %} -
- - -
- {% endif %} - - {% if not loop.last and not grp.divider_id %} -
-
- -
- {% endif %} - {% endfor %} -
-
-{% endblock %} - -{% block extra_scripts %} - -{% endblock %} diff --git a/services/travel-memories/app/templates/phase5.html b/services/travel-memories/app/templates/phase5.html deleted file mode 100644 index 5d7363f..0000000 --- a/services/travel-memories/app/templates/phase5.html +++ /dev/null @@ -1,202 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-
-

Write

- {{ done_count }} / {{ total }} done -
- - {% if not group %} -
All groups written or skipped.
-
- - -
- {% else %} -
- - -
- {% for photo in photos %} - - {% endfor %} -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
-
- - -
-
- - -
-
- -
- - -
- - -
- -

Click a photo on the left to set it as the hero.

-
- -
- - -
- -
- {% if group_idx > 0 %} - ← Prev - {% endif %} - - -
-
- - -
-

Your notes

-

{{ state.notes or 'No notes yet.' }}

-
-
- {% endif %} -
-{% endblock %} - -{% block extra_scripts %} -{% if group %} - -{% endif %} -{% endblock %} diff --git a/services/travel-memories/app/templates/phase6.html b/services/travel-memories/app/templates/phase6.html deleted file mode 100644 index 1dab8cf..0000000 --- a/services/travel-memories/app/templates/phase6.html +++ /dev/null @@ -1,104 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-

Export

-
-
Ready to export
-
{{ to_export | length }}
-
Skipped
-
{{ skipped | length }}
-
- -
- {% for group in to_export %} -
-
-

{{ group.title }}

-

{{ group.date }} Β· {{ group.entry_type }} Β· {{ group.photo_ids | length }} photos

-
-
- {% endfor %} -
- - - - - - - - - -
-
- -
- - Skipped ({{ skipped | length }}) β€” not exported - -
    - {% for g in skipped %}
  • {{ g.title or g.date }}
  • {% endfor %} -
-
-
-{% endblock %} - -{% block extra_scripts %} - -{% endblock %} diff --git a/services/travel-memories/pytest.ini b/services/travel-memories/pytest.ini deleted file mode 100644 index a635c5c..0000000 --- a/services/travel-memories/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -pythonpath = . diff --git a/services/travel-memories/requirements.txt b/services/travel-memories/requirements.txt deleted file mode 100644 index ed586c6..0000000 --- a/services/travel-memories/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -flask==3.1.0 -requests==2.32.3 -pytest==8.3.4 -pytest-playwright==0.6.2 -pytest-httpserver==1.1.0 diff --git a/services/travel-memories/tests/conftest.py b/services/travel-memories/tests/conftest.py deleted file mode 100644 index 444775a..0000000 --- a/services/travel-memories/tests/conftest.py +++ /dev/null @@ -1,101 +0,0 @@ -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(make_httpserver): - server = make_httpserver - server.expect_request("/api/albums").respond_with_json(MOCK_ALBUMS) - server.expect_request("/api/albums/album-1").respond_with_json(MOCK_ALBUM_DETAIL) - for asset_id in ["asset-1", "asset-2", "asset-3"]: - server.expect_request( - f"/api/assets/{asset_id}/thumbnail" - ).respond_with_data(TINY_PNG, content_type="image/png") - server.expect_request( - f"/api/assets/{asset_id}/original" - ).respond_with_data(TINY_PNG, content_type="image/jpeg") - return server - - -@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 diff --git a/services/travel-memories/tests/fixtures/phase2_state.json b/services/travel-memories/tests/fixtures/phase2_state.json deleted file mode 100644 index 1941a5d..0000000 --- a/services/travel-memories/tests/fixtures/phase2_state.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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": "", - "dividers": [], - "group_labels": {} -} diff --git a/services/travel-memories/tests/fixtures/phase3_state.json b/services/travel-memories/tests/fixtures/phase3_state.json deleted file mode 100644 index 70da676..0000000 --- a/services/travel-memories/tests/fixtures/phase3_state.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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": "", - "dividers": [], - "group_labels": {} -} diff --git a/services/travel-memories/tests/fixtures/phase4_state.json b/services/travel-memories/tests/fixtures/phase4_state.json deleted file mode 100644 index 6851fdc..0000000 --- a/services/travel-memories/tests/fixtures/phase4_state.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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.", - "dividers": [], - "group_labels": {} -} diff --git a/services/travel-memories/tests/fixtures/phase5_state.json b/services/travel-memories/tests/fixtures/phase5_state.json deleted file mode 100644 index 8d22623..0000000 --- a/services/travel-memories/tests/fixtures/phase5_state.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "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", - "label": "", "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", - "label": "", "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.", - "dividers": [], - "group_labels": {} -} diff --git a/services/travel-memories/tests/fixtures/phase6_state.json b/services/travel-memories/tests/fixtures/phase6_state.json deleted file mode 100644 index 19fc5e6..0000000 --- a/services/travel-memories/tests/fixtures/phase6_state.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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", - "label": "", "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", - "label": "", "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": "", - "dividers": [], - "group_labels": {} -} diff --git a/services/travel-memories/tests/test_crosscutting.py b/services/travel-memories/tests/test_crosscutting.py deleted file mode 100644 index a9aa6c7..0000000 --- a/services/travel-memories/tests/test_crosscutting.py +++ /dev/null @@ -1,87 +0,0 @@ -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").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" diff --git a/services/travel-memories/tests/test_immich.py b/services/travel-memories/tests/test_immich.py deleted file mode 100644 index 8db956e..0000000 --- a/services/travel-memories/tests/test_immich.py +++ /dev/null @@ -1,50 +0,0 @@ -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/") diff --git a/services/travel-memories/tests/test_notes.py b/services/travel-memories/tests/test_notes.py deleted file mode 100644 index fb14051..0000000 --- a/services/travel-memories/tests/test_notes.py +++ /dev/null @@ -1,40 +0,0 @@ -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"] diff --git a/services/travel-memories/tests/test_phase1.py b/services/travel-memories/tests/test_phase1.py deleted file mode 100644 index 4f23b47..0000000 --- a/services/travel-memories/tests/test_phase1.py +++ /dev/null @@ -1,30 +0,0 @@ -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() diff --git a/services/travel-memories/tests/test_phase2.py b/services/travel-memories/tests/test_phase2.py deleted file mode 100644 index 8a0ac2c..0000000 --- a/services/travel-memories/tests/test_phase2.py +++ /dev/null @@ -1,40 +0,0 @@ -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**") diff --git a/services/travel-memories/tests/test_phase3.py b/services/travel-memories/tests/test_phase3.py deleted file mode 100644 index 19d269e..0000000 --- a/services/travel-memories/tests/test_phase3.py +++ /dev/null @@ -1,40 +0,0 @@ -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**") diff --git a/services/travel-memories/tests/test_phase4.py b/services/travel-memories/tests/test_phase4.py deleted file mode 100644 index c7d3e27..0000000 --- a/services/travel-memories/tests/test_phase4.py +++ /dev/null @@ -1,47 +0,0 @@ -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**") diff --git a/services/travel-memories/tests/test_phase5.py b/services/travel-memories/tests/test_phase5.py deleted file mode 100644 index d650c16..0000000 --- a/services/travel-memories/tests/test_phase5.py +++ /dev/null @@ -1,44 +0,0 @@ -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() diff --git a/services/travel-memories/tests/test_phase6.py b/services/travel-memories/tests/test_phase6.py deleted file mode 100644 index ee15b7a..0000000 --- a/services/travel-memories/tests/test_phase6.py +++ /dev/null @@ -1,74 +0,0 @@ -import json -import shutil -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, pages_dir): - album_id = seed_state("phase6_state") - - # Ensure dest folder does not exist so export proceeds without conflict - daily_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies" - if daily_dest.exists(): - shutil.rmtree(daily_dest) - - res = page.request.post( - f"{base_url}/export/run", - data=json.dumps({"album_id": album_id}), - headers={"Content-Type": "application/json"}, - ) - data = res.json() - - # Must not be a conflict β€” export should succeed - assert data.get("ok") is True, f"Expected ok response, got: {data}" - - # The journal entry.md file must exist on disk - entry_files = list(daily_dest.glob("**/entry.md")) if daily_dest.exists() else [] - assert len(entry_files) >= 1, "entry.md not written to disk" - - # Status must be exported in state - 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") - - # Clean dest so there's no conflict - daily_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies" - if daily_dest.exists(): - shutil.rmtree(daily_dest) - - res = page.request.post( - f"{base_url}/export/run", - data=json.dumps({"album_id": album_id}), - headers={"Content-Type": "application/json"}, - ) - data = res.json() - - # Response shape: {"ok": true, "exported": N, "failed": [...]} - # g2 "The Market" is skipped β€” it must not appear as an exported folder - stories_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "04.stories" - market_dirs = list(stories_dest.glob("*the-market*")) if stories_dest.exists() else [] - assert len(market_dirs) == 0, "Skipped group 'The Market' must not be exported" - - # And the response must not include a conflict (only written groups are exported) - assert data.get("ok") is True, f"Expected ok response, got: {data}" diff --git a/services/travel-memories/tests/test_smoke.py b/services/travel-memories/tests/test_smoke.py deleted file mode 100644 index 77ca68c..0000000 --- a/services/travel-memories/tests/test_smoke.py +++ /dev/null @@ -1,3 +0,0 @@ -def test_health(base_url, page): - page.goto(f"{base_url}/health") - assert "ok" in page.content() diff --git a/services/travel-memories/tests/test_state.py b/services/travel-memories/tests/test_state.py deleted file mode 100644 index 3bba99f..0000000 --- a/services/travel-memories/tests/test_state.py +++ /dev/null @@ -1,52 +0,0 @@ -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"