diff --git a/bincio/extract/garmin_sync.py b/bincio/extract/garmin_sync.py index a27b24e..3c8ecec 100644 --- a/bincio/extract/garmin_sync.py +++ b/bincio/extract/garmin_sync.py @@ -22,9 +22,9 @@ from __future__ import annotations import io import json import zipfile -from datetime import datetime, timedelta, timezone +from collections.abc import Generator +from datetime import UTC, datetime, timedelta from pathlib import Path -from typing import Generator _SYNC_FILE = "garmin_sync.json" @@ -73,9 +73,13 @@ def garmin_sync_iter( data_dir: Root data directory (used for encryption key lookup). user_dir: Per-user directory (contains activities/, garmin_creds.json, etc.). """ + import uuid as _uuid + from bincio.extract.garmin_api import GarminError, get_client from bincio.extract.ingest import ingest_parsed from bincio.extract.parsers.fit import FitParser + from bincio.serve.routers.gear import _load as _gear_load + from bincio.serve.routers.gear import _save as _gear_save # ── Login ────────────────────────────────────────────────────────────────── try: @@ -86,6 +90,41 @@ def garmin_sync_iter( yield {"type": "fetching"} + # ── Sync gear registry ───────────────────────────────────────────────────── + _garmin_uuid_to_name: dict[str, str] = {} + try: + prof = client.connectapi("/userprofile-service/socialProfile") + profile_id = prof.get("profileId") if isinstance(prof, dict) else None + if profile_id: + garmin_gear = client.get_gear(profile_id) + if isinstance(garmin_gear, list): + registry = _gear_load(user_dir) + known = {g.get("garmin_id") for g in registry if g.get("garmin_id")} + for g in garmin_gear: + guuid = g.get("uuid") or "" + name = (g.get("customMakeModel") or g.get("displayName") or + f"{g.get('gearMakeName','')} {g.get('gearModelName','')}".strip()) + if not name or not guuid: + continue + _garmin_uuid_to_name[guuid] = name + if guuid not in known: + gear_type = g.get("gearTypeName", "").lower() + if gear_type not in ("bike", "shoes", "skis"): + gear_type = "other" + retired = g.get("gearStatusName") == "retired" + registry.append({"id": str(_uuid.uuid4()), "name": name, + "type": gear_type, "retired": retired, + "garmin_id": guuid}) + known.add(guuid) + else: + # Update name in case it changed + for item in registry: + if item.get("garmin_id") == guuid: + item["name"] = name + _gear_save(user_dir, registry) + except Exception: + pass # gear sync is best-effort; don't abort activity sync + # ── Determine date range ─────────────────────────────────────────────────── state = _load_sync_state(user_dir) last = state.get("last_sync_at") @@ -144,6 +183,16 @@ def garmin_sync_iter( except Exception as exc: raise RuntimeError(f"FIT parse error: {exc}") from exc + # Resolve gear for this activity + if garmin_id and _garmin_uuid_to_name: + try: + act_gear = client.get_activity_gear(garmin_id) + if isinstance(act_gear, list) and act_gear: + guuid = act_gear[0].get("uuid") or "" + parsed.gear = _garmin_uuid_to_name.get(guuid) or None + except Exception: + pass + # Ingest — raises FileExistsError if already present (dedup) ingest_parsed(parsed, user_dir) imported += 1 @@ -173,7 +222,7 @@ def garmin_sync_iter( } # ── Persist sync state ───────────────────────────────────────────────────── - state["last_sync_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%d") + state["last_sync_at"] = datetime.now(UTC).strftime("%Y-%m-%d") state["total_imported"] = state.get("total_imported", 0) + imported _save_sync_state(user_dir, state) diff --git a/bincio/serve/routers/garmin.py b/bincio/serve/routers/garmin.py index 1e82575..9a7c1e6 100644 --- a/bincio/serve/routers/garmin.py +++ b/bincio/serve/routers/garmin.py @@ -2,7 +2,7 @@ from __future__ import annotations import json -from typing import Optional +from datetime import UTC from fastapi import APIRouter, Cookie, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse @@ -41,7 +41,7 @@ def _garmin_user_message(exc: Exception) -> str: @router.get("/api/garmin/status") -async def garmin_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: +async def garmin_status(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: """Return whether Garmin credentials are stored for the current user.""" user = deps._require_user(bincio_session) dd = deps._get_data_dir() / user.handle @@ -58,7 +58,7 @@ async def garmin_status(bincio_session: Optional[str] = Cookie(default=None)) -> @router.post("/api/garmin/connect") async def garmin_connect( request: Request, - bincio_session: Optional[str] = Cookie(default=None), + bincio_session: str | None = Cookie(default=None), ) -> JSONResponse: """Test Garmin login with the supplied credentials and save them on success.""" user = deps._require_user(bincio_session) @@ -79,7 +79,7 @@ async def garmin_connect( @router.post("/api/garmin/disconnect") -async def garmin_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: +async def garmin_disconnect(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: """Remove stored Garmin credentials and session for the current user.""" user = deps._require_user(bincio_session) dd = deps._get_data_dir() / user.handle @@ -89,7 +89,7 @@ async def garmin_disconnect(bincio_session: Optional[str] = Cookie(default=None) @router.get("/api/garmin/sync/stream") -async def garmin_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse: +async def garmin_sync_stream(bincio_session: str | None = Cookie(default=None)) -> StreamingResponse: """SSE endpoint — streams per-activity Garmin sync progress.""" user = deps._require_user(bincio_session) data_dir = deps._get_data_dir() @@ -117,3 +117,158 @@ async def garmin_sync_stream(bincio_session: Optional[str] = Cookie(default=None media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) + + +@router.post("/api/garmin/import-gear") +async def garmin_import_gear(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + """One-time backfill: fetch gear registry from Garmin and match to existing activities by timestamp.""" + import contextlib + import re + import uuid + + import yaml + + from bincio.extract.garmin_api import GarminError, get_client, has_credentials + from bincio.render.merge import merge_one + from bincio.serve.routers.gear import _load as _gear_load + from bincio.serve.routers.gear import _save as _gear_save + + user = deps._require_user(bincio_session) + data_dir = deps._get_data_dir() + user_dir = data_dir / user.handle + + if not has_credentials(user_dir): + raise HTTPException(400, "No Garmin credentials stored — connect first") + + try: + client = get_client(data_dir, user_dir) + except GarminError as e: + raise HTTPException(502, str(e)) + + # Fetch gear list + try: + prof = client.connectapi("/userprofile-service/socialProfile") + profile_id = prof.get("profileId") if isinstance(prof, dict) else None + if not profile_id: + raise HTTPException(502, "Could not read Garmin profile ID") + garmin_gear = client.get_gear(profile_id) + except GarminError as e: + raise HTTPException(502, str(e)) + + if not isinstance(garmin_gear, list) or not garmin_gear: + return JSONResponse({"ok": True, "gear_added": 0, "activities_updated": 0}) + + # Build / update gear registry + registry = _gear_load(user_dir) + known = {g.get("garmin_id") for g in registry if g.get("garmin_id")} + uuid_to_name: dict[str, str] = {} + gear_added = 0 + + for g in garmin_gear: + guuid = g.get("uuid") or "" + name = (g.get("customMakeModel") or g.get("displayName") or + f"{g.get('gearMakeName','')} {g.get('gearModelName','')}".strip()) + if not name or not guuid: + continue + uuid_to_name[guuid] = name + if guuid not in known: + gear_type = g.get("gearTypeName", "").lower() + if gear_type not in ("bike", "shoes", "skis"): + gear_type = "other" + retired = g.get("gearStatusName") == "retired" + registry.append({"id": str(uuid.uuid4()), "name": name, + "type": gear_type, "retired": retired, "garmin_id": guuid}) + known.add(guuid) + gear_added += 1 + else: + for item in registry: + if item.get("garmin_id") == guuid: + item["name"] = name + + _gear_save(user_dir, registry) + + # Build timestamp → activity_id map from the user's index shards + from datetime import datetime + ts_to_id: dict[int, str] = {} + merged_dir = user_dir / "_merged" + shard_dirs = [merged_dir] if merged_dir.exists() else [user_dir] + for shard_dir in shard_dirs: + for shard_path in sorted(shard_dir.glob("index*.json")): + try: + idx = json.loads(shard_path.read_text(encoding="utf-8")) + for a in idx.get("activities", []): + started = a.get("started_at") or "" + if started and a.get("id"): + dt = datetime.fromisoformat(started.replace("Z", "+00:00")) + ts_to_id[int(dt.astimezone(UTC).timestamp())] = a["id"] + except (OSError, json.JSONDecodeError, KeyError): + continue + + # For each gear, fetch its activities and match by timestamp + edits_dir = user_dir / "edits" + edits_dir.mkdir(exist_ok=True) + activities_updated = 0 + + for guuid, gear_name in uuid_to_name.items(): + try: + gear_acts = client.get_gear_activities(guuid, limit=10000) + except Exception: + continue + if not isinstance(gear_acts, list): + continue + + for ga in gear_acts: + gmt = ga.get("startTimeGMT") or "" + if not gmt: + continue + try: + from datetime import datetime + dt = datetime.strptime(gmt, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC) + ts = int(dt.timestamp()) + except ValueError: + continue + + # Match within ±60 s + act_id = None + for delta in range(0, 61): + act_id = ts_to_id.get(ts + delta) or ts_to_id.get(ts - delta) + if act_id: + break + if not act_id: + continue + + # Skip if activity already has gear set + act_json = user_dir / "activities" / f"{act_id}.json" + if act_json.exists(): + try: + if json.loads(act_json.read_text(encoding="utf-8")).get("gear"): + continue + except (OSError, json.JSONDecodeError): + pass + + sidecar = edits_dir / f"{act_id}.md" + fm, body = {}, "" + if sidecar.exists(): + try: + text = sidecar.read_text(encoding="utf-8") + 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 + + 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(user_dir, act_id) + activities_updated += 1 + + tasks._trigger_rebuild(user.handle) + return JSONResponse({"ok": True, "gear_added": gear_added, "activities_updated": activities_updated})