8118f6f316
- writer.py: timeseries is now written to {id}.timeseries.json as a separate file. The detail JSON gets a timeseries_url field instead. finalize_pending and cleanup_pending handle the extra file.
- merge.py (merge_one): symlinks the .timeseries.json file alongside the detail JSON. merge_all already handles it transparently (the .timeseries.json stem doesn't match any activity
ID in to_merge, so it falls through to the symlink branch).
- types.ts: timeseries is now timeseries?: Timeseries | null, and timeseries_url?: string | null added.
- dataloader.ts: new loadTimeseries(url, detailUrl, base) function that resolves paths correctly in both single- and multi-user modes (uses the fetched detail URL's directory as the base).
- ActivityDetail.svelte: loads timeseries separately after detail loads; uses detail.timeseries for IDB activities (embedded) or fetches via detail.timeseries_url for server activities. Charts show a pulse placeholder while loading.
2 — GZip
- GZipMiddleware (min 1 KB) added to both bincio/serve/server.py and bincio/edit/server.py — all API JSON responses are now gzip-compressed.
- For static files (the big timeseries JSONs), nginx should be configured with gzip on; gzip_types application/json application/geo+json; — no code change needed on the server side.
Net effect: opening an activity page now fetches ~1.4 KB (detail) instead of ~586 KB. The timeseries fetches ~60–150 KB gzip-compressed shortly after (it loads concurrently with the map rendering).
279 lines
11 KiB
Python
279 lines
11 KiB
Python
"""Apply sidecar .md edits to BAS JSON files.
|
|
|
|
Produces data_dir/_merged/ — a mirror of data_dir where:
|
|
- Files without sidecars are symlinked to the originals (cheap, preserves extracted data)
|
|
- Files with sidecars are written as merged copies
|
|
- index.json is rewritten with private filtering + highlight sort
|
|
|
|
This keeps data_dir/activities/*.json pristine (re-running extract never clobbers
|
|
user edits, and removing a sidecar always reverts fully on the next render).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
|
|
def parse_sidecar(path: Path) -> tuple[dict, str]:
|
|
"""Return (frontmatter_dict, markdown_body) from a sidecar .md file."""
|
|
text = path.read_text(encoding="utf-8")
|
|
if text.startswith("---"):
|
|
parts = re.split(r"^---[ \t]*$", text, maxsplit=2, flags=re.MULTILINE)
|
|
if len(parts) >= 3:
|
|
fm = yaml.safe_load(parts[1]) or {}
|
|
return fm, parts[2].strip()
|
|
return {}, text.strip()
|
|
|
|
|
|
def apply_sidecar(detail: dict, fm: dict, body: str) -> dict:
|
|
"""Apply sidecar overrides to a detail JSON dict. Returns a modified copy."""
|
|
d = dict(detail)
|
|
d.setdefault("custom", {})
|
|
d["custom"] = dict(d["custom"]) # don't mutate original
|
|
|
|
if "title" in fm:
|
|
d["title"] = str(fm["title"])
|
|
if "sport" in fm:
|
|
d["sport"] = str(fm["sport"])
|
|
if "gear" in fm:
|
|
d["gear"] = str(fm["gear"]) if fm["gear"] else d.get("gear")
|
|
if body:
|
|
d["description"] = body
|
|
elif "description" in fm:
|
|
d["description"] = str(fm["description"])
|
|
if "highlight" in fm:
|
|
d["custom"]["highlight"] = bool(fm["highlight"])
|
|
if "private" in fm:
|
|
d["privacy"] = "private" if fm["private"] else detail.get("privacy", "public")
|
|
if "hide_stats" in fm:
|
|
d["custom"]["hide_stats"] = [str(s) for s in (fm["hide_stats"] or [])]
|
|
|
|
return d
|
|
|
|
|
|
def _apply_sidecar_summary(summary: dict, fm: dict) -> dict:
|
|
"""Apply sidecar overrides to an index summary entry."""
|
|
s = dict(summary)
|
|
s.setdefault("custom", {})
|
|
s["custom"] = dict(s["custom"])
|
|
|
|
if "title" in fm:
|
|
s["title"] = str(fm["title"])
|
|
if "sport" in fm:
|
|
s["sport"] = str(fm["sport"])
|
|
if "highlight" in fm:
|
|
s["custom"]["highlight"] = bool(fm["highlight"])
|
|
if "private" in fm:
|
|
s["privacy"] = "private" if fm["private"] else summary.get("privacy", "public")
|
|
|
|
return s
|
|
|
|
|
|
def merge_one(data_dir: Path, activity_id: str) -> None:
|
|
"""Apply (or remove) sidecar overrides for a single activity.
|
|
|
|
Updates data_dir/_merged/activities/{id}.json and rewrites
|
|
_merged/index.json. Faster than merge_all() for interactive edits
|
|
because it touches only one activity file instead of rebuilding the
|
|
whole _merged/activities/ directory.
|
|
|
|
Use merge_all() for bulk operations (first run, Strava sync, etc.).
|
|
"""
|
|
edits_dir = data_dir / "edits"
|
|
acts_dir = data_dir / "activities"
|
|
merged_dir = data_dir / "_merged"
|
|
merged_acts = merged_dir / "activities"
|
|
merged_acts.mkdir(parents=True, exist_ok=True)
|
|
|
|
src = acts_dir / f"{activity_id}.json"
|
|
if not src.exists():
|
|
return
|
|
|
|
dest = merged_acts / f"{activity_id}.json"
|
|
|
|
# Determine if a sidecar or image list applies to this activity
|
|
sidecar_path = edits_dir / f"{activity_id}.md" if edits_dir.exists() else None
|
|
images_dir = edits_dir / "images" / activity_id if edits_dir.exists() else None
|
|
has_sidecar = sidecar_path is not None and sidecar_path.exists()
|
|
image_files: list[str] = []
|
|
if images_dir and images_dir.exists():
|
|
image_files = sorted(
|
|
p.name for p in images_dir.iterdir()
|
|
if p.is_file() and not p.name.startswith(".")
|
|
)
|
|
|
|
needs_merge = has_sidecar or bool(image_files)
|
|
|
|
# Symlink the timeseries file (never merged — always points to the extract output)
|
|
ts_src = acts_dir / f"{activity_id}.timeseries.json"
|
|
ts_dest = merged_acts / f"{activity_id}.timeseries.json"
|
|
if ts_dest.exists() or ts_dest.is_symlink():
|
|
ts_dest.unlink()
|
|
if ts_src.exists():
|
|
ts_dest.symlink_to(ts_src.resolve())
|
|
|
|
# Remove the old dest (symlink or file) before writing the new one
|
|
if dest.exists() or dest.is_symlink():
|
|
dest.unlink()
|
|
|
|
if needs_merge:
|
|
detail = json.loads(src.read_text(encoding="utf-8"))
|
|
if has_sidecar:
|
|
fm, body = parse_sidecar(sidecar_path) # type: ignore[arg-type]
|
|
detail = apply_sidecar(detail, fm, body)
|
|
if image_files:
|
|
detail["custom"] = dict(detail.get("custom") or {})
|
|
detail["custom"]["images"] = image_files
|
|
dest.write_text(json.dumps(detail, indent=2, ensure_ascii=False))
|
|
else:
|
|
dest.symlink_to(src.resolve())
|
|
|
|
# Rewrite index — load the full sidecar map so all summaries stay consistent
|
|
index_path = data_dir / "index.json"
|
|
if not index_path.exists():
|
|
return
|
|
|
|
all_sidecars: dict[str, tuple[dict, str]] = {}
|
|
if edits_dir and edits_dir.exists():
|
|
for md_path in edits_dir.glob("*.md"):
|
|
all_sidecars[md_path.stem] = parse_sidecar(md_path)
|
|
|
|
index = json.loads(index_path.read_text(encoding="utf-8"))
|
|
activities = []
|
|
for s in index.get("activities", []):
|
|
aid = s.get("id", "")
|
|
if aid in all_sidecars:
|
|
fm, _ = all_sidecars[aid]
|
|
s = _apply_sidecar_summary(s, fm)
|
|
activities.append(s)
|
|
|
|
activities = [a for a in activities if a.get("privacy") != "private"]
|
|
activities.sort(key=lambda a: a.get("started_at", ""), reverse=True)
|
|
activities.sort(key=lambda a: 0 if a.get("custom", {}).get("highlight") else 1)
|
|
|
|
index["activities"] = activities
|
|
(merged_dir / "index.json").write_text(json.dumps(index, indent=2, ensure_ascii=False))
|
|
|
|
|
|
def merge_all(data_dir: Path) -> int:
|
|
"""Build data_dir/_merged/ with all sidecar overrides applied.
|
|
|
|
Returns the number of sidecars found and applied.
|
|
"""
|
|
edits_dir = data_dir / "edits"
|
|
acts_dir = data_dir / "activities"
|
|
merged_dir = data_dir / "_merged"
|
|
merged_acts = merged_dir / "activities"
|
|
|
|
# Collect sidecars upfront
|
|
sidecars: dict[str, tuple[dict, str]] = {}
|
|
if edits_dir.exists():
|
|
for md_path in sorted(edits_dir.glob("*.md")):
|
|
sidecars[md_path.stem] = parse_sidecar(md_path)
|
|
|
|
# Collect image lists — activities with uploaded images get custom.images even
|
|
# if they have no sidecar text yet
|
|
image_lists: dict[str, list[str]] = {}
|
|
images_root = edits_dir / "images" if edits_dir.exists() else None
|
|
if images_root and images_root.exists():
|
|
for img_dir in sorted(images_root.iterdir()):
|
|
if img_dir.is_dir():
|
|
files = sorted(
|
|
p.name for p in img_dir.iterdir()
|
|
if p.is_file() and not p.name.startswith(".")
|
|
)
|
|
if files:
|
|
image_lists[img_dir.name] = files
|
|
|
|
to_merge = set(sidecars) | set(image_lists)
|
|
|
|
# Wipe and recreate _merged/activities/
|
|
if merged_acts.exists():
|
|
shutil.rmtree(merged_acts)
|
|
merged_acts.mkdir(parents=True)
|
|
|
|
# Mirror activities/ — symlink unmodified, write merged copies for overridden
|
|
if acts_dir.exists():
|
|
for src in sorted(acts_dir.iterdir()):
|
|
if not src.is_file():
|
|
continue
|
|
dest = merged_acts / src.name
|
|
activity_id = src.stem
|
|
if src.suffix == ".json" and activity_id in to_merge:
|
|
detail = json.loads(src.read_text(encoding="utf-8"))
|
|
if activity_id in sidecars:
|
|
fm, body = sidecars[activity_id]
|
|
detail = apply_sidecar(detail, fm, body)
|
|
if activity_id in image_lists:
|
|
detail["custom"] = dict(detail.get("custom") or {})
|
|
detail["custom"]["images"] = image_lists[activity_id]
|
|
dest.write_text(json.dumps(detail, indent=2, ensure_ascii=False))
|
|
else:
|
|
dest.symlink_to(src.resolve())
|
|
|
|
# Mirror edits/images/ → _merged/activities/images/ so the site can serve them
|
|
if images_root and images_root.exists():
|
|
merged_images = merged_acts / "images"
|
|
merged_images.mkdir(exist_ok=True)
|
|
for img_dir in images_root.iterdir():
|
|
if img_dir.is_dir():
|
|
dest_img = merged_images / img_dir.name
|
|
if not dest_img.exists():
|
|
dest_img.symlink_to(img_dir.resolve())
|
|
|
|
# Produce merged athlete.json — base from extract overlaid with edits/athlete.yaml
|
|
athlete_src = data_dir / "athlete.json"
|
|
athlete_dest = merged_dir / "athlete.json"
|
|
if athlete_dest.exists() or athlete_dest.is_symlink():
|
|
athlete_dest.unlink()
|
|
if athlete_src.exists():
|
|
athlete_edits_path = data_dir / "edits" / "athlete.yaml"
|
|
if athlete_edits_path.exists():
|
|
try:
|
|
import yaml as _yaml
|
|
edits = _yaml.safe_load(athlete_edits_path.read_text(encoding="utf-8")) or {}
|
|
except Exception:
|
|
edits = {}
|
|
else:
|
|
edits = {}
|
|
_ATHLETE_EDITABLE = {"max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"}
|
|
if edits:
|
|
athlete_data = json.loads(athlete_src.read_text(encoding="utf-8"))
|
|
athlete_data.update({k: v for k, v in edits.items() if k in _ATHLETE_EDITABLE})
|
|
athlete_dest.write_text(json.dumps(athlete_data, indent=2, ensure_ascii=False))
|
|
else:
|
|
athlete_dest.symlink_to(athlete_src.resolve())
|
|
|
|
# Write merged index.json (private filtered, highlight sorted)
|
|
index_path = data_dir / "index.json"
|
|
if index_path.exists():
|
|
index = json.loads(index_path.read_text(encoding="utf-8"))
|
|
activities = []
|
|
for s in index.get("activities", []):
|
|
aid = s.get("id", "")
|
|
if aid in sidecars:
|
|
fm, _ = sidecars[aid]
|
|
s = _apply_sidecar_summary(s, fm)
|
|
activities.append(s)
|
|
|
|
# Drop private activities from the published feed
|
|
activities = [a for a in activities if a.get("privacy") != "private"]
|
|
|
|
# Sort: newest first, then bring highlighted activities to the top
|
|
activities.sort(key=lambda a: a.get("started_at", ""), reverse=True)
|
|
activities.sort(key=lambda a: 0 if a.get("custom", {}).get("highlight") else 1)
|
|
|
|
index["activities"] = activities
|
|
(merged_dir / "index.json").write_text(
|
|
json.dumps(index, indent=2, ensure_ascii=False)
|
|
)
|
|
elif (merged_dir / "index.json").exists():
|
|
(merged_dir / "index.json").unlink()
|
|
|
|
return len(sidecars)
|