2ec4d9157c
The 285-line _HTML string literal in edit/server.py is replaced by a template file loaded at request time. The route handler is unchanged in behaviour — it still substitutes __SITE_URL__, __SPORT_OPTIONS__, and __STAT_CHECKBOXES__ before returning the response. Five new tests cover: 200 response, form presence, site_url injection, no unresolved placeholders, and template file existence on disk.
649 lines
25 KiB
Python
649 lines
25 KiB
Python
"""FastAPI edit server — serves the activity edit UI and writes sidecar .md files."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import secrets
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
|
|
|
|
from bincio.edit.ops import SPORTS, STAT_PANELS, VALID_ACTIVITY_ID
|
|
|
|
# Populated by the CLI before uvicorn starts
|
|
data_dir: Path | None = None
|
|
site_url: str = "http://localhost:4321"
|
|
strava_client_id: str = ""
|
|
strava_client_secret: str = ""
|
|
dem_url: str = "https://api.open-elevation.com" # Open-Elevation-compatible API base URL
|
|
|
|
# In-memory CSRF state tokens for OAuth flows (token → True); cleared after use
|
|
_oauth_states: set[str] = set()
|
|
|
|
app = FastAPI(title="BincioActivity Edit Server", docs_url=None, redoc_url=None)
|
|
|
|
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
|
# Allow localhost origins only — this server is never meant to be public
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origin_regex=r"https?://localhost(:\d+)?",
|
|
allow_methods=["GET", "POST", "DELETE"],
|
|
allow_headers=["Content-Type"],
|
|
)
|
|
|
|
def _check_id(activity_id: str) -> str:
|
|
"""Reject activity IDs that contain path traversal sequences."""
|
|
if not VALID_ACTIVITY_ID.match(activity_id):
|
|
raise HTTPException(400, "Invalid activity ID")
|
|
return activity_id
|
|
|
|
|
|
from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES
|
|
from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES
|
|
from bincio.shared.images import unique_image_name as _unique_image_name
|
|
|
|
|
|
_TEMPLATE_PATH = Path(__file__).parent / "templates" / "edit.html"
|
|
|
|
# ── Routes ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _get_data_dir() -> Path:
|
|
if data_dir is None:
|
|
raise HTTPException(500, "Edit server not configured (data_dir is None)")
|
|
return data_dir
|
|
|
|
|
|
@app.get("/")
|
|
async def root() -> RedirectResponse:
|
|
return RedirectResponse(url=site_url)
|
|
|
|
|
|
@app.get("/edit/{activity_id}", response_class=HTMLResponse)
|
|
async def edit_page(activity_id: str) -> str:
|
|
sport_opts = "\n".join(
|
|
f'<option value="{s}">{s.capitalize()}</option>' for s in SPORTS
|
|
)
|
|
stat_cbs = "\n".join(
|
|
f'<label class="check-item"><input type="checkbox" data-stat="{s}"> {s.replace("_", " ").capitalize()}</label>'
|
|
for s in STAT_PANELS
|
|
)
|
|
return (
|
|
_TEMPLATE_PATH.read_text(encoding="utf-8")
|
|
.replace("__SITE_URL__", site_url)
|
|
.replace("__SPORT_OPTIONS__", sport_opts)
|
|
.replace("__STAT_CHECKBOXES__", stat_cbs)
|
|
)
|
|
|
|
|
|
@app.get("/api/activity/{activity_id}")
|
|
async def get_activity(activity_id: str) -> JSONResponse:
|
|
dd = _get_data_dir()
|
|
_check_id(activity_id)
|
|
json_path = dd / "activities" / f"{activity_id}.json"
|
|
if not json_path.exists():
|
|
raise HTTPException(404, f"Activity {activity_id!r} not found")
|
|
|
|
detail: dict[str, Any] = json.loads(json_path.read_text(encoding="utf-8"))
|
|
|
|
# Read existing sidecar if any — these are the "user" values shown in the form
|
|
from bincio.render.merge import parse_sidecar
|
|
sidecar_path = dd / "edits" / f"{activity_id}.md"
|
|
fm: dict = {}
|
|
body = ""
|
|
if sidecar_path.exists():
|
|
fm, body = parse_sidecar(sidecar_path)
|
|
|
|
# Existing uploaded images for this activity
|
|
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({
|
|
"id": activity_id,
|
|
"started_at": detail.get("started_at", ""),
|
|
"title": fm.get("title", detail.get("title", "")),
|
|
"sport": fm.get("sport", detail.get("sport", "other")),
|
|
"gear": fm.get("gear", detail.get("gear") or ""),
|
|
"description": body or fm.get("description") or detail.get("description") or "",
|
|
"highlight": fm.get("highlight", detail.get("custom", {}).get("highlight", False)),
|
|
"private": fm.get("private", detail.get("privacy") in ("private", "unlisted")),
|
|
"hide_stats": fm.get("hide_stats", detail.get("custom", {}).get("hide_stats", [])),
|
|
"images": images,
|
|
})
|
|
|
|
|
|
@app.post("/api/activity/{activity_id}")
|
|
async def save_activity(activity_id: str, payload: dict[str, Any]) -> JSONResponse:
|
|
dd = _get_data_dir()
|
|
_check_id(activity_id)
|
|
if not (dd / "activities" / f"{activity_id}.json").exists():
|
|
raise HTTPException(404, f"Activity {activity_id!r} not found")
|
|
|
|
from bincio.edit.ops import apply_sidecar_edit
|
|
apply_sidecar_edit(activity_id, payload, dd)
|
|
|
|
sidecar_path = dd / "edits" / f"{activity_id}.md"
|
|
return JSONResponse({"ok": True, "sidecar": str(sidecar_path)})
|
|
|
|
|
|
@app.post("/api/activity/{activity_id}/recalculate-elevation/dem")
|
|
async def recalculate_elevation_dem_endpoint(activity_id: str) -> JSONResponse:
|
|
"""Replace GPS altitude with DEM terrain elevation and recompute gain/loss.
|
|
|
|
Requires --dem-url to be set when starting bincio edit.
|
|
"""
|
|
if not dem_url:
|
|
raise HTTPException(503, "DEM URL not configured.")
|
|
dd = _get_data_dir()
|
|
_check_id(activity_id)
|
|
try:
|
|
from bincio.extract.dem import recalculate_elevation
|
|
from bincio.render.merge import merge_one
|
|
result = recalculate_elevation(dd, activity_id, dem_url)
|
|
merge_one(dd, activity_id)
|
|
return JSONResponse(result)
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(404, str(e))
|
|
except ValueError as e:
|
|
raise HTTPException(422, str(e))
|
|
|
|
|
|
@app.post("/api/activity/{activity_id}/recalculate-elevation/hysteresis")
|
|
async def recalculate_elevation_hysteresis_endpoint(activity_id: str) -> JSONResponse:
|
|
"""Recompute gain/loss from original recorded elevation using source-aware hysteresis."""
|
|
dd = _get_data_dir()
|
|
_check_id(activity_id)
|
|
try:
|
|
from bincio.extract.dem import recalculate_elevation_hysteresis
|
|
from bincio.render.merge import merge_one
|
|
result = recalculate_elevation_hysteresis(dd, activity_id)
|
|
merge_one(dd, activity_id)
|
|
return JSONResponse(result)
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(404, str(e))
|
|
except ValueError as e:
|
|
raise HTTPException(422, str(e))
|
|
|
|
|
|
@app.get("/api/wheel/version")
|
|
async def wheel_version() -> JSONResponse:
|
|
"""Public endpoint: current bincio wheel version for mobile app update checks."""
|
|
import importlib.metadata
|
|
try:
|
|
version = importlib.metadata.version("bincio")
|
|
except importlib.metadata.PackageNotFoundError:
|
|
version = "0.1.0"
|
|
return JSONResponse({
|
|
"version": version,
|
|
"url": f"/bincio-{version}-py3-none-any.whl",
|
|
"api_url": "/api/wheel/download",
|
|
})
|
|
|
|
|
|
@app.get("/api/wheel/download")
|
|
async def wheel_download() -> FileResponse:
|
|
"""Serve the bincio wheel directly (used locally; in prod nginx serves /bincio-*.whl)."""
|
|
import importlib.metadata
|
|
try:
|
|
version = importlib.metadata.version("bincio")
|
|
except importlib.metadata.PackageNotFoundError:
|
|
version = "0.1.0"
|
|
wheel_name = f"bincio-{version}-py3-none-any.whl"
|
|
dist_dir = Path(__file__).parent.parent.parent / "dist"
|
|
wheel_path = dist_dir / wheel_name
|
|
if not wheel_path.exists():
|
|
raise HTTPException(status_code=404, detail=f"{wheel_name} not found in dist/")
|
|
return FileResponse(wheel_path, media_type="application/zip", filename=wheel_name)
|
|
|
|
|
|
@app.post("/api/activity/{activity_id}/images")
|
|
async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONResponse:
|
|
dd = _get_data_dir()
|
|
_check_id(activity_id)
|
|
if not (dd / "activities" / f"{activity_id}.json").exists():
|
|
raise HTTPException(404, f"Activity {activity_id!r} not found")
|
|
if not file.filename:
|
|
raise HTTPException(400, "No filename")
|
|
|
|
images_dir = dd / "edits" / "images" / activity_id
|
|
images_dir.mkdir(parents=True, exist_ok=True)
|
|
ct = file.content_type or ""
|
|
if ct not in _ALLOWED_IMAGE_TYPES:
|
|
raise HTTPException(400, "Only JPEG, PNG, WebP, or GIF images are accepted")
|
|
contents = await file.read()
|
|
if len(contents) > _MAX_IMAGE_BYTES:
|
|
raise HTTPException(413, f"Image too large (max {_MAX_IMAGE_BYTES // (1024 * 1024)} MB)")
|
|
safe_name = _unique_image_name(images_dir, Path(file.filename).name)
|
|
(images_dir / safe_name).write_bytes(contents)
|
|
return JSONResponse({"ok": True, "filename": safe_name})
|
|
|
|
|
|
@app.get("/api/athlete")
|
|
async def get_athlete() -> JSONResponse:
|
|
dd = _get_data_dir()
|
|
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 overrides on top
|
|
overrides = _read_athlete_edits(dd)
|
|
for key in ("max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"):
|
|
if key in overrides:
|
|
data[key] = overrides[key]
|
|
|
|
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(payload: dict[str, Any]) -> JSONResponse:
|
|
dd = _get_data_dir()
|
|
athlete_path = dd / "athlete.json"
|
|
if not athlete_path.exists():
|
|
raise HTTPException(404, "athlete.json not found — run bincio extract first")
|
|
|
|
# Write edits/athlete.yaml with validated fields
|
|
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",
|
|
)
|
|
|
|
# Re-merge — merge_all() applies edits/athlete.yaml on top of athlete.json
|
|
from bincio.render.merge import merge_all
|
|
merge_all(dd)
|
|
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
def _read_athlete_edits(data_dir: Path) -> dict:
|
|
path = data_dir / "edits" / "athlete.yaml"
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
import yaml
|
|
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
except Exception:
|
|
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(
|
|
files: list[UploadFile] = File(...),
|
|
store_original: bool = Form(False),
|
|
) -> StreamingResponse:
|
|
"""Accept FIT/GPX/TCX files and/or activities.csv; stream SSE progress while processing.
|
|
|
|
activities.csv (Strava export format) can be included in the batch to:
|
|
- Enrich activity files in the same batch (matched by filename)
|
|
- Retroactively update sidecars for existing activities (matched by strava_id)
|
|
"""
|
|
from bincio.extract.ingest import ingest_parsed
|
|
from bincio.extract.parsers.factory import parse_file
|
|
from bincio.extract.writer import make_activity_id
|
|
from bincio.render.merge import merge_all
|
|
|
|
dd = _get_data_dir()
|
|
staging = dd / "_uploads"
|
|
staging.mkdir(exist_ok=True)
|
|
|
|
# Read all files into memory now (async), then process synchronously in the generator
|
|
csv_bytes_list: list[bytes] = []
|
|
activity_items: list[tuple[str, bytes]] = []
|
|
|
|
for f in files:
|
|
fname = Path(f.filename or "").name
|
|
raw = await f.read()
|
|
if fname.lower().endswith(".csv"):
|
|
csv_bytes_list.append(raw)
|
|
else:
|
|
activity_items.append((fname, raw))
|
|
|
|
# Build metadata from the first CSV found (activities.csv from Strava export)
|
|
metadata = None
|
|
if csv_bytes_list:
|
|
from bincio.extract.strava_csv import StravaMetadata
|
|
import tempfile
|
|
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp:
|
|
tmp.write(csv_bytes_list[0])
|
|
tmp_path = Path(tmp.name)
|
|
try:
|
|
metadata = StravaMetadata(tmp_path)
|
|
finally:
|
|
tmp_path.unlink(missing_ok=True)
|
|
|
|
total_files = len(activity_items)
|
|
|
|
def event_stream():
|
|
added = 0
|
|
duplicates = 0
|
|
errors = 0
|
|
any_added = False
|
|
|
|
for n, (name, contents) in enumerate(activity_items, 1):
|
|
suffix = _file_suffix(name)
|
|
if suffix not in _SUPPORTED_SUFFIXES:
|
|
errors += 1
|
|
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'unsupported type'})}\n\n"
|
|
continue
|
|
|
|
if len(contents) > _MAX_UPLOAD_BYTES:
|
|
errors += 1
|
|
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'file too large'})}\n\n"
|
|
continue
|
|
|
|
staged = staging / name
|
|
staged.write_bytes(contents)
|
|
kept = False
|
|
try:
|
|
activity = parse_file(staged)
|
|
if metadata is not None:
|
|
metadata.enrich(name, activity)
|
|
activity_id = make_activity_id(activity)
|
|
if (dd / "activities" / f"{activity_id}.json").exists():
|
|
duplicates += 1
|
|
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'duplicate'})}\n\n"
|
|
continue
|
|
ingest_parsed(activity, dd, privacy="public")
|
|
if store_original:
|
|
originals_dir = dd / "originals"
|
|
originals_dir.mkdir(exist_ok=True)
|
|
staged.rename(originals_dir / name)
|
|
kept = True
|
|
added += 1
|
|
any_added = True
|
|
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'imported'})}\n\n"
|
|
except Exception:
|
|
errors += 1
|
|
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error'})}\n\n"
|
|
finally:
|
|
if not kept:
|
|
staged.unlink(missing_ok=True)
|
|
|
|
csv_updates = 0
|
|
if metadata is not None:
|
|
from bincio.extract.strava_csv import apply_csv_to_data_dir
|
|
csv_updates = apply_csv_to_data_dir(dd, metadata)
|
|
if csv_updates:
|
|
yield f"data: {json.dumps({'type': 'csv', 'updates': csv_updates})}\n\n"
|
|
|
|
if any_added or csv_updates:
|
|
merge_all(dd)
|
|
|
|
yield f"data: {json.dumps({'type': 'done', 'added': added, 'csv_updates': csv_updates, 'duplicates': duplicates, 'errors': errors})}\n\n"
|
|
|
|
return StreamingResponse(
|
|
event_stream(),
|
|
media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
)
|
|
|
|
|
|
@app.post("/api/import-bas")
|
|
async def import_bas(payload: dict[str, Any]) -> JSONResponse:
|
|
"""Accept a pre-converted BAS detail JSON (from the /convert/ page) and save it."""
|
|
dd = _get_data_dir()
|
|
detail = payload.get("detail")
|
|
geojson = payload.get("geojson")
|
|
|
|
if not isinstance(detail, dict) or not detail.get("id"):
|
|
raise HTTPException(400, "Missing or invalid 'detail' field")
|
|
|
|
activity_id = detail["id"]
|
|
_check_id(activity_id)
|
|
|
|
acts_dir = dd / "activities"
|
|
acts_dir.mkdir(exist_ok=True)
|
|
|
|
dest = acts_dir / f"{activity_id}.json"
|
|
if dest.exists():
|
|
raise HTTPException(409, f"Activity already exists: {activity_id}")
|
|
|
|
dest.write_text(json.dumps(detail, indent=2, ensure_ascii=False))
|
|
|
|
if geojson:
|
|
(acts_dir / f"{activity_id}.geojson").write_text(
|
|
json.dumps(geojson, indent=2, ensure_ascii=False)
|
|
)
|
|
|
|
# Rebuild index
|
|
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", [])}
|
|
|
|
# Build a minimal summary from the detail
|
|
summary_keys = [
|
|
"id", "title", "sport", "sub_sport", "started_at", "distance_m",
|
|
"duration_s", "moving_time_s", "elevation_gain_m", "avg_speed_kmh",
|
|
"avg_hr_bpm", "avg_cadence_rpm", "avg_power_w", "privacy",
|
|
"detail_url", "track_url", "preview_coords", "highlight", "duplicate_of",
|
|
]
|
|
summary = {k: detail[k] for k in summary_keys if k in detail}
|
|
existing[activity_id] = summary
|
|
|
|
from bincio.extract.writer import write_index
|
|
write_index(list(existing.values()), dd, owner)
|
|
|
|
from bincio.render.merge import merge_all
|
|
merge_all(dd)
|
|
|
|
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()
|
|
_check_id(activity_id)
|
|
safe_name = Path(filename).name # strip any path traversal
|
|
if not safe_name:
|
|
raise HTTPException(400, "Invalid filename")
|
|
target = dd / "edits" / "images" / activity_id / safe_name
|
|
if target.exists() and target.is_file():
|
|
target.unlink()
|
|
# Remove empty parent dir
|
|
if not any(target.parent.iterdir()):
|
|
shutil.rmtree(target.parent)
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
# ── Strava sync ───────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/strava/status")
|
|
async def strava_status() -> JSONResponse:
|
|
"""Return whether Strava is configured and whether a token is stored."""
|
|
dd = _get_data_dir()
|
|
from bincio.extract.strava_api import load_token
|
|
token = load_token(dd)
|
|
return JSONResponse({
|
|
"configured": bool(strava_client_id),
|
|
"connected": token is not None,
|
|
"last_sync": token.get("last_sync_at") if token else None,
|
|
})
|
|
|
|
|
|
@app.get("/api/strava/auth-url")
|
|
async def strava_auth_url(request: Request) -> JSONResponse:
|
|
"""Return the Strava OAuth URL the browser should open."""
|
|
if not strava_client_id:
|
|
raise HTTPException(400, "Strava client ID not configured. Pass --strava-client-id to bincio edit.")
|
|
state = secrets.token_urlsafe(16)
|
|
_oauth_states.add(state)
|
|
redirect_uri = str(request.url_for("strava_callback"))
|
|
from bincio.extract.strava_api import auth_url
|
|
return JSONResponse({"url": auth_url(strava_client_id, redirect_uri, state=state)})
|
|
|
|
|
|
@app.get("/api/strava/callback", name="strava_callback")
|
|
async def strava_callback(code: str = "", error: str = "", state: str = "") -> RedirectResponse:
|
|
"""Strava OAuth callback — exchange code for token then redirect to the site."""
|
|
if error or not code:
|
|
return RedirectResponse(f"{site_url}?strava=error")
|
|
if state not in _oauth_states:
|
|
return RedirectResponse(f"{site_url}?strava=error")
|
|
_oauth_states.discard(state)
|
|
if not strava_client_id or not strava_client_secret:
|
|
return RedirectResponse(f"{site_url}?strava=error")
|
|
dd = _get_data_dir()
|
|
from bincio.extract.strava_api import StravaError, exchange_code, save_token
|
|
try:
|
|
token = exchange_code(strava_client_id, strava_client_secret, code)
|
|
except StravaError:
|
|
return RedirectResponse(f"{site_url}?strava=error")
|
|
save_token(dd, token)
|
|
return RedirectResponse(f"{site_url}?strava=connected")
|
|
|
|
|
|
@app.post("/api/strava/sync")
|
|
async def strava_sync() -> JSONResponse:
|
|
"""Fetch new Strava activities since last sync and add them to the data store."""
|
|
if not strava_client_id or not strava_client_secret:
|
|
raise HTTPException(400, "Strava not configured. Pass --strava-client-id and --strava-client-secret to bincio edit.")
|
|
dd = _get_data_dir()
|
|
from bincio.edit.ops import run_strava_sync
|
|
try:
|
|
result = run_strava_sync(dd, strava_client_id, strava_client_secret)
|
|
except RuntimeError as e:
|
|
raise HTTPException(502, str(e))
|
|
return JSONResponse(result)
|
|
|
|
|
|
@app.post("/api/strava/reset")
|
|
async def strava_reset(request: Request) -> JSONResponse:
|
|
"""Reset last_sync_at.
|
|
|
|
mode=soft — set to the started_at of the most recent activity already on disk
|
|
(next sync only fetches activities newer than the last known one)
|
|
mode=hard — clear last_sync_at entirely
|
|
(next sync re-downloads the full Strava history, skipping existing files)
|
|
"""
|
|
dd = _get_data_dir()
|
|
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 in the current index
|
|
from datetime import datetime, timezone
|
|
index_path = dd / "index.json"
|
|
last_ts: int | None = None
|
|
if index_path.exists():
|
|
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())
|
|
|
|
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})
|
|
|
|
|
|
@app.post("/api/upload/strava-zip")
|
|
async def upload_strava_zip(
|
|
file: UploadFile = File(...),
|
|
private: str = Form(default="false"),
|
|
) -> StreamingResponse:
|
|
"""Accept a Strava bulk export ZIP and stream SSE progress while processing.
|
|
|
|
The ZIP is written to a temp file, processed activity-by-activity, then deleted.
|
|
Originals are never kept — the UI informs the user of this upfront.
|
|
"""
|
|
if not file.filename or not file.filename.lower().endswith(".zip"):
|
|
raise HTTPException(400, "Please upload a .zip file")
|
|
|
|
privacy = "private" if private.lower() in ("true", "1", "yes") else "public"
|
|
|
|
dd = _get_data_dir()
|
|
import tempfile
|
|
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False, dir=dd)
|
|
zip_path = Path(tmp.name)
|
|
try:
|
|
while chunk := await file.read(1024 * 1024): # 1 MB chunks
|
|
tmp.write(chunk)
|
|
finally:
|
|
tmp.close()
|
|
|
|
from bincio.extract.strava_zip import strava_zip_iter
|
|
from bincio.render.merge import merge_all
|
|
|
|
def event_stream():
|
|
any_imported = False
|
|
try:
|
|
for event in strava_zip_iter(zip_path, dd, privacy=privacy):
|
|
yield f"data: {json.dumps(event)}\n\n"
|
|
if event.get("type") == "progress" and event.get("status") == "imported":
|
|
any_imported = True
|
|
if event.get("type") == "done" and any_imported:
|
|
merge_all(dd)
|
|
except Exception as exc:
|
|
zip_path.unlink(missing_ok=True)
|
|
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"},
|
|
)
|