Settings: per-user default for download_disabled

New pref download_disabled_default (stored in user_prefs + mirrored to
_user_settings.json for the render pipeline). When true, apply_sidecar
marks all activities as download_disabled unless the sidecar explicitly
sets download_disabled: false (per-activity opt-in from the edit drawer).

Settings page gets an "Activity defaults" card with the toggle.
This commit is contained in:
Davide Scaini
2026-05-16 20:51:23 +02:00
parent 2d9620c6d1
commit de602ff5d9
4 changed files with 79 additions and 5 deletions
+5 -1
View File
@@ -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] hide = [s for s in (payload.get("hide_stats") or []) if s in STAT_PANELS]
if hide: if hide:
lines.append(f"hide_stats: [{', '.join(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") 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() description = (payload.get("description") or "").strip()
+15 -4
View File
@@ -69,7 +69,7 @@ def parse_sidecar(path: Path) -> tuple[dict, str]:
return {}, text.strip() 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.""" """Apply sidecar overrides to a detail JSON dict. Returns a modified copy."""
from bincio.extract.writer import _infer_indoor_title from bincio.extract.writer import _infer_indoor_title
d = dict(detail) 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") d["privacy"] = "unlisted" if fm["private"] else detail.get("privacy", "public")
if "hide_stats" in fm: if "hide_stats" in fm:
d["custom"]["hide_stats"] = [str(s) for s in (fm["hide_stats"] or [])] 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 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 return d
@@ -239,6 +243,13 @@ def _merge_all_locked(data_dir: Path) -> int:
merged_dir = data_dir / "_merged" merged_dir = data_dir / "_merged"
merged_acts = merged_dir / "activities" 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 # Collect sidecars upfront
sidecars: dict[str, tuple[dict, str]] = {} sidecars: dict[str, tuple[dict, str]] = {}
if edits_dir.exists(): 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")) detail = json.loads(src.read_text(encoding="utf-8"))
if activity_id in sidecars: if activity_id in sidecars:
fm, body = sidecars[activity_id] fm, body = sidecars[activity_id]
detail = apply_sidecar(detail, fm, body) detail = apply_sidecar(detail, fm, body, download_disabled_default=_dl_default)
else: else:
detail = apply_sidecar(detail, {}, "") detail = apply_sidecar(detail, {}, "", download_disabled_default=_dl_default)
if activity_id in image_lists: if activity_id in image_lists:
detail["custom"] = dict(detail.get("custom") or {}) detail["custom"] = dict(detail.get("custom") or {})
detail["custom"]["images"] = image_lists[activity_id] detail["custom"]["images"] = image_lists[activity_id]
+12
View File
@@ -222,6 +222,18 @@ async def me_set_prefs(
# Coerce all values to strings; ignore unknown keys silently # Coerce all values to strings; ignore unknown keys silently
prefs = {str(k): str(v) for k, v in body.items()} prefs = {str(k): str(v) for k, v in body.items()}
set_user_prefs(deps._get_db(), user.handle, prefs) 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}) return JSONResponse({"ok": True})
+47
View File
@@ -95,6 +95,18 @@ import Base from '../../layouts/Base.astro';
<p id="nav-prefs-status" class="text-xs mt-3 hidden"></p> <p id="nav-prefs-status" class="text-xs mt-3 hidden"></p>
</section> </section>
<!-- Activity defaults card -->
<section class="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-5">
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-1">Activity defaults</h2>
<p class="text-xs text-zinc-600 mb-4">Applied to all activities that don't have an explicit per-activity override.</p>
<label class="flex items-center gap-3 cursor-pointer group">
<input id="pref-download-disabled" type="checkbox" class="accent-[--accent]" />
<span class="text-sm text-zinc-300 group-hover:text-white transition-colors">Disable downloads by default</span>
</label>
<p class="text-xs text-zinc-500 mt-2">When enabled, activity files cannot be downloaded by visitors. You can still override this per activity from the edit drawer.</p>
<p id="activity-defaults-status" class="text-xs mt-3 hidden"></p>
</section>
<!-- Strava credentials card --> <!-- Strava credentials card -->
<section class="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-5"> <section class="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-5">
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-1">Strava API credentials</h2> <h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-1">Strava API credentials</h2>
@@ -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 ──────────────────────────────────────────────────────── // ── Strava credentials ────────────────────────────────────────────────────────
async function loadStravaCreds() { async function loadStravaCreds() {
@@ -561,6 +607,7 @@ import Base from '../../layouts/Base.astro';
loadMe(); loadMe();
loadStorage(); loadStorage();
loadNavPrefs(); loadNavPrefs();
loadActivityDefaults();
loadStravaCreds(); loadStravaCreds();
loadStravaConnection(); loadStravaConnection();
</script> </script>