Merge branch 'dev/edit'

This commit is contained in:
Davide Scaini
2026-03-29 15:57:02 +02:00
14 changed files with 1402 additions and 61 deletions
+50 -42
View File
@@ -254,44 +254,46 @@ Sidecars work for *remote* activities too: if you include someone else's BAS fee
you can write local `.md` sidecars for their activity IDs. Your render stage applies
your overrides on top of their data. This is a natural extension of the local case.
### Editing UX: `bincio edit --serve`
### Editing UX: drawer in Astro + `bincio edit` write API
A separate FastAPI server (`bincio edit --serve`, default port 4041) handles all writes.
The static site and Astro are untouched — no hybrid mode, no dead-code API routes in prod.
The edit UI is a **slide-in drawer** (`EditDrawer.svelte`) in the Astro site.
The drawer fetches from and POSTs to the `bincio edit` FastAPI server (write API only —
the server no longer serves its own HTML UI).
**How it works:**
```
bincio edit --serve --data ~/bincio_data # starts on :4041
bincio render --serve # Astro dev server, port 4321
bincio edit --data-dir ~/… # write API only, port 4041
```
- Serves a bundled Svelte UI (single compiled HTML, reuses existing Svelte investment)
- `GET /api/activity/{id}` — returns merged BAS JSON + existing sidecar fields
- `POST /api/activity/{id}` — writes `edits/{id}.md` to the data dir
- Edit button appears on the activity detail page **only when `PUBLIC_EDIT_URL` is set** in `site/.env`
- Clicking Edit opens the drawer in the same page — no navigation, no copy-pasting IDs
- Drawer fetches `GET /api/activity/{id}` to pre-fill, `POST /api/activity/{id}` to save
- After save: server runs `merge_all()` automatically → Astro serves updated data immediately on refresh
- Closing the drawer applies `title` + `description` changes optimistically to the local page state
(no full reload required to see the text change)
**`PUBLIC_EDIT_URL` as feature flag:**
- **Unset** → no Edit button, no drawer. Works as a normal static site. Safe for public hosting.
- **Set** (e.g. `http://localhost:4041`) → editing enabled. Lives in `site/.env` (gitignored).
Each deployment opts in explicitly.
**Edit server API (`bincio edit --data-dir <dir>`):**
- `GET /api/activity/{id}` — current values (sidecar overrides layered on BAS JSON)
- `POST /api/activity/{id}` — write sidecar `.md`, trigger `merge_all()`
- `POST /api/activity/{id}/images` — multipart upload → `edits/images/{id}/{filename}`
- The Astro dev server's file watcher picks up `.md` writes → incremental rebuild
- `DELETE /api/activity/{id}/images/{filename}` — remove uploaded image
**Edit UI features:**
- Title text input (pre-filled from BAS JSON)
- Sport dropdown (pre-filled, shows all known sport types)
- Markdown textarea for description, with minimal toolbar (bold, italic, link, image insert)
- Live markdown preview panel
- `hide_stats` checkbox group: elevation, speed, heart_rate, cadence, power
- `highlight` toggle (feature in feed)
- `private` toggle (suppress from feed at render time)
- Image drag-and-drop zone → uploads to `edits/images/{id}/`, inserts `![]()` into textarea
- Save button → POST to API → success toast
**Edit drawer features:**
- Title, sport dropdown, gear
- Markdown textarea for description (images inserted as `![name](filename)` references)
- Image drag-and-drop zone with chip list + delete
- Hide stat panels (elevation, speed, heart_rate, cadence, power) — toggle buttons
- Highlight flag (★ — sorts to top of feed, visual badge)
- Private flag (⊘ — suppressed from index at render time)
**Workflow (typical):**
1. User browses the Astro dev server on :4040
2. Activity detail page has an "Edit" button (rendered only when `PUBLIC_EDIT_URL` env var is set)
3. Button links to `:4041/edit/{id}` — opens the FastAPI-served edit UI
4. User fills in form, saves → sidecar written → Astro rebuilds → refreshing :4040 shows changes
The `PUBLIC_EDIT_URL` env var in `.env` controls whether the Edit button appears;
leave it unset for production builds, set to `http://localhost:4041` for local dev.
### Image storage
### Image storage and serving
```
~/bincio_data/
@@ -304,17 +306,20 @@ leave it unset for production builds, set to `http://localhost:4041` for local d
```
Images are referenced in the markdown body with relative paths: `![Summit](col-summit.jpg)`.
The render stage resolves relative image paths against `edits/images/{id}/` and copies them
to `site/public/images/activities/{id}/` so they're served from the static site.
`merge_all()` symlinks `edits/images/{id}/``_merged/activities/images/{id}/` so images
are served at `data/activities/images/{id}/{filename}` by the Astro dev server.
`ActivityDetail.svelte` rewrites relative image paths to this URL when rendering markdown.
**Note:** browsers cannot display `.HEIC` files. Convert to JPEG/PNG first:
`sips -s format jpeg photo.HEIC --out photo.jpg` (macOS).
### Decided
- **Sidecar location**: `edits/` subdirectory (not co-located with JSON) — cleaner, easier to
backup/sync just your customisations independently of the extracted data
- **`private: true`**: suppresses from `index.json` at render time (not client-side hide)
safer for public hosting
- **`highlight`**: visual badge in feed + sorted before non-highlighted activities
- **Edit UI**: `bincio edit --serve` FastAPI server (Option B) — not integrated into Astro
- **Sidecar location**: `edits/` subdirectory — cleaner, easier to backup/sync independently
- **Merge output**: `data/_merged/` — extracted data stays pristine; `public/data``_merged/`
- **`private: true`**: suppressed from `index.json` at render time (not client-side hide)
- **`highlight`**: sorts to top of feed; visual badge TBD
- **Edit UI**: drawer in Astro site, `bincio edit` is a pure write API (no HTML serving)
## Known issues / next steps
@@ -337,9 +342,12 @@ to `site/public/images/activities/{id}/` so they're served from the static site.
- [ ] Map thumbnail in activity cards (SVG path from GeoJSON)
- [ ] GitHub Actions template for auto-publish
- [ ] Karoo/Garmin Connect importers beyond Strava
- [ ] `bincio.render.merge` module: walk `edits/`, parse sidecars, produce enriched data for Astro
- [ ] `bincio render --watch` incremental rebuild on sidecar changes
- [ ] Sidecar `.md` format: title, sport, description, hide_stats, highlight, private, images
- [ ] `bincio edit --serve` FastAPI server with Svelte edit UI (port 4041)
- [ ] Edit button on activity detail pages (visible when `PUBLIC_EDIT_URL` env var set)
- [ ] Image upload → `edits/images/{id}/`, render stage copies to `public/images/activities/{id}/`
- [x] `bincio.render.merge` — sidecar parser, `_merged/` output, private filter, highlight sort
- [x] `bincio edit` FastAPI write API (GET/POST activity, image upload/delete, triggers merge)
- [x] `EditDrawer.svelte` — slide-in edit UI in the Astro site (no separate HTML from server)
- [x] `PUBLIC_EDIT_URL` feature flag — unset = no edit UI, set = drawer enabled
- [x] Markdown rendering in activity description with image path rewriting
- [x] `hide_stats` support in activity detail stats panel
- [ ] `bincio render --watch` incremental rebuild on sidecar/data changes
- [ ] Highlight badge in activity feed cards
- [ ] Image format warning (HEIC → JPEG conversion hint in the upload UI)
+2
View File
@@ -13,6 +13,8 @@ def main() -> None:
from bincio.extract.cli import extract # noqa: E402
from bincio.render.cli import render # noqa: E402
from bincio.edit.cli import edit # noqa: E402
main.add_command(extract)
main.add_command(render)
main.add_command(edit)
View File
+79
View File
@@ -0,0 +1,79 @@
"""bincio edit — local edit server for activity sidecar files."""
from __future__ import annotations
from pathlib import Path
from typing import Optional
import click
from rich.console import Console
console = Console()
@click.command()
@click.option("--data-dir", default=None,
help="BAS data store directory (output of bincio extract).")
@click.option("--port", default=4041, show_default=True,
help="Port for the edit server.")
@click.option("--site-url", default="http://localhost:4321", show_default=True,
help="URL of the Astro dev server (for the Back link).")
@click.option("--config", "config_path", default=None,
help="Path to extract_config.yaml (reads output.dir from it).")
def edit(
data_dir: Optional[str],
port: int,
site_url: str,
config_path: Optional[str],
) -> None:
"""Start a local web UI for editing activity sidecar files.
Writes sidecar .md files to <data-dir>/edits/ which bincio render picks
up and applies at build time.
Run alongside the Astro dev server:
\b
bincio render --serve # port 4321 (or npm run dev)
bincio edit # port 4041
"""
try:
import uvicorn
except ImportError:
raise click.ClickException(
"uvicorn is required for the edit server.\n"
"Install with: uv add 'bincio[edit]'"
)
data = _resolve_data_dir(data_dir, config_path)
console.print(f"Data dir: [cyan]{data}[/cyan]")
console.print(f"Edit UI: [cyan]http://localhost:{port}/edit/<activity-id>[/cyan]")
console.print(f"Site URL: [cyan]{site_url}[/cyan]")
console.print("Press [bold]Ctrl+C[/bold] to stop.\n")
import bincio.edit.server as srv
srv.data_dir = data
srv.site_url = site_url
uvicorn.run(srv.app, host="127.0.0.1", port=port, log_level="warning")
def _resolve_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Path:
if explicit:
return Path(explicit).expanduser().resolve()
if config_path and Path(config_path).exists():
import yaml
raw = yaml.safe_load(Path(config_path).read_text())
out = raw.get("output", {}).get("dir")
if out:
return Path(out).expanduser().resolve()
default = Path.cwd() / "bincio_data"
if default.exists():
return default
raise click.UsageError(
"Could not find the BAS data directory. "
"Run `bincio extract` first, or pass --data-dir."
)
+447
View File
@@ -0,0 +1,447 @@
"""FastAPI edit server — serves the activity edit UI and writes sidecar .md files."""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
# Populated by the CLI before uvicorn starts
data_dir: Path | None = None
site_url: str = "http://localhost:4321"
app = FastAPI(title="BincioActivity Edit Server", docs_url=None, redoc_url=None)
# Allow the Astro dev server (and any local origin) to call the write API
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
SPORTS = ["cycling", "running", "hiking", "walking", "swimming", "skiing", "other"]
STAT_PANELS = ["elevation", "speed", "heart_rate", "cadence", "power"]
# ── HTML UI ───────────────────────────────────────────────────────────────────
_HTML = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Activity</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #09090b; --surface: #18181b; --border: #27272a;
--text: #fafafa; --muted: #71717a; --accent: #3b82f6;
--accent-dim: #1d3461; --danger: #ef4444;
--radius: 10px; --font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body { background: var(--bg); color: var(--text); font-family: var(--font);
font-size: 14px; line-height: 1.5; padding: 24px; min-height: 100vh; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.25rem; font-weight: 700; }
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
input, select, textarea {
width: 100%; padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 14px; font-family: var(--font);
outline: none; transition: border-color .15s;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
textarea { resize: vertical; min-height: 140px; }
.field { margin-bottom: 16px; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.check-group { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.check-item { display: flex; align-items: center; gap: 6px; cursor: pointer;
padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
user-select: none; transition: border-color .15s, background .15s; }
.check-item:hover { border-color: var(--muted); }
.check-item input[type=checkbox] { width: auto; accent-color: var(--accent); }
.check-item.active { border-color: var(--accent); background: var(--accent-dim); }
.toggle-row { display: flex; gap: 16px; }
.toggle { display: flex; align-items: center; gap: 8px; cursor: pointer;
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
transition: border-color .15s, background .15s; }
.toggle:hover { border-color: var(--muted); }
.toggle.active { border-color: var(--accent); background: var(--accent-dim); }
.toggle input { width: auto; accent-color: var(--accent); }
.drop-zone { border: 2px dashed var(--border); border-radius: var(--radius);
padding: 24px; text-align: center; color: var(--muted); cursor: pointer;
transition: border-color .15s; margin-top: 4px; }
.drop-zone:hover, .drop-zone.drag-over { border-color: var(--accent); color: var(--text); }
.image-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.image-chip { display: flex; align-items: center; gap: 6px; padding: 4px 10px;
background: var(--surface); border: 1px solid var(--border); border-radius: 20px;
font-size: 12px; }
.image-chip button { background: none; border: none; color: var(--muted);
cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; }
.image-chip button:hover { color: var(--danger); }
.actions { display: flex; gap: 12px; align-items: center; margin-top: 8px; }
.btn { padding: 8px 20px; border-radius: 6px; font-size: 14px; font-weight: 500;
cursor: pointer; border: none; transition: opacity .15s; }
.btn:disabled { opacity: .4; cursor: default; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover:not(:disabled) { opacity: .85; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover:not(:disabled) { border-color: var(--muted); }
.status { font-size: 13px; }
.status.ok { color: #4ade80; }
.status.err { color: var(--danger); }
.header { display: flex; align-items: baseline; gap: 16px; margin-bottom: 24px; }
.back { font-size: 13px; color: var(--muted); }
.meta { font-size: 12px; color: var(--muted); margin-top: 4px; }
.card { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px; max-width: 780px; margin: 0 auto; }
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin-bottom: 14px; padding-bottom: 6px;
border-bottom: 1px solid var(--border); }
</style>
</head>
<body>
<div style="max-width:780px;margin:0 auto">
<div class="header">
<a class="back" href="__SITE_URL__">← Back to site</a>
<h1 id="page-title">Edit Activity</h1>
</div>
<p id="meta" class="meta" style="margin-bottom:16px"></p>
<div class="card">
<form id="form" autocomplete="off">
<p class="section-title">Identity</p>
<div class="row">
<div class="field">
<label for="title">Title</label>
<input id="title" name="title" type="text" placeholder="Leave blank to keep extracted title">
</div>
<div class="field">
<label for="sport">Sport</label>
<select id="sport" name="sport">
__SPORT_OPTIONS__
</select>
</div>
</div>
<div class="field">
<label for="gear">Gear</label>
<input id="gear" name="gear" type="text" placeholder="e.g. Trek Domane SL6">
</div>
<p class="section-title" style="margin-top:20px">Description</p>
<div class="field">
<label for="description">Markdown supported</label>
<textarea id="description" name="description" placeholder="Write about this activity…"></textarea>
</div>
<p class="section-title" style="margin-top:20px">Display</p>
<div class="field">
<label>Hide stat panels</label>
<div class="check-group" id="hide-stats-group">
__STAT_CHECKBOXES__
</div>
</div>
<div class="field" style="margin-top:12px">
<label>Flags</label>
<div class="toggle-row">
<label class="toggle" id="toggle-highlight">
<input type="checkbox" id="highlight" name="highlight"> Highlight in feed
</label>
<label class="toggle" id="toggle-private">
<input type="checkbox" id="private" name="private"> Private (hide from feed)
</label>
</div>
</div>
<p class="section-title" style="margin-top:20px">Images</p>
<div class="field">
<label>Drag & drop images or click to browse</label>
<div class="drop-zone" id="drop-zone">
<span id="drop-label">Drop images here or click to upload</span>
<input type="file" id="file-input" accept="image/*" multiple style="display:none">
</div>
<div class="image-list" id="image-list"></div>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary" id="save-btn">Save</button>
<span class="status" id="status"></span>
</div>
</form>
</div>
</div>
<script>
const id = location.pathname.split('/edit/')[1];
const api = '/api/activity/' + id;
let uploadedImages = [];
// Fetch current data
fetch(api).then(r => r.json()).then(data => {
document.getElementById('page-title').textContent = 'Edit: ' + (data.title || id);
document.getElementById('meta').textContent = data.started_at
? new Date(data.started_at).toLocaleString() : '';
document.getElementById('title').value = data.title || '';
document.getElementById('sport').value = data.sport || 'other';
document.getElementById('gear').value = data.gear || '';
document.getElementById('description').value = data.description || '';
if (data.highlight) setToggle('highlight', true);
if (data.private) setToggle('private', true);
(data.hide_stats || []).forEach(s => {
const cb = document.querySelector(`input[data-stat="${s}"]`);
if (cb) { cb.checked = true; cb.closest('.check-item').classList.add('active'); }
});
uploadedImages = data.images || [];
renderImageList();
}).catch(() => {
document.getElementById('status').textContent = 'Could not load activity data.';
document.getElementById('status').className = 'status err';
});
// Toggle active class on check items
document.querySelectorAll('.check-item input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', () => {
cb.closest('.check-item').classList.toggle('active', cb.checked);
});
});
function setToggle(name, val) {
const cb = document.getElementById(name);
cb.checked = val;
document.getElementById('toggle-' + name).classList.toggle('active', val);
}
document.getElementById('highlight').addEventListener('change', e => {
document.getElementById('toggle-highlight').classList.toggle('active', e.target.checked);
});
document.getElementById('private').addEventListener('change', e => {
document.getElementById('toggle-private').classList.toggle('active', e.target.checked);
});
// Image upload
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
uploadFiles([...e.dataTransfer.files]);
});
fileInput.addEventListener('change', () => uploadFiles([...fileInput.files]));
async function uploadFiles(files) {
for (const file of files) {
const fd = new FormData();
fd.append('file', file);
const r = await fetch(api + '/images', { method: 'POST', body: fd });
if (r.ok) {
const d = await r.json();
if (!uploadedImages.includes(d.filename)) uploadedImages.push(d.filename);
renderImageList();
// Insert markdown image reference at end of description
const ta = document.getElementById('description');
const ref = '\\n![' + d.filename.replace(/\\.[^.]+$/, '') + '](' + d.filename + ')';
ta.value = ta.value.trimEnd() + ref;
}
}
}
function renderImageList() {
const list = document.getElementById('image-list');
list.innerHTML = uploadedImages.map(f =>
`<span class="image-chip">${f}
<button type="button" onclick="removeImage('${f}')" title="Remove">×</button>
</span>`
).join('');
}
async function removeImage(filename) {
await fetch(api + '/images/' + encodeURIComponent(filename), { method: 'DELETE' });
uploadedImages = uploadedImages.filter(f => f !== filename);
renderImageList();
}
// Save
document.getElementById('form').addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('save-btn');
const status = document.getElementById('status');
btn.disabled = true;
status.textContent = 'Saving…';
status.className = 'status';
const hideStats = [...document.querySelectorAll('input[data-stat]:checked')]
.map(cb => cb.dataset.stat);
const payload = {
title: document.getElementById('title').value.trim(),
sport: document.getElementById('sport').value,
gear: document.getElementById('gear').value.trim(),
description: document.getElementById('description').value.trim(),
highlight: document.getElementById('highlight').checked,
private: document.getElementById('private').checked,
hide_stats: hideStats,
};
try {
const r = await fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(await r.text());
status.textContent = 'Saved! Re-run `bincio render` to rebuild.';
status.className = 'status ok';
} catch (err) {
status.textContent = 'Error: ' + err.message;
status.className = 'status err';
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>
"""
# ── Routes ────────────────────────────────────────────────────────────────────
def _get_data_dir() -> Path:
if data_dir is None:
raise HTTPException(500, "Edit server not configured (data_dir is None)")
return data_dir
@app.get("/")
async def root() -> RedirectResponse:
return RedirectResponse(url=site_url)
@app.get("/edit/{activity_id}", response_class=HTMLResponse)
async def edit_page(activity_id: str) -> str:
sport_opts = "\n".join(
f'<option value="{s}">{s.capitalize()}</option>' for s in SPORTS
)
stat_cbs = "\n".join(
f'<label class="check-item"><input type="checkbox" data-stat="{s}"> {s.replace("_", " ").capitalize()}</label>'
for s in STAT_PANELS
)
html = (
_HTML
.replace("__SITE_URL__", site_url)
.replace("__SPORT_OPTIONS__", sport_opts)
.replace("__STAT_CHECKBOXES__", stat_cbs)
)
return html
@app.get("/api/activity/{activity_id}")
async def get_activity(activity_id: str) -> JSONResponse:
dd = _get_data_dir()
json_path = dd / "activities" / f"{activity_id}.json"
if not json_path.exists():
raise HTTPException(404, f"Activity {activity_id!r} not found")
detail: dict[str, Any] = json.loads(json_path.read_text(encoding="utf-8"))
# Read existing sidecar if any — these are the "user" values shown in the form
from bincio.render.merge import parse_sidecar
sidecar_path = dd / "edits" / f"{activity_id}.md"
fm: dict = {}
body = ""
if sidecar_path.exists():
fm, body = parse_sidecar(sidecar_path)
# Existing uploaded images for this activity
images_dir = dd / "edits" / "images" / activity_id
images = sorted(p.name for p in images_dir.iterdir() if p.is_file()) if images_dir.exists() else []
return JSONResponse({
"id": activity_id,
"started_at": detail.get("started_at", ""),
"title": fm.get("title", detail.get("title", "")),
"sport": fm.get("sport", detail.get("sport", "other")),
"gear": fm.get("gear", detail.get("gear") or ""),
"description": body or fm.get("description") or detail.get("description") or "",
"highlight": fm.get("highlight", detail.get("custom", {}).get("highlight", False)),
"private": fm.get("private", detail.get("privacy") == "private"),
"hide_stats": fm.get("hide_stats", detail.get("custom", {}).get("hide_stats", [])),
"images": images,
})
@app.post("/api/activity/{activity_id}")
async def save_activity(activity_id: str, payload: dict[str, Any]) -> JSONResponse:
dd = _get_data_dir()
if not (dd / "activities" / f"{activity_id}.json").exists():
raise HTTPException(404, f"Activity {activity_id!r} not found")
edits_dir = dd / "edits"
edits_dir.mkdir(exist_ok=True)
sidecar_path = edits_dir / f"{activity_id}.md"
lines: list[str] = []
if payload.get("title"):
lines.append(f"title: {json.dumps(payload['title'])}")
if payload.get("sport") and payload["sport"] != "other":
lines.append(f"sport: {payload['sport']}")
if payload.get("gear"):
lines.append(f"gear: {json.dumps(payload['gear'])}")
if payload.get("highlight"):
lines.append("highlight: true")
if payload.get("private"):
lines.append("private: true")
hide = payload.get("hide_stats") or []
if hide:
lines.append(f"hide_stats: [{', '.join(str(s) for s in hide)}]")
description = (payload.get("description") or "").strip()
content = "---\n" + "\n".join(lines) + "\n---\n"
if description:
content += "\n" + description + "\n"
sidecar_path.write_text(content, encoding="utf-8")
# Re-merge so the Astro dev server immediately serves updated data
from bincio.render.merge import merge_all
merge_all(dd)
return JSONResponse({"ok": True, "sidecar": str(sidecar_path)})
@app.post("/api/activity/{activity_id}/images")
async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONResponse:
dd = _get_data_dir()
if not (dd / "activities" / f"{activity_id}.json").exists():
raise HTTPException(404, f"Activity {activity_id!r} not found")
if not file.filename:
raise HTTPException(400, "No filename")
images_dir = dd / "edits" / "images" / activity_id
images_dir.mkdir(parents=True, exist_ok=True)
dest = images_dir / Path(file.filename).name
dest.write_bytes(await file.read())
return JSONResponse({"ok": True, "filename": dest.name})
@app.delete("/api/activity/{activity_id}/images/{filename}")
async def delete_image(activity_id: str, filename: str) -> JSONResponse:
dd = _get_data_dir()
target = dd / "edits" / "images" / activity_id / filename
if target.exists() and target.is_file():
target.unlink()
# Remove empty parent dir
if not any(target.parent.iterdir()):
shutil.rmtree(target.parent)
return JSONResponse({"ok": True})
+17 -4
View File
@@ -61,11 +61,23 @@ def _ensure_npm(site: Path) -> None:
subprocess.run(["npm", "install"], cwd=site, check=True)
def _merge_edits(data: Path) -> None:
"""Run the sidecar merge step, producing data/_merged/."""
from bincio.render.merge import merge_all
n = merge_all(data)
if n:
console.print(f"Merged [cyan]{n}[/cyan] sidecar edit(s) into _merged/")
else:
console.print("No sidecars found — _merged/ mirrors extracted data.")
def _link_data(site: Path, data: Path) -> None:
"""Symlink the BAS data store into site/public/data."""
"""Symlink site/public/data → data/_merged/ (the post-merge output)."""
merged = data / "_merged"
target = merged if merged.exists() else data
public_data = site / "public" / "data"
if public_data.is_symlink():
if public_data.resolve() == data:
if public_data.resolve() == target.resolve():
return # already correct
public_data.unlink()
elif public_data.exists():
@@ -74,8 +86,8 @@ def _link_data(site: Path, data: Path) -> None:
"remove it manually if you want bincio to manage it."
)
return
public_data.symlink_to(data)
console.print(f"Linked data: [cyan]{data}[/cyan] → [cyan]{public_data}[/cyan]")
public_data.symlink_to(target)
console.print(f"Linked data: [cyan]{target}[/cyan] → [cyan]{public_data}[/cyan]")
@click.command()
@@ -108,6 +120,7 @@ def render(
console.print(f"Data: [cyan]{data}[/cyan]")
_ensure_npm(site)
_merge_edits(data)
_link_data(site, data)
env = {**os.environ, "BINCIO_DATA_DIR": str(data)}
+169
View File
@@ -0,0 +1,169 @@
"""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)
# 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())
# 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)
+5
View File
@@ -32,6 +32,11 @@ dependencies = [
classifier = [
"scikit-learn>=1.5",
]
edit = [
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
"python-multipart>=0.0.9",
]
dev = [
"pytest>=9.0",
"pytest-cov>=5.0",
+2
View File
@@ -0,0 +1,2 @@
BINCIO_DATA_DIR=/tmp/bincio_test
PUBLIC_EDIT_URL=http://localhost:4041
+1
View File
@@ -4,6 +4,7 @@ import tailwind from "@astrojs/tailwind";
export default defineConfig({
integrations: [svelte(), tailwind()],
devToolbar: { enabled: false },
output: "static",
// When hosting at a subdirectory (e.g. GitHub Pages project site), set:
// base: "/repo-name",
+2 -1
View File
@@ -12,9 +12,10 @@
"dependencies": {
"@astrojs/svelte": "^7.0.0",
"@astrojs/tailwind": "^5.1.0",
"@observablehq/plot": "^0.6.0",
"astro": "^5.0.0",
"maplibre-gl": "^5.0.0",
"@observablehq/plot": "^0.6.0",
"marked": "^17.0.5",
"svelte": "^5.0.0",
"tailwindcss": "^3.4.0"
},
+126 -9
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { marked } from 'marked';
import type { ActivitySummary, ActivityDetail } from '../lib/types';
import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, sportIcon, sportLabel, sportColor } from '../lib/format';
import ActivityMap from './ActivityMap.svelte';
import ActivityCharts from './ActivityCharts.svelte';
import EditDrawer from './EditDrawer.svelte';
export let activity: ActivitySummary;
export let base: string = '/';
@@ -12,8 +14,14 @@
let detail: ActivityDetail | null = null;
let error = '';
// Linked hover index shared between map and charts
let hoveredIdx: number | null = null;
let editOpen = false;
let lightboxIndex: number | null = null;
// Local overrides applied immediately after a save (no re-fetch needed)
let localTitle = '';
let localDescription = '';
$: displayTitle = localTitle || activity.title;
onMount(async () => {
if (!activity.detail_url) return;
@@ -26,9 +34,42 @@
}
});
function onSaved(e: CustomEvent<{ title: string; description: string }>) {
editOpen = false;
localTitle = e.detail.title;
localDescription = e.detail.description;
}
$: trackUrl = activity.track_url ? `${base}data/${activity.track_url}` : null;
$: color = sportColor(activity.sport);
function lightboxPrev() { if (lightboxIndex !== null) lightboxIndex = (lightboxIndex - 1 + galleryImages.length) % galleryImages.length; }
function lightboxNext() { if (lightboxIndex !== null) lightboxIndex = (lightboxIndex + 1) % galleryImages.length; }
function onKeydown(e: KeyboardEvent) {
if (lightboxIndex === null) return;
if (e.key === 'ArrowLeft') { e.preventDefault(); lightboxPrev(); }
if (e.key === 'ArrowRight') { e.preventDefault(); lightboxNext(); }
if (e.key === 'Escape') { lightboxIndex = null; }
}
$: rawDescription = localDescription || detail?.description || '';
$: descriptionHtml = (() => {
if (!rawDescription) return '';
const renderer = new marked.Renderer();
// Local relative images are always shown in the gallery — suppress inline rendering
renderer.image = ({ href, title, text }) => {
const isLocal = href && !href.startsWith('http') && !href.startsWith('/') && !href.startsWith('data:');
if (isLocal) return '';
const titleAttr = title ? ` title="${title}"` : '';
return `<img src="${href ?? ''}" alt="${text}"${titleAttr} class="rounded-lg max-w-full my-2">`;
};
return marked(rawDescription, { renderer }) as string;
})();
$: imageBase = `${base}data/activities/images/${activity.id}/`;
$: galleryImages = (detail?.custom as any)?.images as string[] ?? [];
const stat = (label: string, value: string, key?: string) => ({ label, value, key });
$: hiddenStats = new Set<string>((detail?.custom as any)?.hide_stats ?? []);
$: stats = [
@@ -43,6 +84,62 @@
].filter(s => !s.key || !hiddenStats.has(s.key));
</script>
<svelte:window on:keydown={onKeydown} />
{#if editOpen && editUrl}
<EditDrawer activityId={activity.id} {editUrl} on:saved={onSaved} />
{/if}
<!-- Lightbox -->
{#if lightboxIndex !== null}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
on:click={() => lightboxIndex = null}
on:keydown={onKeydown}
>
<!-- Prev -->
{#if galleryImages.length > 1}
<button
class="absolute left-4 top-1/2 -translate-y-1/2 text-white/60 hover:text-white text-3xl px-3 py-6 transition-colors z-10"
on:click|stopPropagation={lightboxPrev}
aria-label="Previous"
></button>
{/if}
<img
src={imageBase + galleryImages[lightboxIndex]}
alt={galleryImages[lightboxIndex]}
class="max-h-[90vh] max-w-[90vw] rounded-lg shadow-2xl object-contain"
on:click|stopPropagation
/>
<!-- Next -->
{#if galleryImages.length > 1}
<button
class="absolute right-4 top-1/2 -translate-y-1/2 text-white/60 hover:text-white text-3xl px-3 py-6 transition-colors z-10"
on:click|stopPropagation={lightboxNext}
aria-label="Next"
></button>
{/if}
<!-- Counter + filename -->
<div class="absolute bottom-6 left-1/2 -translate-x-1/2 text-white/50 text-xs text-center">
<p>{galleryImages[lightboxIndex]}</p>
{#if galleryImages.length > 1}
<p class="mt-0.5">{lightboxIndex + 1} / {galleryImages.length}</p>
{/if}
</div>
<!-- Close -->
<button
class="absolute top-4 right-5 text-white/50 hover:text-white text-2xl transition-colors"
on:click={() => lightboxIndex = null}
aria-label="Close"
>×</button>
</div>
{/if}
<!-- Header -->
<div class="flex items-start gap-4 mb-6">
<a href={`${base}`} class="text-zinc-500 hover:text-white transition-colors mt-1 shrink-0">
@@ -61,24 +158,44 @@
</span>
</div>
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-white">{activity.title}</h1>
<h1 class="text-2xl font-bold text-white">{displayTitle}</h1>
{#if editUrl}
<a
href={`${editUrl}/edit/${activity.id}`}
target="_blank"
rel="noopener"
<button
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"
on:click={() => editOpen = true}
>
Edit
</a>
</button>
{/if}
</div>
{#if detail?.description}
<p class="text-zinc-400 mt-1 text-sm whitespace-pre-line">{detail.description}</p>
{#if descriptionHtml}
<div class="text-zinc-400 mt-2 text-sm leading-relaxed [&_img]:rounded-lg [&_img]:my-2 [&_p]:my-1 [&_a]:text-blue-400">
{@html descriptionHtml}
</div>
{/if}
</div>
</div>
<!-- Photo gallery -->
{#if galleryImages.length}
<div class="mb-4 grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2">
{#each galleryImages as img, i}
<button
class="relative overflow-hidden rounded-lg bg-zinc-800 aspect-square hover:opacity-90 transition-opacity focus:outline-none focus:ring-2 focus:ring-blue-500"
on:click={() => lightboxIndex = i}
aria-label="Open photo {i + 1}"
>
<img
src={imageBase + img}
alt={img}
class="w-full h-full object-cover"
loading="lazy"
/>
</button>
{/each}
</div>
{/if}
<!-- Map + Stats split -->
<div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-4 mb-4">
<!-- Map -->
+291
View File
@@ -0,0 +1,291 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Sport } from '../lib/types';
export let activityId: string;
export let editUrl: string;
const dispatch = createEventDispatcher<{ saved: { title: string; description: string } }>();
const SPORTS: Sport[] = ['cycling', 'running', 'hiking', 'walking', 'swimming', 'skiing', 'other'];
const STAT_PANELS = [
{ key: 'elevation', label: 'Elevation' },
{ key: 'speed', label: 'Speed' },
{ key: 'heart_rate', label: 'Heart rate' },
{ key: 'cadence', label: 'Cadence' },
{ key: 'power', label: 'Power' },
];
let loading = true;
let loadError = '';
let saving = false;
let saveStatus = '';
let saveOk = false;
// Form state
let title = '';
let sport: Sport = 'cycling';
let gear = '';
let description = '';
let highlight = false;
let isPrivate = false;
let hideStats: string[] = [];
let images: string[] = [];
// Image upload
let uploading = false;
let fileInput: HTMLInputElement;
const api = `${editUrl}/api/activity/${activityId}`;
async function load() {
loading = true;
loadError = '';
try {
const res = await fetch(api);
if (!res.ok) throw new Error(`Edit server returned ${res.status} — is bincio edit running?`);
const d = await res.json();
title = d.title ?? '';
sport = d.sport ?? 'cycling';
gear = d.gear ?? '';
description = d.description ?? '';
highlight = d.highlight ?? false;
isPrivate = d.private ?? false;
hideStats = d.hide_stats ?? [];
images = d.images ?? [];
} catch (e: any) {
loadError = e.message;
} finally {
loading = false;
}
}
async function save() {
saving = true;
saveStatus = '';
saveOk = false;
try {
const res = await fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, sport, gear, description, highlight, private: isPrivate, hide_stats: hideStats }),
});
if (!res.ok) throw new Error(await res.text());
saveStatus = 'Saved';
saveOk = true;
dispatch('saved', { title, description });
} catch (e: any) {
saveStatus = e.message;
saveOk = false;
} finally {
saving = false;
}
}
async function uploadImages(files: FileList) {
uploading = true;
for (const file of Array.from(files)) {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`${api}/images`, { method: 'POST', body: fd });
if (res.ok) {
const d = await res.json();
if (!images.includes(d.filename)) images = [...images, d.filename];
// Insert markdown reference at cursor or end
const ref = `\n![${d.filename.replace(/\.[^.]+$/, '')}](${d.filename})`;
description = description.trimEnd() + ref;
}
}
uploading = false;
}
async function deleteImage(filename: string) {
await fetch(`${api}/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
images = images.filter(f => f !== filename);
// Remove the markdown reference too
description = description.replace(new RegExp(`!\\[[^\\]]*\\]\\(${filename}\\)`, 'g'), '').trim();
}
function toggleStat(key: string) {
hideStats = hideStats.includes(key)
? hideStats.filter(s => s !== key)
: [...hideStats, key];
}
load();
</script>
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/60 z-40 backdrop-blur-sm"
on:click={() => dispatch('saved', { title, description })}
role="presentation"
/>
<!-- Drawer -->
<aside class="fixed top-0 right-0 h-full w-full max-w-md bg-zinc-950 border-l border-zinc-800 z-50 flex flex-col shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-zinc-800 shrink-0">
<h2 class="font-semibold text-white text-sm">Edit activity</h2>
<button
class="text-zinc-500 hover:text-white transition-colors text-xl leading-none"
on:click={() => dispatch('saved', { title, description })}
aria-label="Close"
>×</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto px-5 py-4">
{#if loading}
<div class="space-y-3 animate-pulse">
{#each Array(4) as _}
<div class="h-9 rounded bg-zinc-800" />
{/each}
</div>
{:else if loadError}
<p class="text-red-400 text-sm">{loadError}</p>
{:else}
<!-- Title -->
<div class="mb-4">
<label class="block text-xs text-zinc-500 mb-1" for="ed-title">Title</label>
<input
id="ed-title"
type="text"
bind:value={title}
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white outline-none focus:border-blue-500 transition-colors"
/>
</div>
<!-- Sport + Gear -->
<div class="grid grid-cols-2 gap-3 mb-4">
<div>
<label class="block text-xs text-zinc-500 mb-1" for="ed-sport">Sport</label>
<select
id="ed-sport"
bind:value={sport}
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white outline-none focus:border-blue-500 transition-colors"
>
{#each SPORTS as s}
<option value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
{/each}
</select>
</div>
<div>
<label class="block text-xs text-zinc-500 mb-1" for="ed-gear">Gear</label>
<input
id="ed-gear"
type="text"
bind:value={gear}
placeholder="e.g. Trek Domane"
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-600 outline-none focus:border-blue-500 transition-colors"
/>
</div>
</div>
<!-- Description -->
<div class="mb-4">
<label class="block text-xs text-zinc-500 mb-1" for="ed-desc">Description <span class="text-zinc-600">(markdown)</span></label>
<textarea
id="ed-desc"
bind:value={description}
rows={6}
placeholder="Write about this activity…"
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-600 outline-none focus:border-blue-500 transition-colors resize-y"
/>
</div>
<!-- Images -->
<div class="mb-4">
<p class="text-xs text-zinc-500 mb-2">Images</p>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="border border-dashed border-zinc-700 rounded-lg px-4 py-3 text-center text-xs text-zinc-500 cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors"
on:click={() => fileInput.click()}
on:dragover|preventDefault
on:drop|preventDefault={e => e.dataTransfer?.files && uploadImages(e.dataTransfer.files)}
>
{uploading ? 'Uploading…' : 'Drop images or click to upload'}
</div>
<input bind:this={fileInput} type="file" accept="image/*" multiple class="hidden"
on:change={e => e.currentTarget.files && uploadImages(e.currentTarget.files)} />
{#if images.length}
<div class="flex flex-wrap gap-2 mt-2">
{#each images as img}
<span class="flex items-center gap-1 text-xs bg-zinc-800 border border-zinc-700 rounded-full px-2 py-0.5">
{img}
<button class="text-zinc-500 hover:text-red-400 transition-colors" on:click={() => deleteImage(img)}>×</button>
</span>
{/each}
</div>
{/if}
</div>
<!-- Hide stats -->
<div class="mb-4">
<p class="text-xs text-zinc-500 mb-2">Hide stat panels</p>
<div class="flex flex-wrap gap-2">
{#each STAT_PANELS as panel}
<button
type="button"
class="text-xs px-3 py-1 rounded-full border transition-colors"
class:border-zinc-700={!hideStats.includes(panel.key)}
class:text-zinc-400={!hideStats.includes(panel.key)}
class:border-blue-500={hideStats.includes(panel.key)}
class:text-white={hideStats.includes(panel.key)}
style={hideStats.includes(panel.key) ? 'background:rgba(59,130,246,.15)' : ''}
on:click={() => toggleStat(panel.key)}
>
{panel.label}
</button>
{/each}
</div>
</div>
<!-- Flags -->
<div class="flex gap-3 mb-2">
<button
type="button"
class="flex items-center gap-2 text-xs px-3 py-1.5 rounded-lg border transition-colors"
class:border-zinc-700={!highlight}
class:text-zinc-400={!highlight}
class:border-yellow-500={highlight}
class:text-yellow-300={highlight}
style={highlight ? 'background:rgba(234,179,8,.1)' : ''}
on:click={() => highlight = !highlight}
>
★ Highlight
</button>
<button
type="button"
class="flex items-center gap-2 text-xs px-3 py-1.5 rounded-lg border transition-colors"
class:border-zinc-700={!isPrivate}
class:text-zinc-400={!isPrivate}
class:border-red-500={isPrivate}
class:text-red-300={isPrivate}
style={isPrivate ? 'background:rgba(239,68,68,.1)' : ''}
on:click={() => isPrivate = !isPrivate}
>
⊘ Private
</button>
</div>
{/if}
</div>
<!-- Footer -->
{#if !loading && !loadError}
<div class="px-5 py-4 border-t border-zinc-800 flex items-center gap-3 shrink-0">
<button
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-sm font-medium rounded-lg transition-colors"
disabled={saving}
on:click={save}
>
{saving ? 'Saving…' : 'Save'}
</button>
{#if saveStatus}
<span class="text-xs" class:text-green-400={saveOk} class:text-red-400={!saveOk}>
{saveStatus}
</span>
{/if}
</div>
{/if}
</aside>
+206
View File
@@ -0,0 +1,206 @@
"""Tests for bincio.render.merge — sidecar edit overlay logic."""
import json
import textwrap
from pathlib import Path
import pytest
from bincio.render.merge import apply_sidecar, merge_all, parse_sidecar
# ── parse_sidecar ─────────────────────────────────────────────────────────────
def test_parse_sidecar_full(tmp_path):
md = tmp_path / "act.md"
md.write_text(textwrap.dedent("""\
---
title: "Ride to the coast"
sport: cycling
highlight: true
private: false
hide_stats: [cadence, power]
gear: "Trek Domane"
---
Great day out with Marco.
"""))
fm, body = parse_sidecar(md)
assert fm["title"] == "Ride to the coast"
assert fm["sport"] == "cycling"
assert fm["highlight"] is True
assert fm["private"] is False
assert fm["hide_stats"] == ["cadence", "power"]
assert fm["gear"] == "Trek Domane"
assert body == "Great day out with Marco."
def test_parse_sidecar_no_frontmatter(tmp_path):
md = tmp_path / "act.md"
md.write_text("Just a description, no frontmatter.\n")
fm, body = parse_sidecar(md)
assert fm == {}
assert body == "Just a description, no frontmatter."
def test_parse_sidecar_frontmatter_only(tmp_path):
md = tmp_path / "act.md"
md.write_text("---\ntitle: Solo spin\n---\n")
fm, body = parse_sidecar(md)
assert fm["title"] == "Solo spin"
assert body == ""
# ── apply_sidecar ─────────────────────────────────────────────────────────────
BASE_DETAIL = {
"id": "2024-01-01T08:00:00Z_cycling",
"title": "Morning Ride",
"sport": "cycling",
"started_at": "2024-01-01T08:00:00Z",
"description": "Original description from Strava.",
"privacy": "public",
"gear": None,
"custom": {},
}
def test_apply_sidecar_title_and_sport():
fm = {"title": "Renamed", "sport": "gravel"}
result = apply_sidecar(BASE_DETAIL, fm, "")
assert result["title"] == "Renamed"
assert result["sport"] == "gravel"
# Original must be unchanged
assert BASE_DETAIL["title"] == "Morning Ride"
def test_apply_sidecar_body_becomes_description():
result = apply_sidecar(BASE_DETAIL, {}, "My **epic** ride.")
assert result["description"] == "My **epic** ride."
def test_apply_sidecar_body_takes_precedence_over_fm_description():
fm = {"description": "FM description"}
result = apply_sidecar(BASE_DETAIL, fm, "Body description")
assert result["description"] == "Body description"
def test_apply_sidecar_private_flag():
result = apply_sidecar(BASE_DETAIL, {"private": True}, "")
assert result["privacy"] == "private"
def test_apply_sidecar_highlight():
result = apply_sidecar(BASE_DETAIL, {"highlight": True}, "")
assert result["custom"]["highlight"] is True
def test_apply_sidecar_hide_stats():
result = apply_sidecar(BASE_DETAIL, {"hide_stats": ["cadence", "power"]}, "")
assert result["custom"]["hide_stats"] == ["cadence", "power"]
def test_apply_sidecar_does_not_mutate_input():
fm = {"title": "New title", "highlight": True}
original_custom = BASE_DETAIL["custom"]
apply_sidecar(BASE_DETAIL, fm, "")
assert BASE_DETAIL["title"] == "Morning Ride"
assert BASE_DETAIL["custom"] is original_custom
assert "highlight" not in original_custom
# ── merge_all ─────────────────────────────────────────────────────────────────
@pytest.fixture()
def data_dir(tmp_path):
acts = tmp_path / "activities"
acts.mkdir()
# Two activities
for act_id, title in [
("2024-01-01T08:00:00Z_cycling", "Morning Ride"),
("2024-01-02T09:00:00Z_running", "Easy Run"),
]:
detail = {
"id": act_id, "title": title, "sport": act_id.split("_")[1],
"started_at": act_id.split("_")[0],
"description": "", "privacy": "public", "custom": {},
}
(acts / f"{act_id}.json").write_text(json.dumps(detail))
# Index
index = {"activities": [
{"id": "2024-01-01T08:00:00Z_cycling", "title": "Morning Ride",
"sport": "cycling", "started_at": "2024-01-01T08:00:00Z", "privacy": "public", "custom": {}},
{"id": "2024-01-02T09:00:00Z_running", "title": "Easy Run",
"sport": "running", "started_at": "2024-01-02T09:00:00Z", "privacy": "public", "custom": {}},
]}
(tmp_path / "index.json").write_text(json.dumps(index))
return tmp_path
def test_merge_all_no_sidecars(data_dir):
n = merge_all(data_dir)
assert n == 0
merged = data_dir / "_merged"
assert merged.exists()
# Unmodified files are symlinked
detail_link = merged / "activities" / "2024-01-01T08:00:00Z_cycling.json"
assert detail_link.is_symlink()
def test_merge_all_applies_sidecar(data_dir):
edits = data_dir / "edits"
edits.mkdir()
(edits / "2024-01-01T08:00:00Z_cycling.md").write_text(
"---\ntitle: Epic Ride\nhighlight: true\n---\n\nWhat a day!"
)
n = merge_all(data_dir)
assert n == 1
merged_json = data_dir / "_merged" / "activities" / "2024-01-01T08:00:00Z_cycling.json"
assert not merged_json.is_symlink()
data = json.loads(merged_json.read_text())
assert data["title"] == "Epic Ride"
assert data["custom"]["highlight"] is True
assert data["description"] == "What a day!"
# Untouched activity is still a symlink
run_link = data_dir / "_merged" / "activities" / "2024-01-02T09:00:00Z_running.json"
assert run_link.is_symlink()
def test_merge_all_private_filtered_from_index(data_dir):
edits = data_dir / "edits"
edits.mkdir()
(edits / "2024-01-01T08:00:00Z_cycling.md").write_text("---\nprivate: true\n---\n")
merge_all(data_dir)
index = json.loads((data_dir / "_merged" / "index.json").read_text())
ids = [a["id"] for a in index["activities"]]
assert "2024-01-01T08:00:00Z_cycling" not in ids
assert "2024-01-02T09:00:00Z_running" in ids
def test_merge_all_highlight_sorts_first(data_dir):
edits = data_dir / "edits"
edits.mkdir()
# Highlight the older activity — it should appear first
(edits / "2024-01-01T08:00:00Z_cycling.md").write_text("---\nhighlight: true\n---\n")
merge_all(data_dir)
index = json.loads((data_dir / "_merged" / "index.json").read_text())
ids = [a["id"] for a in index["activities"]]
assert ids[0] == "2024-01-01T08:00:00Z_cycling"
def test_merge_all_idempotent(data_dir):
edits = data_dir / "edits"
edits.mkdir()
(edits / "2024-01-01T08:00:00Z_cycling.md").write_text("---\ntitle: Renamed\n---\n")
merge_all(data_dir)
merge_all(data_dir) # second run should not error or double-apply
data = json.loads(
(data_dir / "_merged" / "activities" / "2024-01-01T08:00:00Z_cycling.json").read_text()
)
assert data["title"] == "Renamed"