From b781193d4496aca87876979245bddce14c187016 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Wed, 3 Jun 2026 10:32:02 +0200 Subject: [PATCH] feat: bulk delete + merge activities in feed - Select mode in ActivityFeed: toggle with Select button (logged-in only), cards become clickable with checkmark indicator, action bar fixed at bottom - Bulk delete: calls existing DELETE /api/activity/{id} for each selected, removes from local feed state immediately - Bulk merge: POST /api/merge sorts by started_at (earliest = primary), sums distance/duration/elevation, weighted-averages HR/power, concatenates geojson and timeseries; backs up originals to _merge_backup/ for recovery - GET /api/merges returns per-user hidden list; feed filters secondaries client-side on load so static shards don't need a rebuild to hide them - POST /api/unmerge/{id} restores primary from backup, unhides secondaries - ActivityDetail: shows "Merged (N)" badge + Unmerge button for owners - Fix: edit button now works from personal profile feed (handle was missing from year-shard activities; injected from filterHandle on sessionStorage write) --- bincio/serve/routers/merge.py | 270 ++++++++++++++++++++++ bincio/serve/server.py | 4 + site/src/components/ActivityDetail.svelte | 33 +++ site/src/components/ActivityFeed.svelte | 167 +++++++++++-- site/src/lib/types.ts | 2 + 5 files changed, 462 insertions(+), 14 deletions(-) create mode 100644 bincio/serve/routers/merge.py diff --git a/bincio/serve/routers/merge.py b/bincio/serve/routers/merge.py new file mode 100644 index 0000000..1168dd2 --- /dev/null +++ b/bincio/serve/routers/merge.py @@ -0,0 +1,270 @@ +"""Merge and unmerge activity endpoints.""" +from __future__ import annotations + +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from bincio.serve import deps, tasks +from bincio.serve.db import User + +router = APIRouter() + +_MERGES_FILE = "_merges.json" +_BACKUP_DIR = "_merge_backup" + + +def _read_merges(user_dir: Path) -> dict: + p = user_dir / _MERGES_FILE + if p.exists(): + try: + return json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + pass + return {"hidden": [], "merges": {}} + + +def _write_merges(user_dir: Path, data: dict) -> None: + (user_dir / _MERGES_FILE).write_text( + json.dumps(data, ensure_ascii=False), encoding="utf-8" + ) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +@router.get("/api/merges") +async def get_merges(user: User = Depends(deps._require_auth)) -> JSONResponse: + user_dir = deps._get_data_dir() / user.handle + return JSONResponse(_read_merges(user_dir)) + + +class MergeRequest(BaseModel): + activity_ids: list[str] + + +@router.post("/api/merge") +async def merge_activities( + body: MergeRequest, + user: User = Depends(deps._require_auth), +) -> JSONResponse: + if len(body.activity_ids) < 2: + raise HTTPException(400, "Need at least 2 activities to merge") + + dd = deps._get_data_dir() + user_dir = dd / user.handle + acts_dir = user_dir / "activities" + + activities: list[dict] = [] + for aid in body.activity_ids: + deps._check_id(aid) + p = acts_dir / f"{aid}.json" + if not p.exists(): + raise HTTPException(404, f"Activity {aid} not found") + try: + a = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + raise HTTPException(500, f"Could not read activity {aid}") + a["_id"] = aid + activities.append(a) + + sports = {a.get("sport") for a in activities} + if len(sports) > 1: + raise HTTPException(400, "Cannot merge activities of different sports") + + activities.sort(key=lambda a: a.get("started_at", "")) + primary = activities[0] + secondaries = activities[1:] + primary_id = primary["_id"] + secondary_ids = [a["_id"] for a in secondaries] + + def _sum(key: str) -> float | None: + vals = [a.get(key) for a in activities if a.get(key) is not None] + return sum(vals) if vals else None + + def _wavg(key: str) -> float | None: + pairs = [(a.get(key), a.get("moving_time_s")) for a in activities] + pairs = [(v, w) for v, w in pairs if v is not None and w and w > 0] + if not pairs: + return None + total_w = sum(w for _, w in pairs) + return sum(v * w for v, w in pairs) / total_w if total_w > 0 else None + + distance_m = _sum("distance_m") + duration_s = _sum("duration_s") + moving_time_s = _sum("moving_time_s") + elevation_gain_m = _sum("elevation_gain_m") + avg_speed_kmh = (distance_m / moving_time_s * 3.6) if distance_m and moving_time_s else None + avg_hr_bpm_val = _wavg("avg_hr_bpm") + avg_power_w_val = _wavg("avg_power_w") + + backup_dir = user_dir / _BACKUP_DIR + backup_dir.mkdir(exist_ok=True) + for suffix in (".json", ".geojson", ".timeseries.json"): + src = acts_dir / f"{primary_id}{suffix}" + if src.exists(): + shutil.copy2(src, backup_dir / f"{primary_id}{suffix}.bak") + + primary_data: dict[str, Any] = {k: v for k, v in primary.items() if not k.startswith("_")} + primary_data["distance_m"] = distance_m + primary_data["duration_s"] = duration_s + primary_data["moving_time_s"] = moving_time_s + primary_data["elevation_gain_m"] = elevation_gain_m + if avg_speed_kmh is not None: + primary_data["avg_speed_kmh"] = round(avg_speed_kmh, 3) + if avg_hr_bpm_val is not None: + primary_data["avg_hr_bpm"] = round(avg_hr_bpm_val) + if avg_power_w_val is not None: + primary_data["avg_power_w"] = round(avg_power_w_val) + primary_data["merged_ids"] = secondary_ids + + (acts_dir / f"{primary_id}.json").write_text( + json.dumps(primary_data, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + _merge_geojson(acts_dir, primary_id, secondary_ids) + _merge_timeseries(acts_dir, primary_id, secondary_ids, activities) + + merges = _read_merges(user_dir) + for sid in secondary_ids: + if sid not in merges["hidden"]: + merges["hidden"].append(sid) + merges.setdefault("merges", {})[primary_id] = { + "secondary_ids": secondary_ids, + "merged_at": _now_iso(), + } + _write_merges(user_dir, merges) + + from bincio.render.merge import merge_one + merge_one(user_dir, primary_id) + tasks._trigger_rebuild(user.handle) + + return JSONResponse({"ok": True, "primary_id": primary_id, "hidden": secondary_ids}) + + +@router.post("/api/unmerge/{primary_id}") +async def unmerge_activity( + primary_id: str, + user: User = Depends(deps._require_auth), +) -> JSONResponse: + deps._check_id(primary_id) + dd = deps._get_data_dir() + user_dir = dd / user.handle + acts_dir = user_dir / "activities" + backup_dir = user_dir / _BACKUP_DIR + + merges = _read_merges(user_dir) + merge_info = merges.get("merges", {}).get(primary_id) + if not merge_info: + raise HTTPException(404, "No merge record found for this activity") + + secondary_ids: list[str] = merge_info.get("secondary_ids", []) + + for suffix in (".json", ".geojson", ".timeseries.json"): + bak = backup_dir / f"{primary_id}{suffix}.bak" + if bak.exists(): + shutil.copy2(bak, acts_dir / f"{primary_id}{suffix}") + bak.unlink() + + merges["hidden"] = [h for h in merges.get("hidden", []) if h not in secondary_ids] + merges["merges"].pop(primary_id, None) + _write_merges(user_dir, merges) + + from bincio.render.merge import merge_one + merge_one(user_dir, primary_id) + tasks._trigger_rebuild(user.handle) + + return JSONResponse({"ok": True, "restored": secondary_ids}) + + +def _merge_geojson(acts_dir: Path, primary_id: str, secondary_ids: list[str]) -> None: + primary_path = acts_dir / f"{primary_id}.geojson" + if not primary_path.exists(): + return + try: + geo = json.loads(primary_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return + features: list = list(geo.get("features", [])) + for sid in secondary_ids: + p = acts_dir / f"{sid}.geojson" + if not p.exists(): + continue + try: + sec = json.loads(p.read_text(encoding="utf-8")) + features.extend(sec.get("features", [])) + except (OSError, json.JSONDecodeError): + continue + geo["features"] = features + primary_path.write_text(json.dumps(geo, ensure_ascii=False), encoding="utf-8") + + +def _merge_timeseries( + acts_dir: Path, + primary_id: str, + secondary_ids: list[str], + activities: list[dict], +) -> None: + primary_path = acts_dir / f"{primary_id}.timeseries.json" + if not primary_path.exists(): + return + try: + merged = json.loads(primary_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return + + merged = {k: list(v) if isinstance(v, list) else v for k, v in merged.items()} + + _ARRAY_KEYS = ("lat", "lon", "elevation_m", "speed_kmh", "hr_bpm", "cadence_rpm", "power_w", "temperature_c") + + for i, sid in enumerate(secondary_ids): + sec_path = acts_dir / f"{sid}.timeseries.json" + if not sec_path.exists(): + continue + try: + sec = json.loads(sec_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + + sec_t: list = sec.get("t") or [] + if not sec_t: + continue + + try: + p_start = datetime.fromisoformat( + activities[0].get("started_at", "").replace("Z", "+00:00") + ).timestamp() + s_start = datetime.fromisoformat( + activities[i + 1].get("started_at", "").replace("Z", "+00:00") + ).timestamp() + offset = s_start - p_start + except (ValueError, TypeError): + cur_t: list = merged.get("t") or [] + offset = (cur_t[-1] + 1) if cur_t else 0 + + adjusted_t = [offset + t for t in sec_t] + primary_len = len(merged.get("t") or []) + + if merged.get("t") is None: + merged["t"] = [] + merged["t"].extend(adjusted_t) + + for key in _ARRAY_KEYS: + sec_vals = sec.get(key) + if sec_vals is None: + if merged.get(key) is not None: + merged[key] = merged[key] + [None] * len(sec_t) + else: + if merged.get(key) is None: + merged[key] = [None] * primary_len + list(sec_vals) + else: + merged[key] = merged[key] + list(sec_vals) + + primary_path.write_text(json.dumps(merged, ensure_ascii=False), encoding="utf-8") diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 225289f..4c77417 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -20,12 +20,14 @@ from bincio.serve.routers import ( activities, admin, auth, + budget, download, feed, garmin, gear, ideas, me, + merge, ogimage, segments, strava, @@ -63,6 +65,8 @@ for _router in [ me.router, admin.router, activities.router, + merge.router, + budget.router, download.router, uploads.router, segments.router, diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte index d7198ca..afd2bac 100644 --- a/site/src/components/ActivityDetail.svelte +++ b/site/src/components/ActivityDetail.svelte @@ -36,6 +36,25 @@ pr_elapsed_s: number; } let segmentEfforts: SegmentEffortHit[] = []; + let unmergeWorking = false; + + async function _unmerge() { + if (!confirm('Unmerge this activity? The original tracks will be restored and the secondary activities will reappear in your feed.')) return; + unmergeWorking = true; + try { + const r = await fetch(`/api/unmerge/${activity.id}`, { method: 'POST', credentials: 'include' }); + if (r.ok) { + window.location.reload(); + } else { + const d = await r.json(); + alert(d.detail ?? 'Unmerge failed'); + } + } catch { + alert('Unmerge failed'); + } finally { + unmergeWorking = false; + } + } // Local overrides applied immediately after a save (no re-fetch needed) let localTitle = ''; @@ -381,6 +400,20 @@ class="text-xs px-2 py-0.5 rounded border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors shrink-0" >+ segment {/if} + {#if detail?.merged_ids?.length} + + Merged ({detail.merged_ids.length}) + + {#if editEnabled} + + {/if} + {/if} {#if descriptionHtml}
diff --git a/site/src/components/ActivityFeed.svelte b/site/src/components/ActivityFeed.svelte index f2bb142..f27af16 100644 --- a/site/src/components/ActivityFeed.svelte +++ b/site/src/components/ActivityFeed.svelte @@ -59,6 +59,73 @@ /** Logged-in handle — resolved async via bincio:me event. */ let me: string = ''; + // ── Bulk / select mode ──────────────────────────────────────────────────── + let selectMode = false; + let selected: Set = new Set(); + let hiddenIds: Set = new Set(); + let bulkWorking = false; + + async function _loadMerges() { + try { + const r = await fetch('/api/merges', { credentials: 'include' }); + if (r.ok) { + const data = await r.json(); + hiddenIds = new Set(data.hidden ?? []); + } + } catch { /* non-critical */ } + } + + function _toggleSelect(id: string) { + const s = new Set(selected); + s.has(id) ? s.delete(id) : s.add(id); + selected = s; + } + + function _exitSelect() { + selectMode = false; + selected = new Set(); + } + + async function _deleteSelected() { + const ids = [...selected]; + if (!confirm(`Delete ${ids.length} activit${ids.length === 1 ? 'y' : 'ies'}? This cannot be undone.`)) return; + bulkWorking = true; + for (const id of ids) { + try { + const r = await fetch(`/api/activity/${id}`, { method: 'DELETE', credentials: 'include' }); + if (r.ok) all = all.filter(a => a.id !== id); + } catch { /* continue */ } + } + bulkWorking = false; + _exitSelect(); + } + + async function _mergeSelected() { + const ids = [...selected]; + if (ids.length < 2) return; + bulkWorking = true; + try { + const r = await fetch('/api/merge', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ activity_ids: ids }), + }); + const data = await r.json(); + if (r.ok) { + for (const hid of data.hidden ?? []) hiddenIds.add(hid); + hiddenIds = new Set(hiddenIds); + _exitSelect(); + } else { + alert(data.detail ?? 'Merge failed'); + } + } catch { + alert('Merge failed'); + } finally { + bulkWorking = false; + } + } + function computeDateRange(preset: string): { dateFrom: string; dateTo: string } { if (preset === 'all') return { dateFrom: '', dateTo: '' }; if (/^\d{4}$/.test(preset)) { @@ -74,11 +141,14 @@ return { dateFrom: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T`, dateTo: '' }; } + // Filter out secondary activities hidden by a merge. + $: withMerge = all.filter(a => !hiddenIds.has(a.id)); + // Show private activities only to their owner. // On a profile page (filterHandle set): show unlisted if me === filterHandle. // On the global feed: show unlisted only for the logged-in user's own activities. $: isOwner = filterHandle !== '' && me === filterHandle; - $: withPrivacy = all.filter(a => { + $: withPrivacy = withMerge.filter(a => { if (isUnlisted(a.privacy)) { return filterHandle ? isOwner : (me !== '' && (a as any).handle === me); } @@ -196,8 +266,12 @@ // Resolve the logged-in handle so we can show the owner their private activities. if ((window as any).__bincioMe !== undefined) { me = (window as any).__bincioMe; + _loadMerges(); } else { - window.addEventListener('bincio:me', (e: Event) => { me = (e as CustomEvent).detail; }, { once: true }); + window.addEventListener('bincio:me', (e: Event) => { + me = (e as CustomEvent).detail; + _loadMerges(); + }, { once: true }); } try { @@ -287,6 +361,17 @@ on:click={() => viewMode = 'map'} >Map
+ {#if me} + + {/if} @@ -368,8 +453,15 @@ {:else}
{#each visible as a (a.id)} + {@const isSelected = selected.has(a.id)} -
+ + +
_toggleSelect(a.id) : undefined} + >
- - {sportIcon(a.sport)} {sportLabel(a.sport, a.sub_sport)} - + {#if selectMode} +
+ {#if isSelected}{/if} +
+ {:else} + + {sportIcon(a.sport)} {sportLabel(a.sport, a.sub_sport)} + + {/if}
@@ -466,3 +573,35 @@
{/if} {/if} + + +{#if selectMode} +
+ + {selected.size} selected + + + + + {#if bulkWorking} + Working… + {/if} +
+{/if} diff --git a/site/src/lib/types.ts b/site/src/lib/types.ts index 62ec87e..091a021 100644 --- a/site/src/lib/types.ts +++ b/site/src/lib/types.ts @@ -130,6 +130,8 @@ export interface ActivityDetail extends Omit; + /** IDs of secondary activities merged into this one (set by /api/merge). */ + merged_ids?: string[] | null; } export interface Lap {