Files
Davide Scaini b781193d44 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)
2026-06-03 10:32:02 +02:00

271 lines
9.0 KiB
Python

"""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")