diff --git a/services/travel-memories/app/__init__.py b/services/travel-memories/app/__init__.py index 0e22d9b..5d5a1e8 100644 --- a/services/travel-memories/app/__init__.py +++ b/services/travel-memories/app/__init__.py @@ -8,7 +8,7 @@ def create_app(state_dir=None, pages_dir=None): 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 + 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) @@ -17,6 +17,7 @@ def create_app(state_dir=None, pages_dir=None): 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(): diff --git a/services/travel-memories/app/routes/export.py b/services/travel-memories/app/routes/export.py new file mode 100644 index 0000000..7baa51d --- /dev/null +++ b/services/travel-memories/app/routes/export.py @@ -0,0 +1,234 @@ +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 = "entry.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), + }) + # Mark as exported since destination already exists + group.status = "exported" + continue + + if dest.exists(): + 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 + 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({"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" + ) + 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" + ) + + 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" + 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}) + + +@bp.post("/export/overwrite") +def overwrite_export(): + body = request.get_json() + album_id = body["album_id"] + group_id = body["group_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} + + group = next((g for g in state.groups if g.id == group_id), None) + if group is None: + return jsonify({"ok": False, "error": "group not found"}), 404 + + 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 = "entry.md" + template = "story" + + if dest.exists(): + shutil.rmtree(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({"asset_id": pid, "error": str(e)}) + + 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" + ) + 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" + ) + + 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" + save_state(state, current_app) + + return jsonify({ + "ok": True, + "exported": 1, + "title": group.title, + "dest": str(dest), + "failed_photos": failed, + }) diff --git a/services/travel-memories/app/templates/phase6.html b/services/travel-memories/app/templates/phase6.html new file mode 100644 index 0000000..aa71029 --- /dev/null +++ b/services/travel-memories/app/templates/phase6.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} +{% block content %} +
{{ group.title }}
+{{ group.date }} · {{ group.entry_type }} · {{ group.photo_ids | length }} photos
+