diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 6e1135a..58f3bc1 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -15,7 +15,7 @@ import time from pathlib import Path from typing import Any, Optional -from fastapi import Cookie, FastAPI, HTTPException, Request, Response +from fastapi import Cookie, FastAPI, File, HTTPException, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -320,6 +320,175 @@ async def post_activity( return JSONResponse({"ok": True}) +@app.get("/api/activity/{activity_id}/images") +async def list_images( + activity_id: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = _require_user(bincio_session) + _check_id(activity_id) + dd = _get_data_dir() / user.handle + images_dir = dd / "edits" / "images" / activity_id + images = sorted(p.name for p in images_dir.iterdir() if p.is_file()) if images_dir.exists() else [] + return JSONResponse({"images": images}) + + +@app.post("/api/activity/{activity_id}/images") +async def upload_image( + activity_id: str, + file: UploadFile = File(...), + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = _require_user(bincio_session) + _check_id(activity_id) + dd = _get_data_dir() / user.handle + if not (dd / "activities" / f"{activity_id}.json").exists(): + raise HTTPException(404, "Activity not found") + if not file.filename: + raise HTTPException(400, "No filename") + ct = file.content_type or "" + if not ct.startswith("image/"): + raise HTTPException(400, f"Only image files accepted (got {ct})") + images_dir = dd / "edits" / "images" / activity_id + images_dir.mkdir(parents=True, exist_ok=True) + safe_name = Path(file.filename).name + (images_dir / safe_name).write_bytes(await file.read()) + _trigger_rebuild(user.handle) + return JSONResponse({"ok": True, "filename": safe_name}) + + +@app.delete("/api/activity/{activity_id}/images/{filename}") +async def delete_image( + activity_id: str, + filename: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = _require_user(bincio_session) + _check_id(activity_id) + dd = _get_data_dir() / user.handle + import shutil + safe_name = Path(filename).name + target = dd / "edits" / "images" / activity_id / safe_name + if target.exists() and target.is_file(): + target.unlink() + if target.parent.exists() and not any(target.parent.iterdir()): + shutil.rmtree(target.parent) + _trigger_rebuild(user.handle) + return JSONResponse({"ok": True}) + + +@app.get("/api/athlete") +async def get_athlete(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + user = _require_user(bincio_session) + dd = _get_data_dir() / user.handle + athlete_path = dd / "athlete.json" + if not athlete_path.exists(): + raise HTTPException(404, "athlete.json not found — run bincio extract first") + data = json.loads(athlete_path.read_text(encoding="utf-8")) + # Layer edits/athlete.yaml on top + edits_path = dd / "edits" / "athlete.yaml" + if edits_path.exists(): + try: + import yaml + edits = yaml.safe_load(edits_path.read_text(encoding="utf-8")) or {} + for k in ("max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"): + if k in edits: + data[k] = edits[k] + except Exception: + pass + return JSONResponse({ + "max_hr": data.get("max_hr"), + "ftp_w": data.get("ftp_w"), + "hr_zones": data.get("hr_zones"), + "power_zones": data.get("power_zones"), + "seasons": data.get("seasons", []), + "gear": data.get("gear", {}), + }) + + +@app.post("/api/athlete") +async def save_athlete( + request: Request, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = _require_user(bincio_session) + dd = _get_data_dir() / user.handle + if not (dd / "athlete.json").exists(): + raise HTTPException(404, "athlete.json not found — run bincio extract first") + payload = await request.json() + edits_dir = dd / "edits" + edits_dir.mkdir(exist_ok=True) + overrides: dict[str, Any] = {} + if payload.get("max_hr") is not None: + overrides["max_hr"] = int(payload["max_hr"]) + if payload.get("ftp_w") is not None: + overrides["ftp_w"] = int(payload["ftp_w"]) + if payload.get("hr_zones") is not None: + overrides["hr_zones"] = [[int(lo), int(hi)] for lo, hi in payload["hr_zones"]] + if payload.get("power_zones") is not None: + overrides["power_zones"] = [[int(lo), int(hi)] for lo, hi in payload["power_zones"]] + if payload.get("seasons") is not None: + overrides["seasons"] = [ + {"name": str(s["name"]), "start": str(s["start"]), "end": str(s["end"])} + for s in payload["seasons"] + ] + if payload.get("gear") is not None: + overrides["gear"] = payload["gear"] + import yaml + (edits_dir / "athlete.yaml").write_text( + yaml.dump(overrides, allow_unicode=True, default_flow_style=False), + encoding="utf-8", + ) + from bincio.render.merge import merge_all + merge_all(dd) + _trigger_rebuild(user.handle) + return JSONResponse({"ok": True}) + + +_SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"} + + +@app.post("/api/upload") +async def upload_activity( + file: UploadFile = File(...), + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = _require_user(bincio_session) + dd = _get_data_dir() / user.handle + name = Path(file.filename or "upload.fit").name + p = Path(name.lower()) + suffix = (p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz") if p.suffix == ".gz" else p.suffix + if suffix not in _SUPPORTED_SUFFIXES: + raise HTTPException(400, f"Unsupported file type '{suffix}'") + contents = await file.read() + if len(contents) > 50 * 1024 * 1024: + raise HTTPException(413, "File too large (max 50 MB)") + staging = dd / "_uploads" + staging.mkdir(exist_ok=True) + staged = staging / name + staged.write_bytes(contents) + try: + from bincio.extract.ingest import ingest_parsed + from bincio.extract.parsers.factory import parse_file + activity = parse_file(staged) + activity_id_check = dd / "activities" / f"{activity.source_file}.json" + from bincio.extract.writer import make_activity_id + activity_id = make_activity_id(activity) + if (dd / "activities" / f"{activity_id}.json").exists(): + raise HTTPException(409, f"Activity already exists: {activity_id}") + ingest_parsed(activity, dd, privacy="public") + from bincio.render.merge import merge_all + merge_all(dd) + _trigger_rebuild(user.handle) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(422, f"Failed to process activity: {type(exc).__name__}: {exc}") + finally: + staged.unlink(missing_ok=True) + return JSONResponse({"ok": True, "id": activity_id}) + + @app.post("/api/strava/sync") async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = _require_user(bincio_session) diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index f85f3c8..b0be67a 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -23,6 +23,7 @@ let mounted = false; const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; + const editEnabled = editUrl !== '' || import.meta.env.PUBLIC_EDIT_ENABLED === 'true'; $: if (mounted) { const params = new URLSearchParams(window.location.search); @@ -94,7 +95,7 @@ >{tab.label} {/each} - {#if editUrl} + {#if editEnabled}