"""OG preview endpoints. GET /activity/{activity_id} Returns a minimal HTML page with Open Graph meta tags for social link previews (Telegram, WhatsApp, Slack, โฆ). nginx proxies only bot User-Agents here; regular browsers still get the static SPA shell. GET /api/og-image/{user_handle}/{activity_id}.png Returns the pre-generated 400ร400 track PNG. Falls back to generating on the fly if the static file doesn't exist yet (e.g. a brand-new import before the next deploy-time generation run). """ from __future__ import annotations import html import json from datetime import datetime, timezone from pathlib import Path from fastapi import APIRouter, HTTPException, Request from fastapi.responses import HTMLResponse, Response from bincio.serve import deps router = APIRouter() # Sport โ emoji map (extend as needed) _SPORT_EMOJI: dict[str, str] = { "cycling": "๐ด", "running": "๐", "swimming": "๐", "hiking": "๐ฅพ", "walking": "๐ถ", "skiing": "โท๏ธ", "rowing": "๐ฃ", "triathlon": "๐", "e_cycling": "๐ด", "gravel": "๐ด", } def _find_user(data_dir: Path, activity_id: str) -> str | None: """Return the user handle that owns *activity_id*, or None.""" for user_dir in sorted(data_dir.iterdir()): if not user_dir.is_dir() or user_dir.name.startswith("_") or user_dir.name == "segments": continue if (user_dir / "activities" / f"{activity_id}.json").exists(): return user_dir.name return None def _fmt_description(detail: dict, handle: str) -> str: parts: list[str] = [] sport = (detail.get("sport") or "").lower() emoji = _SPORT_EMOJI.get(sport, "๐ ") parts.append(emoji) dist_m = detail.get("distance_m") if dist_m: parts.append(f"{dist_m / 1000:.1f} km") gain = detail.get("elevation_gain_m") if gain: parts.append(f"{gain:.0f} m โ") dur = detail.get("moving_time_s") or detail.get("duration_s") if dur: h, rem = divmod(int(dur), 3600) m = rem // 60 parts.append(f"{h}h {m:02d}m" if h else f"{m}m") started = detail.get("started_at") if started: try: dt = datetime.fromisoformat(started).astimezone(timezone.utc) parts.append(dt.strftime("%-d %b %Y")) except ValueError: pass parts.append(f"@{handle}") return " ยท ".join(parts) @router.get("/activity/{activity_id}", response_class=HTMLResponse, include_in_schema=False) @router.get("/activity/{activity_id}/", response_class=HTMLResponse, include_in_schema=False) async def og_preview(activity_id: str, request: Request) -> HTMLResponse: data_dir = deps._get_data_dir() handle = _find_user(data_dir, activity_id) if handle is None: raise HTTPException(404) json_path = data_dir / handle / "activities" / f"{activity_id}.json" detail = json.loads(json_path.read_text(encoding="utf-8")) title = detail.get("title") or activity_id desc = _fmt_description(detail, handle) base = str(request.base_url).rstrip("/") img_url = f"{base}/og-image/{handle}/{activity_id}.png" act_url = f"{base}/activity/{activity_id}/" h_title = html.escape(title) h_desc = html.escape(desc) h_img = html.escape(img_url) h_url = html.escape(act_url) content = f"""