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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -0,0 +1,433 @@
|
||||
"""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.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)
|
||||
|
||||
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")
|
||||
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
@@ -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)}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user