diff --git a/bincio/edit/ops.py b/bincio/edit/ops.py index 81766ef..86e7990 100644 --- a/bincio/edit/ops.py +++ b/bincio/edit/ops.py @@ -50,8 +50,12 @@ def apply_sidecar_edit(activity_id: str, payload: dict[str, Any], data_dir: Path hide = [s for s in (payload.get("hide_stats") or []) if s in STAT_PANELS] if hide: lines.append(f"hide_stats: [{', '.join(hide)}]") - if payload.get("download_disabled"): + dd = payload.get("download_disabled") + if dd is True: lines.append("download_disabled: true") + elif dd is False: + # Explicit false: allows per-activity opt-in against a user-level default + lines.append("download_disabled: false") description = (payload.get("description") or "").strip() diff --git a/bincio/render/merge.py b/bincio/render/merge.py index 6f3ec9e..e4c0b52 100644 --- a/bincio/render/merge.py +++ b/bincio/render/merge.py @@ -69,7 +69,7 @@ def parse_sidecar(path: Path) -> tuple[dict, str]: return {}, text.strip() -def apply_sidecar(detail: dict, fm: dict, body: str) -> dict: +def apply_sidecar(detail: dict, fm: dict, body: str, *, download_disabled_default: bool = False) -> dict: """Apply sidecar overrides to a detail JSON dict. Returns a modified copy.""" from bincio.extract.writer import _infer_indoor_title d = dict(detail) @@ -97,8 +97,12 @@ def apply_sidecar(detail: dict, fm: dict, body: str) -> dict: d["privacy"] = "unlisted" 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 [])] - if "download_disabled" in fm: + dd = fm.get("download_disabled") # True, False, or None (absent) + if dd is True: d["download_disabled"] = True + elif dd is None and download_disabled_default: + d["download_disabled"] = True + # dd is False → explicit per-activity opt-in, leave unset return d @@ -239,6 +243,13 @@ def _merge_all_locked(data_dir: Path) -> int: merged_dir = data_dir / "_merged" merged_acts = merged_dir / "activities" + _settings_path = data_dir / "_user_settings.json" + try: + _user_settings = json.loads(_settings_path.read_text(encoding="utf-8")) if _settings_path.exists() else {} + except (OSError, json.JSONDecodeError): + _user_settings = {} + _dl_default: bool = bool(_user_settings.get("download_disabled_default", False)) + # Collect sidecars upfront sidecars: dict[str, tuple[dict, str]] = {} if edits_dir.exists(): @@ -287,9 +298,9 @@ def _merge_all_locked(data_dir: Path) -> int: 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) + detail = apply_sidecar(detail, fm, body, download_disabled_default=_dl_default) else: - detail = apply_sidecar(detail, {}, "") + detail = apply_sidecar(detail, {}, "", download_disabled_default=_dl_default) if activity_id in image_lists: detail["custom"] = dict(detail.get("custom") or {}) detail["custom"]["images"] = image_lists[activity_id] diff --git a/bincio/serve/routers/me.py b/bincio/serve/routers/me.py index bd6c76c..f752f82 100644 --- a/bincio/serve/routers/me.py +++ b/bincio/serve/routers/me.py @@ -222,6 +222,18 @@ async def me_set_prefs( # Coerce all values to strings; ignore unknown keys silently prefs = {str(k): str(v) for k, v in body.items()} set_user_prefs(deps._get_db(), user.handle, prefs) + + # Mirror download_disabled_default to a file so the render pipeline can read it + if "download_disabled_default" in prefs: + user_dir = deps._get_data_dir() / user.handle + settings_path = user_dir / "_user_settings.json" + try: + current = json.loads(settings_path.read_text(encoding="utf-8")) if settings_path.exists() else {} + except (OSError, json.JSONDecodeError): + current = {} + current["download_disabled_default"] = prefs["download_disabled_default"] == "true" + settings_path.write_text(json.dumps(current, indent=2), encoding="utf-8") + return JSONResponse({"ok": True}) diff --git a/site/src/pages/settings/index.astro b/site/src/pages/settings/index.astro index fac2eff..677180b 100644 --- a/site/src/pages/settings/index.astro +++ b/site/src/pages/settings/index.astro @@ -95,6 +95,18 @@ import Base from '../../layouts/Base.astro'; + +
+

Activity defaults

+

Applied to all activities that don't have an explicit per-activity override.

+ +

When enabled, activity files cannot be downloaded by visitors. You can still override this per activity from the edit drawer.

+ +
+

Strava API credentials

@@ -456,6 +468,40 @@ import Base from '../../layouts/Base.astro'; }); } + // ── Activity defaults ───────────────────────────────────────────────────────── + + const dlDefaultEl = document.getElementById('pref-download-disabled') as HTMLInputElement; + const dlStatusEl = document.getElementById('activity-defaults-status')!; + + async function loadActivityDefaults() { + try { + const r = await fetch('/api/me/prefs', { credentials: 'include' }); + if (!r.ok) return; + const prefs = await r.json(); + dlDefaultEl.checked = prefs['download_disabled_default'] === 'true'; + } catch {} + } + + dlDefaultEl?.addEventListener('change', async () => { + try { + const r = await fetch('/api/me/prefs', { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ download_disabled_default: String(dlDefaultEl.checked) }), + }); + if (r.ok) { + setStatus(dlStatusEl, 'Saved.', true); + setTimeout(() => dlStatusEl.classList.add('hidden'), 2000); + } else { + const d = await r.json(); + setStatus(dlStatusEl, d.detail ?? 'Failed', false); + } + } catch { + setStatus(dlStatusEl, 'Could not reach server', false); + } + }); + // ── Strava credentials ──────────────────────────────────────────────────────── async function loadStravaCreds() { @@ -561,6 +607,7 @@ import Base from '../../layouts/Base.astro'; loadMe(); loadStorage(); loadNavPrefs(); + loadActivityDefaults(); loadStravaCreds(); loadStravaConnection();