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)
This commit is contained in:
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user