Files
bincio-activity/bincio/render/merge.py
T
Davide Scaini 1d3848f85e feat: activity sidecar edits via bincio edit --serve
- bincio/render/merge.py: parse sidecar .md files (YAML frontmatter +
     markdown body), produce data/_merged/ with symlinks for unmodified
     activities and real merged files for overridden ones; filters private
     activities from index.json; sorts highlighted activities first.
     Keeps extracted data pristine — re-running extract never clobbers edits.

   - bincio/edit/: FastAPI edit server (port 4041) with embedded HTML/JS
     edit UI; GET/POST /api/activity/{id} reads/writes sidecars; multipart
     image upload to edits/images/{id}/; DELETE for image cleanup.

   - bincio render now calls merge_all() before build/serve and symlinks
     public/data → data/_merged/ instead of data/ directly.

   - ActivityDetail.svelte: edit button (links to edit server) when
     PUBLIC_EDIT_URL env var is set; respects custom.hide_stats to suppress
     stat panels; description supports whitespace-preserving rendering.

   - 15 unit tests covering parse_sidecar, apply_sidecar, and merge_all.
2026-03-29 15:06:55 +02:00

151 lines
5.4 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_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)
# 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 sidecars:
fm, body = sidecars[activity_id]
detail = json.loads(src.read_text(encoding="utf-8"))
merged = apply_sidecar(detail, fm, body)
dest.write_text(json.dumps(merged, indent=2, ensure_ascii=False))
else:
dest.symlink_to(src.resolve())
# Mirror edits/images/ → _merged/activities/images/ so the site can serve them
edits_images = edits_dir / "images" if edits_dir.exists() else None
if edits_images and edits_images.exists():
merged_images = merged_acts / "images"
merged_images.mkdir(exist_ok=True)
for img_dir in edits_images.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())
# 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)