292 lines
12 KiB
Python
292 lines
12 KiB
Python
"""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)
|