diff --git a/CLAUDE.md b/CLAUDE.md index e320ced..14d9e97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -625,7 +625,7 @@ Not yet implemented — see friends/federation items in the checklist below. - [ ] Activity search / full-text filter in feed - [ ] Map thumbnail in activity cards (SVG path from GeoJSON) - [ ] GitHub Actions template for auto-publish -- [ ] **Ingestion: web file upload** — `POST /api/upload` in edit server, drag-and-drop in nav +- [x] **Ingestion: web file upload** — `POST /api/upload` in edit server, drag-and-drop in nav - [ ] **Ingestion: `bincio import strava`** — OAuth2 + streams API, idempotent incremental sync - [ ] **Ingestion: `bincio extract --watch`** — directory watcher for ongoing FIT sync - [ ] **Ingestion: `bincio import garmin`** — garminconnect library or FIT folder sync diff --git a/bincio/edit/server.py b/bincio/edit/server.py index 7bddb8d..b93c397 100644 --- a/bincio/edit/server.py +++ b/bincio/edit/server.py @@ -516,6 +516,72 @@ def _read_athlete_edits(data_dir: Path) -> dict: return {} +_SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"} + + +def _file_suffix(name: str) -> str: + """Return the effective suffix, including .gz double-extension.""" + p = Path(name.lower()) + if p.suffix == ".gz": + return p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz" + return p.suffix + + +@app.post("/api/upload") +async def upload_activity(file: UploadFile = File(...)) -> JSONResponse: + """Accept a FIT/GPX/TCX file, extract it, update index.json, and re-merge.""" + dd = _get_data_dir() + + name = file.filename or "upload.fit" + suffix = _file_suffix(name) + if suffix not in _SUPPORTED_SUFFIXES: + raise HTTPException(400, f"Unsupported file type '{Path(name).suffix}'. Expected FIT, GPX, or TCX.") + + staging = dd / "_uploads" + staging.mkdir(exist_ok=True) + staged = staging / name + staged.write_bytes(await file.read()) + + try: + from bincio.extract.metrics import compute + from bincio.extract.parsers.factory import parse_file + from bincio.extract.writer import build_summary, make_activity_id, write_activity, write_index + + activity = parse_file(staged) + metrics = compute(activity) + activity_id = make_activity_id(activity) + + existing_json = dd / "activities" / f"{activity_id}.json" + if existing_json.exists(): + raise HTTPException(409, f"Activity already exists: {activity_id}") + + write_activity(activity, metrics, dd, privacy="public", rdp_epsilon=0.0001) + summary = build_summary(activity, metrics, activity_id, "public") + + # Read current index to preserve owner + existing summaries + index_path = dd / "index.json" + if index_path.exists(): + index_data = json.loads(index_path.read_text(encoding="utf-8")) + else: + index_data = {"owner": {"handle": "unknown"}, "activities": []} + owner = index_data.get("owner", {}) + existing = {s["id"]: s for s in index_data.get("activities", [])} + existing[activity_id] = summary + write_index(list(existing.values()), dd, owner) + + from bincio.render.merge import merge_all + merge_all(dd) + + except HTTPException: + raise + except Exception as exc: + raise HTTPException(422, str(exc)) + finally: + staged.unlink(missing_ok=True) + + return JSONResponse({"ok": True, "id": activity_id}) + + @app.delete("/api/activity/{activity_id}/images/{filename}") async def delete_image(activity_id: str, filename: str) -> JSONResponse: dd = _get_data_dir() diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 4548def..7f09cf5 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -4,6 +4,7 @@ interface Props { description?: string; } const { title = 'BincioActivity', description = 'Your personal activity stats' } = Astro.props; +const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; --- @@ -103,7 +104,15 @@ const { title = 'BincioActivity', description = 'Your personal activity stats' } Stats Athlete -