From 8ceb71476574c90968e890540e87f9e9f58f10f8 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 12:50:38 +0200 Subject: [PATCH] bulk upload --- bincio/serve/server.py | 72 ++++++++++++++++++++++--------------- site/src/layouts/Base.astro | 31 +++++++++------- 2 files changed, 62 insertions(+), 41 deletions(-) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 3e6e202..983a07d 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -465,43 +465,57 @@ _SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"} @app.post("/api/upload") async def upload_activity( - file: UploadFile = File(...), + files: list[UploadFile] = File(...), bincio_session: Optional[str] = Cookie(default=None), ) -> JSONResponse: + 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 + 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 + + results = [] + any_added = False + + for file in files: + 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: + results.append({"name": name, "ok": False, "error": f"Unsupported file type '{suffix}'"}) + continue + + contents = await file.read() + if len(contents) > 50 * 1024 * 1024: + results.append({"name": name, "ok": False, "error": "File too large (max 50 MB)"}) + continue + + staged = staging / name + staged.write_bytes(contents) + try: + activity = parse_file(staged) + activity_id = make_activity_id(activity) + if (dd / "activities" / f"{activity_id}.json").exists(): + results.append({"name": name, "ok": False, "error": "duplicate"}) + continue + ingest_parsed(activity, dd, privacy="public") + results.append({"name": name, "ok": True, "id": activity_id}) + any_added = True + except Exception as exc: + results.append({"name": name, "ok": False, "error": f"{type(exc).__name__}: {exc}"}) + finally: + staged.unlink(missing_ok=True) + + if any_added: 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}) + + added = [r for r in results if r["ok"]] + return JSONResponse({"ok": True, "added": len(added), "results": results}) @app.post("/api/strava/sync") diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 5411cff..679daf8 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -256,8 +256,8 @@ try { id="upload-drop" class="border-2 border-dashed border-zinc-700 rounded-lg p-8 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors" > -
Drop a FIT, GPX, or TCX file
or click to browse
- +
Drop FIT, GPX, or TCX files
or click to browse
+

@@ -437,24 +437,31 @@ try { e.preventDefault(); drop.style.borderColor = ''; drop.style.color = ''; - if (e.dataTransfer?.files[0]) doUpload(e.dataTransfer.files[0]); + if (e.dataTransfer?.files.length) doUpload(e.dataTransfer.files); }); - input.addEventListener('change', () => { if (input.files?.[0]) doUpload(input.files[0]); }); + input.addEventListener('change', () => { if (input.files?.length) doUpload(input.files); }); - async function doUpload(file) { - label.textContent = file.name; - fileStatus.textContent = 'Uploading…'; + async function doUpload(files) { + const n = files.length; + label.textContent = n === 1 ? files[0].name : `${n} files selected`; + fileStatus.textContent = `Uploading ${n} file${n > 1 ? 's' : ''}…`; fileStatus.style.color = 'var(--text-4)'; drop.style.pointerEvents = 'none'; const fd = new FormData(); - fd.append('file', file); + for (const f of files) fd.append('files', f); try { - const r = await fetch(`${editUrl}/api/upload`, { method: 'POST', body: fd }); + const r = await fetch(`${editUrl}/api/upload`, { method: 'POST', credentials: 'include', body: fd }); if (!r.ok) throw new Error(await r.text()); const d = await r.json(); - fileStatus.textContent = 'Done! Opening activity…'; - fileStatus.style.color = '#4ade80'; - setTimeout(() => { window.location.href = `${baseUrl}activity/${d.id}/`; }, 600); + const dupes = d.results.filter(r => r.error === 'duplicate').length; + const errors = d.results.filter(r => !r.ok && r.error !== 'duplicate').length; + let msg = `${d.added} added`; + if (dupes) msg += `, ${dupes} duplicate${dupes > 1 ? 's' : ''}`; + if (errors) msg += `, ${errors} failed`; + fileStatus.textContent = msg; + fileStatus.style.color = d.added > 0 ? '#4ade80' : '#a1a1aa'; + if (d.added > 0) setTimeout(() => { window.location.reload(); }, 1200); + else drop.style.pointerEvents = ''; } catch (e) { fileStatus.textContent = 'Error: ' + e.message; fileStatus.style.color = '#f87171';