"""Strava integration endpoints (/api/strava/*).""" from __future__ import annotations import json import secrets from typing import Optional from fastapi import APIRouter, Cookie, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse from bincio.serve import deps, tasks from bincio.serve.db import get_setting router = APIRouter() _strava_oauth_states: set[str] = set() @router.get("/api/strava/status") async def strava_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = deps._require_user(bincio_session) cid, _ = deps._strava_creds(user.handle) if not cid: return JSONResponse({"configured": False, "connected": False, "last_sync": None}) dd = deps._get_data_dir() / user.handle from bincio.extract.strava_api import load_token token = load_token(dd) return JSONResponse({ "configured": True, "connected": token is not None, "last_sync": token.get("last_sync_at") if token else None, }) @router.post("/api/strava/disconnect") async def strava_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: """Remove the stored Strava token, forcing a fresh OAuth on next connect.""" user = deps._require_user(bincio_session) token_path = deps._get_data_dir() / user.handle / "strava_token.json" token_path.unlink(missing_ok=True) return JSONResponse({"ok": True}) @router.post("/api/strava/reset") async def strava_reset(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: """Reset last_sync_at so the next sync re-fetches from a chosen point. mode=soft — set to the started_at of the most recent activity on disk (next sync only fetches activities newer than the last known one) mode=hard — clear last_sync_at entirely (next sync re-downloads full Strava history, skipping existing files) """ user = deps._require_user(bincio_session) dd = deps._get_data_dir() / user.handle from bincio.extract.strava_api import load_token, save_token token = load_token(dd) if token is None: raise HTTPException(400, "Not connected to Strava") body = await request.json() mode = body.get("mode", "soft") if mode == "hard": token.pop("last_sync_at", None) save_token(dd, token) return JSONResponse({"ok": True, "mode": "hard", "last_sync_at": None}) # soft: find the most recent started_at across the user's merged index from datetime import datetime, timezone last_ts: int | None = None for index_path in [dd / "_merged" / "index.json", dd / "index.json"]: if not index_path.exists(): continue try: index_data = json.loads(index_path.read_text(encoding="utf-8")) started_ats = [ a.get("started_at") for a in index_data.get("activities", []) if a.get("started_at") ] if started_ats: latest = max(started_ats) dt = datetime.fromisoformat(latest.replace("Z", "+00:00")) last_ts = int(dt.astimezone(timezone.utc).timestamp()) break except (OSError, json.JSONDecodeError, ValueError): continue if last_ts is None: token.pop("last_sync_at", None) else: token["last_sync_at"] = last_ts save_token(dd, token) return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts}) @router.get("/api/strava/auth-url") async def strava_auth_url(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = deps._require_user(bincio_session) cid, _ = deps._strava_creds(user.handle) if not cid: raise HTTPException(400, "Strava client ID not configured on this server") state = secrets.token_urlsafe(16) _strava_oauth_states.add(state) if deps.public_url: redirect_uri = deps.public_url.rstrip("/") + "/api/strava/callback" else: redirect_uri = str(request.url_for("strava_callback")) from bincio.extract.strava_api import auth_url return JSONResponse({"url": auth_url(cid, redirect_uri, state=state)}) @router.get("/api/strava/callback", name="strava_callback") async def strava_callback( request: Request, code: str = "", error: str = "", state: str = "", bincio_session: Optional[str] = Cookie(default=None), ) -> RedirectResponse: site_origin = deps.public_url.rstrip("/") if deps.public_url else str(request.base_url).rstrip("/") if error or not code: return RedirectResponse(f"{site_origin}/?strava=error") if state not in _strava_oauth_states: return RedirectResponse(f"{site_origin}/?strava=error") _strava_oauth_states.discard(state) user = deps._current_user(bincio_session) if not user: return RedirectResponse(f"{site_origin}/?strava=error") cid, csec = deps._strava_creds(user.handle) if not cid or not csec: return RedirectResponse(f"{site_origin}/?strava=error") dd = deps._get_data_dir() / user.handle from bincio.extract.strava_api import StravaError, exchange_code, save_token try: token = exchange_code(cid, csec, code) except StravaError: return RedirectResponse(f"{site_origin}/?strava=error") save_token(dd, token) return RedirectResponse(f"{site_origin}/?strava=connected") @router.get("/api/strava/sync/stream") async def serve_strava_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse: """SSE endpoint — streams per-activity progress then a final summary event.""" user = deps._require_user(bincio_session) cid, csec = deps._strava_creds(user.handle) if not cid or not csec: raise HTTPException(400, "Strava not configured on this server") dd = deps._get_data_dir() / user.handle store_orig_setting = get_setting(deps._get_db(), "store_originals") store_orig = store_orig_setting == "true" originals_dir = (dd / "originals" / "strava") if store_orig else None if originals_dir: originals_dir.mkdir(parents=True, exist_ok=True) from bincio.extract.ingest import strava_sync_iter def event_stream(): try: for event in strava_sync_iter(dd, cid, csec, originals_dir): if event["type"] == "done": tasks._trigger_rebuild(user.handle) # start before client closes connection yield f"data: {json.dumps(event)}\n\n" except Exception as exc: yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n" return StreamingResponse( event_stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @router.post("/api/strava/import-gear") async def serve_strava_import_gear(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: """One-time backfill: scan stored Strava originals for gear_ids, fetch names, populate gear registry.""" user = deps._require_user(bincio_session) cid, csec = deps._strava_creds(user.handle) if not cid or not csec: raise HTTPException(400, "Strava not configured on this server") dd = deps._get_data_dir() / user.handle originals_dir = dd / "originals" / "strava" if not originals_dir.exists(): return JSONResponse({"ok": True, "gear_added": 0, "activities_updated": 0, "message": "No stored originals found"}) import contextlib import uuid from bincio.extract.strava_api import StravaError, ensure_fresh, fetch_gear from bincio.render.merge import merge_one from bincio.serve.routers.gear import _load as _gear_load, _save as _gear_save try: token = ensure_fresh(dd, cid, csec) except StravaError as e: raise HTTPException(502, str(e)) registry = _gear_load(dd) known_strava_ids = {g.get("strava_id") for g in registry if g.get("strava_id")} # Collect all unique gear_ids from originals gear_id_to_activities: dict[str, list[str]] = {} for orig_path in originals_dir.glob("*.json"): try: data = json.loads(orig_path.read_text(encoding="utf-8")) gear_id = (data.get("meta") or {}).get("gear_id") or "" if gear_id: gear_id_to_activities.setdefault(gear_id, []).append(orig_path.stem) except (OSError, json.JSONDecodeError): continue gear_added = 0 activities_updated = 0 for gear_id, activity_ids in gear_id_to_activities.items(): if gear_id in known_strava_ids: gear_name = next(g["name"] for g in registry if g.get("strava_id") == gear_id) else: details = fetch_gear(token["access_token"], gear_id) gear_name = details.get("name") or "" if not gear_name: continue gear_type = "shoes" if gear_id.startswith("g") else "bike" new_item: dict = {"id": str(uuid.uuid4()), "name": gear_name, "type": gear_type, "retired": False, "strava_id": gear_id} registry.append(new_item) known_strava_ids.add(gear_id) gear_added += 1 # Backfill: write sidecar for each activity that has no gear set yet import yaml as _yaml edits_dir = dd / "edits" edits_dir.mkdir(exist_ok=True) for activity_id in activity_ids: activity_json = dd / "activities" / f"{activity_id}.json" if not activity_json.exists(): continue try: act = json.loads(activity_json.read_text(encoding="utf-8")) if act.get("gear"): continue # already has gear except (OSError, json.JSONDecodeError): continue sidecar = edits_dir / f"{activity_id}.md" fm: dict = {} body = "" if sidecar.exists(): try: text = sidecar.read_text(encoding="utf-8") import re as _re parts = _re.split(r"^---[ \t]*$", text, maxsplit=2, flags=_re.MULTILINE) if len(parts) >= 3: fm = _yaml.safe_load(parts[1]) or {} body = parts[2].strip() except Exception: pass if fm.get("gear"): continue # sidecar already sets gear fm["gear"] = gear_name fm_text = _yaml.safe_dump(fm, default_flow_style=False, allow_unicode=True).strip() content = f"---\n{fm_text}\n---\n" if body: content += f"\n{body}\n" sidecar.write_text(content, encoding="utf-8") with contextlib.suppress(Exception): merge_one(dd, activity_id) activities_updated += 1 _gear_save(dd, registry) tasks._trigger_rebuild(user.handle) return JSONResponse({"ok": True, "gear_added": gear_added, "activities_updated": activities_updated}) @router.post("/api/strava/sync") async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = deps._require_user(bincio_session) cid, csec = deps._strava_creds(user.handle) if not cid or not csec: raise HTTPException(400, "Strava not configured on this server") dd = deps._get_data_dir() / user.handle store_orig_setting = get_setting(deps._get_db(), "store_originals") store_orig = store_orig_setting == "true" originals_dir = (dd / "originals" / "strava") if store_orig else None if originals_dir: originals_dir.mkdir(parents=True, exist_ok=True) from bincio.edit.ops import run_strava_sync try: result = run_strava_sync(dd, cid, csec, originals_dir=originals_dir) except RuntimeError as e: raise HTTPException(502, str(e)) tasks._trigger_rebuild(user.handle) return JSONResponse(result)