Compare commits
181 Commits
mobile_app
...
5287b98bc1
| Author | SHA1 | Date | |
|---|---|---|---|
| 5287b98bc1 | |||
| 0e5044eb06 | |||
| 75f7fa8810 | |||
| 5255e24184 | |||
| c59fc0073f | |||
| a142e8732f | |||
| 13859a34d3 | |||
| 1dca00d5e3 | |||
| fa61801580 | |||
| 2af29a460b | |||
| 0d6bf57932 | |||
| 447d56a960 | |||
| 2f5251e9fe | |||
| c9b544ab55 | |||
| b827792d16 | |||
| 94cd3f7eb4 | |||
| bdee036204 | |||
| 7db7bf91e0 | |||
| 801140ac51 | |||
| 49feef66c5 | |||
| b23b3de1bb | |||
| 5bf426df29 | |||
| 40ccec0e2d | |||
| e553e08663 | |||
| aca9f79b46 | |||
| 40aa51be4d | |||
| e5c5383471 | |||
| 693f720cbd | |||
| 56932f7f25 | |||
| 02edb0b0f9 | |||
| df025873c6 | |||
| 7f2a751065 | |||
| 793b719983 | |||
| d4e5b11f71 | |||
| 418e3a13e8 | |||
| 84eff1f3b0 | |||
| 835968e8fe | |||
| 29c6e399c0 | |||
| 1f6239e7d2 | |||
| 5d2e2443a3 | |||
| 90283c45f4 | |||
| cd80b8e32e | |||
| adaa075e6e | |||
| bbfab72138 | |||
| 6faf63c2cd | |||
| c0f6c4da6d | |||
| 766da0320b | |||
| 7a44cbbef0 | |||
| 979a6c527f | |||
| 6bc77486f1 | |||
| 9521a64da4 | |||
| 7953e05241 | |||
| db9b4ce32c | |||
| 14a4a0b994 | |||
| 0dc450ba30 | |||
| 1cca485062 | |||
| f71fe2ddf5 | |||
| 08e8e54c36 | |||
| 003b540481 | |||
| 7cd8a6b030 | |||
| baf20b51ba | |||
| de602ff5d9 | |||
| 2d9620c6d1 | |||
| 2c69e75842 | |||
| 0eb25620ef | |||
| 307f1fbbc1 | |||
| c465e518e5 | |||
| fe437626e6 | |||
| d3bce49445 | |||
| 8a06227243 | |||
| 1f3f5b3d3b | |||
| d2151a4acf | |||
| 9cc70269f5 | |||
| afbcaa5011 | |||
| 15e9969ca2 | |||
| c905449114 | |||
| ed6a7ed39c | |||
| 3b675a68b0 | |||
| c12f5336f5 | |||
| 4ea2292e2b | |||
| 0fbb7822df | |||
| a863cdd663 | |||
| 9f1e9e4d3b | |||
| de07d8d4cf | |||
| 1ce94b8536 | |||
| b509db4940 | |||
| 653db2428f | |||
| 5167f2a988 | |||
| 8af6b7b04e | |||
| 16965a7645 | |||
| c36b95e041 | |||
| 862226305a | |||
| 8ff781661e | |||
| 4d6859b927 | |||
| b32553b0b1 | |||
| 8804bdec37 | |||
| 487ce42361 | |||
| 46445dd1cb | |||
| ab112788b4 | |||
| 8d799e8e64 | |||
| cfb7198d64 | |||
| 2b9e080b4c | |||
| 20bb5bfb60 | |||
| dc719a55d5 | |||
| 5593764fdb | |||
| e7228c2be8 | |||
| 298fe3ea39 | |||
| 4e32cf4f21 | |||
| a75fabecb9 | |||
| b3c41967f6 | |||
| 6d13993f98 | |||
| 537d1bb712 | |||
| 5307ae287c | |||
| 2daa66d7b0 | |||
| 1a7d1dc8c3 | |||
| e7c5af0d01 | |||
| a10164b932 | |||
| c85d2edf39 | |||
| 7ec91b0e6a | |||
| 27f6d141f7 | |||
| 8380b1d2cc | |||
| 2ec4d9157c | |||
| 9dd533825f | |||
| e61d05fc41 | |||
| cd97e4cc87 | |||
| 58a5d5b450 | |||
| fb033e3da2 | |||
| e4b53dde44 | |||
| a4b4d11fc0 | |||
| fc012b5311 | |||
| b5a1e881fb | |||
| b9a21e8bcc | |||
| aa1c0b38c0 | |||
| c2c4cb9f3a | |||
| d82033fd84 | |||
| c30a15d295 | |||
| 38f2e51788 | |||
| 9553ca5ce7 | |||
| cf9817e853 | |||
| 6e92ea4fce | |||
| 994f4287ef | |||
| ad9e428b1e | |||
| c837464a28 | |||
| 2395a6e566 | |||
| cb3c9b6e41 | |||
| 861748a059 | |||
| f00e5e47b2 | |||
| 0ff5473dfd | |||
| 59cf99f0af | |||
| b8fd4e4ded | |||
| d7fd585e77 | |||
| f2075e29d2 | |||
| c7f0013e57 | |||
| 6c9de35426 | |||
| e9e7b5d0e7 | |||
| 4d2df860ce | |||
| 61db0734d2 | |||
| dd9f7a82dc | |||
| 79cad29ff1 | |||
| 6b2698c0c5 | |||
| c46e91d0f5 | |||
| bd0595ee79 | |||
| f1fec6d825 | |||
| a5db6142b3 | |||
| 1298586a74 | |||
| 3231fdb4b7 | |||
| 0b266d208c | |||
| 867da767eb | |||
| 93f6109028 | |||
| 8fbbf460a9 | |||
| 14313ec59c | |||
| 1eaf5c4e0b | |||
| 5be58f4e1c | |||
| 695dc9fdce | |||
| 8f028101c7 | |||
| 55d59112ad | |||
| 2287d6e2ee | |||
| 12693dbd60 | |||
| 680ef9d440 | |||
| 48ffc5be8e | |||
| 8c10ff5574 |
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## [unreleased] — 2026-05-19
|
||||
|
||||
### Performance — activity detail page
|
||||
|
||||
Four targeted fixes that together eliminate the blank loading screen and
|
||||
reduce timeseries payload size for the dominant use case.
|
||||
|
||||
**sessionStorage summary passthrough** (`ActivityFeed.svelte`,
|
||||
`ActivityDetailLoader.svelte`): when the user clicks an activity from the
|
||||
feed, the summary object is written to sessionStorage before navigation and
|
||||
read back synchronously at module init on the detail page — before the first
|
||||
render. The "Loading activity…" screen and the two sequential index-fetch
|
||||
round trips are eliminated entirely for this path. Direct URLs and bookmarks
|
||||
fall through to the existing slow path unchanged.
|
||||
|
||||
**Spatial 10 m downsampling** (`bincio/extract/timeseries.py`): timeseries
|
||||
are now downsampled to one sample per 10 m of distance traveled (GPS
|
||||
haversine primary, speed × Δt fallback) instead of one per second. Indoor
|
||||
activities with neither GPS nor speed data are left at 1 s resolution.
|
||||
Running activities see ~67 % fewer points; long cycling rides ~30 %. A
|
||||
`bincio render --downsample-timeseries` migration flag retroactively
|
||||
downsamples all existing stored files without re-extracting from FIT/GPX.
|
||||
|
||||
**nginx timeseries caching** (`deploy/vps/nginx-activity.conf`): a regex
|
||||
location block before the generic `/data/` handler serves `*.timeseries.json`
|
||||
with `Cache-Control: public, max-age=3600, stale-while-revalidate=3600`.
|
||||
Previously every page view triggered a conditional GET even when nothing had
|
||||
changed.
|
||||
|
||||
**asyncio.to_thread for segment_efforts** (`bincio/serve/routers/activities.py`):
|
||||
the synchronous file scan in `GET /api/activities/{id}/segment_efforts` is
|
||||
now dispatched via `asyncio.to_thread` so it runs in a thread pool instead of
|
||||
blocking the event loop during concurrent fetches.
|
||||
|
||||
### Performance — static asset caching
|
||||
|
||||
**Immutable JS/CSS caching** (`deploy/vps/nginx-activity.conf`): Astro
|
||||
content-hashes all `/_astro/*.js` and `/_astro/*.css` filenames at build time.
|
||||
A new nginx location block serves them with `max-age=31536000, immutable` so
|
||||
browsers never revalidate until the hash changes. HTML pages get an explicit
|
||||
`no-cache, must-revalidate` header so the latest asset URLs are always fetched
|
||||
after a deploy.
|
||||
|
||||
### Tooling
|
||||
|
||||
**VPS backup script** (`deploy/vps/backup-vps.sh`): extended to pull
|
||||
`nginx-wiki.conf` and `nginx-planner.conf` in addition to the existing files.
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] — 2026-04-22
|
||||
|
||||
### Improvement — DEM & hysteresis algorithm refinements
|
||||
|
||||
@@ -19,6 +19,27 @@ from bincio.serve.init_cmd import init # noqa: E402
|
||||
from bincio.serve.cli import serve # noqa: E402
|
||||
from bincio.dev import dev # noqa: E402
|
||||
from bincio.reextract_cmd import reextract_originals # noqa: E402
|
||||
from bincio.sync_strava import sync_strava_cmd # noqa: E402
|
||||
from bincio.sync_garmin import sync_garmin_cmd # noqa: E402
|
||||
from bincio.segments.cli import segments_group # noqa: E402
|
||||
|
||||
|
||||
@main.command("bake-tracks")
|
||||
@click.option("--data-dir", required=True, help="BAS data store directory.")
|
||||
@click.option("--handle", default=None, help="Bake one user only (default: all).")
|
||||
def bake_tracks_cmd(data_dir: str, handle: str | None) -> None:
|
||||
"""Pre-bake GPS tracks.json for the Explore heatmap page."""
|
||||
from pathlib import Path
|
||||
from bincio.explore import bake_tracks
|
||||
from bincio.render.cli import _user_dirs
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
data = Path(data_dir).expanduser().resolve()
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
n = bake_tracks(user_dir.name, data)
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {n} track(s) baked")
|
||||
|
||||
|
||||
main.add_command(extract)
|
||||
main.add_command(render)
|
||||
@@ -28,3 +49,6 @@ main.add_command(init)
|
||||
main.add_command(serve)
|
||||
main.add_command(dev)
|
||||
main.add_command(reextract_originals)
|
||||
main.add_command(sync_strava_cmd)
|
||||
main.add_command(sync_garmin_cmd)
|
||||
main.add_command(segments_group)
|
||||
|
||||
+3
-2
@@ -87,9 +87,10 @@ def _start_serve(data: Path, api_port: int, site: Path, api_host: str = "127.0.0
|
||||
"""Start bincio serve in a background thread."""
|
||||
import uvicorn
|
||||
import bincio.serve.server as srv
|
||||
from bincio.serve import deps
|
||||
|
||||
srv.data_dir = data
|
||||
srv.site_dir = site
|
||||
deps.data_dir = data
|
||||
deps.site_dir = site
|
||||
|
||||
config = uvicorn.Config(
|
||||
srv.app,
|
||||
|
||||
@@ -13,7 +13,10 @@ from typing import Any, Optional
|
||||
|
||||
# ── Shared constants (imported by edit/server.py and serve/server.py) ─────────
|
||||
|
||||
from bincio.extract.sport import SUB_SPORTS as _SUB_SPORTS
|
||||
|
||||
SPORTS = ["cycling", "running", "hiking", "walking", "swimming", "skiing", "other"]
|
||||
_VALID_SUB_SPORTS = {v for vs in _SUB_SPORTS.values() for v in vs}
|
||||
STAT_PANELS = ["elevation", "speed", "heart_rate", "cadence", "power"]
|
||||
VALID_ACTIVITY_ID = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9\-]{0,250}$')
|
||||
|
||||
@@ -36,6 +39,8 @@ def apply_sidecar_edit(activity_id: str, payload: dict[str, Any], data_dir: Path
|
||||
lines.append(f"title: {json.dumps(payload['title'])}")
|
||||
if payload.get("sport") and payload["sport"] in SPORTS and payload["sport"] != "other":
|
||||
lines.append(f"sport: {payload['sport']}")
|
||||
if payload.get("sub_sport") and payload["sub_sport"] in _VALID_SUB_SPORTS:
|
||||
lines.append(f"sub_sport: {payload['sub_sport']}")
|
||||
if payload.get("gear"):
|
||||
lines.append(f"gear: {json.dumps(payload['gear'])}")
|
||||
if payload.get("highlight"):
|
||||
@@ -45,6 +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)}]")
|
||||
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()
|
||||
|
||||
|
||||
+6
-303
@@ -43,308 +43,12 @@ def _check_id(activity_id: str) -> str:
|
||||
return activity_id
|
||||
|
||||
|
||||
_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||
_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES
|
||||
from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES
|
||||
from bincio.shared.images import unique_image_name as _unique_image_name
|
||||
|
||||
|
||||
def _unique_image_name(directory: Path, filename: str) -> str:
|
||||
"""Return a filename that does not collide with existing files in directory."""
|
||||
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||
candidate = filename
|
||||
counter = 1
|
||||
while (directory / candidate).exists():
|
||||
candidate = f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
# ── 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"> Unlisted (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 escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
function renderImageList() {
|
||||
const list = document.getElementById('image-list');
|
||||
list.innerHTML = uploadedImages.map(f =>
|
||||
`<span class="image-chip">${escapeHtml(f)}
|
||||
<button type="button" onclick="removeImage('${escapeHtml(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>
|
||||
"""
|
||||
_TEMPLATE_PATH = Path(__file__).parent / "templates" / "edit.html"
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -369,13 +73,12 @@ async def edit_page(activity_id: str) -> str:
|
||||
f'<label class="check-item"><input type="checkbox" data-stat="{s}"> {s.replace("_", " ").capitalize()}</label>'
|
||||
for s in STAT_PANELS
|
||||
)
|
||||
html = (
|
||||
_HTML
|
||||
return (
|
||||
_TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
.replace("__SITE_URL__", site_url)
|
||||
.replace("__SPORT_OPTIONS__", sport_opts)
|
||||
.replace("__STAT_CHECKBOXES__", stat_cbs)
|
||||
)
|
||||
return html
|
||||
|
||||
|
||||
@app.get("/api/activity/{activity_id}")
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
<!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"> Unlisted (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 escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
function renderImageList() {
|
||||
const list = document.getElementById('image-list');
|
||||
list.innerHTML = uploadedImages.map(f =>
|
||||
`<span class="image-chip">${escapeHtml(f)}
|
||||
<button type="button" onclick="removeImage('${escapeHtml(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>
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Pre-bake per-handle GPS tracks for the Explore page.
|
||||
|
||||
Reads all activity GeoJSON files for a handle, applies RDP simplification,
|
||||
and writes per-year tracks_YYYY.json shards plus a tracks_index.json manifest
|
||||
for progressive client-side loading.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from bincio.extract.simplify import _rdp_mask
|
||||
|
||||
_VERSION = 2
|
||||
_RDP_EPSILON = 0.0001 # ~10 m on the ground
|
||||
|
||||
|
||||
_SPORT_MAP: dict[str, str] = {
|
||||
"cycling": "cycling", "road_cycling": "cycling", "gravel_cycling": "cycling",
|
||||
"mountain_biking": "cycling", "e_biking": "cycling", "indoor_cycling": "cycling",
|
||||
"biking": "cycling", "bike": "cycling", "ride": "cycling",
|
||||
"running": "running", "trail_running": "running", "treadmill_running": "running",
|
||||
"jogging": "running",
|
||||
"hiking": "hiking", "walking": "hiking", "trekking": "hiking",
|
||||
"mountaineering": "hiking",
|
||||
"skiing": "skiing", "cross_country_skiing": "skiing", "alpine_skiing": "skiing",
|
||||
"snowboarding": "skiing",
|
||||
}
|
||||
|
||||
|
||||
def _sport_to_type(sport: str | None) -> str:
|
||||
if not sport:
|
||||
return "other"
|
||||
return _SPORT_MAP.get(sport.lower(), "other")
|
||||
|
||||
|
||||
def bake_tracks(handle: str, data_dir: Path) -> int:
|
||||
"""Build tracks.json for handle. Returns number of tracks included."""
|
||||
acts_dir = data_dir / handle / "activities"
|
||||
if not acts_dir.exists():
|
||||
return 0
|
||||
|
||||
tracks = []
|
||||
for gj_path in sorted(acts_dir.glob("*.geojson")):
|
||||
act_id = gj_path.stem
|
||||
|
||||
meta: dict = {}
|
||||
meta_path = acts_dir / f"{act_id}.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
else:
|
||||
# bare-timestamp geojson with no metadata — superseded by a slug version
|
||||
if list(acts_dir.glob(f"{act_id}-*.geojson")):
|
||||
continue
|
||||
|
||||
if meta.get("virtual") or meta.get("sub_sport") == "indoor":
|
||||
continue
|
||||
|
||||
try:
|
||||
gj = json.loads(gj_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
raw_coords = gj.get("geometry", {}).get("coordinates") or []
|
||||
if len(raw_coords) < 2:
|
||||
continue
|
||||
|
||||
lng_lat = [[float(c[0]), float(c[1])] for c in raw_coords if len(c) >= 2]
|
||||
if len(lng_lat) < 2:
|
||||
continue
|
||||
|
||||
mask = _rdp_mask(lng_lat, epsilon=_RDP_EPSILON)
|
||||
simplified = [pt for pt, keep in zip(lng_lat, mask) if keep]
|
||||
if len(simplified) < 2:
|
||||
continue
|
||||
|
||||
tracks.append({
|
||||
"id": act_id,
|
||||
"date": (meta.get("started_at") or "")[:10],
|
||||
"type": _sport_to_type(meta.get("sport")),
|
||||
"name": meta.get("title") or act_id,
|
||||
"dist": int(meta.get("distance_m") or 0),
|
||||
"coords": simplified,
|
||||
})
|
||||
|
||||
tracks.sort(key=lambda t: t["date"], reverse=True)
|
||||
|
||||
user_dir = data_dir / handle
|
||||
now = int(time.time())
|
||||
|
||||
# Group into per-year buckets
|
||||
by_year: dict[str, list] = {}
|
||||
for t in tracks:
|
||||
year = t["date"][:4] or "0000"
|
||||
by_year.setdefault(year, []).append(t)
|
||||
|
||||
# Remove stale year shards that no longer have data
|
||||
for old in user_dir.glob("tracks_*.json"):
|
||||
stem = old.stem # e.g. "tracks_2024" or "tracks_index"
|
||||
if stem == "tracks_index":
|
||||
continue
|
||||
year_part = stem[len("tracks_"):]
|
||||
if year_part not in by_year:
|
||||
old.unlink(missing_ok=True)
|
||||
|
||||
# Write per-year shards
|
||||
for year, year_tracks in by_year.items():
|
||||
shard_path = user_dir / f"tracks_{year}.json"
|
||||
shard_path.write_text(
|
||||
json.dumps({
|
||||
"v": _VERSION,
|
||||
"handle": handle,
|
||||
"year": year,
|
||||
"generated_at": now,
|
||||
"tracks": year_tracks,
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write manifest
|
||||
years_sorted = sorted(by_year.keys(), reverse=True)
|
||||
index_path = user_dir / "tracks_index.json"
|
||||
index_path.write_text(
|
||||
json.dumps({
|
||||
"v": _VERSION,
|
||||
"handle": handle,
|
||||
"generated_at": now,
|
||||
"total": len(tracks),
|
||||
"years": years_sorted,
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Remove legacy monolithic file if present
|
||||
legacy = user_dir / "tracks.json"
|
||||
legacy.unlink(missing_ok=True)
|
||||
|
||||
return len(tracks)
|
||||
+29
-16
@@ -19,6 +19,8 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from bincio.extract.metrics import elevation_params
|
||||
|
||||
# Sample one GPS point per N seconds when building the DEM query.
|
||||
# SRTM30 resolution is ~30 m; at 30 km/h cycling that's ~3 s per tile —
|
||||
# sampling every 10 s is more than enough.
|
||||
@@ -297,7 +299,9 @@ def recalculate_elevation(
|
||||
}
|
||||
|
||||
|
||||
def recalculate_elevation_hysteresis(user_dir: Path, activity_id: str) -> dict:
|
||||
def recalculate_elevation_hysteresis(
|
||||
user_dir: Path, activity_id: str, *, patch_index: bool = True
|
||||
) -> dict:
|
||||
"""Recompute elevation gain/loss from the original recorded elevation data.
|
||||
|
||||
Algorithm
|
||||
@@ -346,13 +350,19 @@ def recalculate_elevation_hysteresis(user_dir: Path, activity_id: str) -> dict:
|
||||
if len(elevations) < 2:
|
||||
raise ValueError("Not enough elevation data to compute gain/loss")
|
||||
|
||||
# Determine source-aware threshold
|
||||
detail = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
altitude_source = detail.get("altitude_source", "unknown")
|
||||
threshold = 1.0 if altitude_source == "barometric" else 3.0
|
||||
source = detail.get("source") or ""
|
||||
ma_window, threshold = elevation_params(altitude_source, source)
|
||||
|
||||
# Pre-smooth to suppress noise, then accumulate with low dead-band
|
||||
smoothed = _moving_average(elevations, _MA_WINDOW_S)
|
||||
# Strip leading no-fix zeros (same logic as metrics._elevation)
|
||||
if elevations and abs(elevations[0]) < 0.5:
|
||||
for i, e in enumerate(elevations):
|
||||
if abs(e) > threshold:
|
||||
elevations = elevations[i:]
|
||||
break
|
||||
|
||||
smoothed = _moving_average(elevations, ma_window) if ma_window > 1 else elevations
|
||||
gain, loss = _hysteresis_gain_loss(smoothed, threshold)
|
||||
gain_r = round(gain, 1)
|
||||
loss_r = round(loss, 1)
|
||||
@@ -362,21 +372,24 @@ def recalculate_elevation_hysteresis(user_dir: Path, activity_id: str) -> dict:
|
||||
detail["elevation_loss_m"] = loss_r
|
||||
json_path.write_text(json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
# Patch index.json summary
|
||||
index_path = user_dir / "index.json"
|
||||
if index_path.exists():
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
for s in index.get("activities", []):
|
||||
if s.get("id") == activity_id:
|
||||
s["elevation_gain_m"] = gain_r
|
||||
break
|
||||
index_path.write_text(
|
||||
json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
# Patch index.json summary (skip for bulk callers who batch this themselves)
|
||||
if patch_index:
|
||||
index_path = user_dir / "index.json"
|
||||
if index_path.exists():
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
for s in index.get("activities", []):
|
||||
if s.get("id") == activity_id:
|
||||
s["elevation_gain_m"] = gain_r
|
||||
break
|
||||
index_path.write_text(
|
||||
json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
return {
|
||||
"elevation_gain_m": gain_r,
|
||||
"elevation_loss_m": loss_r,
|
||||
"threshold_m": threshold,
|
||||
"ma_window_s": ma_window,
|
||||
"altitude_source": altitude_source,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ from __future__ import annotations
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
_SYNC_FILE = "garmin_sync.json"
|
||||
|
||||
@@ -73,9 +73,13 @@ def garmin_sync_iter(
|
||||
data_dir: Root data directory (used for encryption key lookup).
|
||||
user_dir: Per-user directory (contains activities/, garmin_creds.json, etc.).
|
||||
"""
|
||||
import uuid as _uuid
|
||||
|
||||
from bincio.extract.garmin_api import GarminError, get_client
|
||||
from bincio.extract.ingest import ingest_parsed
|
||||
from bincio.extract.parsers.fit import FitParser
|
||||
from bincio.serve.routers.gear import _load as _gear_load
|
||||
from bincio.serve.routers.gear import _save as _gear_save
|
||||
|
||||
# ── Login ──────────────────────────────────────────────────────────────────
|
||||
try:
|
||||
@@ -86,6 +90,41 @@ def garmin_sync_iter(
|
||||
|
||||
yield {"type": "fetching"}
|
||||
|
||||
# ── Sync gear registry ─────────────────────────────────────────────────────
|
||||
_garmin_uuid_to_name: dict[str, str] = {}
|
||||
try:
|
||||
prof = client.connectapi("/userprofile-service/socialProfile")
|
||||
profile_id = prof.get("profileId") if isinstance(prof, dict) else None
|
||||
if profile_id:
|
||||
garmin_gear = client.get_gear(profile_id)
|
||||
if isinstance(garmin_gear, list):
|
||||
registry = _gear_load(user_dir)
|
||||
known = {g.get("garmin_id") for g in registry if g.get("garmin_id")}
|
||||
for g in garmin_gear:
|
||||
guuid = g.get("uuid") or ""
|
||||
name = (g.get("customMakeModel") or g.get("displayName") or
|
||||
f"{g.get('gearMakeName','')} {g.get('gearModelName','')}".strip())
|
||||
if not name or not guuid:
|
||||
continue
|
||||
_garmin_uuid_to_name[guuid] = name
|
||||
if guuid not in known:
|
||||
gear_type = g.get("gearTypeName", "").lower()
|
||||
if gear_type not in ("bike", "shoes", "skis"):
|
||||
gear_type = "other"
|
||||
retired = g.get("gearStatusName") == "retired"
|
||||
registry.append({"id": str(_uuid.uuid4()), "name": name,
|
||||
"type": gear_type, "retired": retired,
|
||||
"garmin_id": guuid})
|
||||
known.add(guuid)
|
||||
else:
|
||||
# Update name in case it changed
|
||||
for item in registry:
|
||||
if item.get("garmin_id") == guuid:
|
||||
item["name"] = name
|
||||
_gear_save(user_dir, registry)
|
||||
except Exception:
|
||||
pass # gear sync is best-effort; don't abort activity sync
|
||||
|
||||
# ── Determine date range ───────────────────────────────────────────────────
|
||||
state = _load_sync_state(user_dir)
|
||||
last = state.get("last_sync_at")
|
||||
@@ -144,6 +183,16 @@ def garmin_sync_iter(
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"FIT parse error: {exc}") from exc
|
||||
|
||||
# Resolve gear for this activity
|
||||
if garmin_id and _garmin_uuid_to_name:
|
||||
try:
|
||||
act_gear = client.get_activity_gear(garmin_id)
|
||||
if isinstance(act_gear, list) and act_gear:
|
||||
guuid = act_gear[0].get("uuid") or ""
|
||||
parsed.gear = _garmin_uuid_to_name.get(guuid) or None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ingest — raises FileExistsError if already present (dedup)
|
||||
ingest_parsed(parsed, user_dir)
|
||||
imported += 1
|
||||
@@ -173,7 +222,8 @@ def garmin_sync_iter(
|
||||
}
|
||||
|
||||
# ── Persist sync state ─────────────────────────────────────────────────────
|
||||
state["last_sync_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
state["last_sync_at"] = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
state["total_imported"] = state.get("total_imported", 0) + imported
|
||||
_save_sync_state(user_dir, state)
|
||||
|
||||
yield {
|
||||
@@ -194,3 +244,145 @@ def run_garmin_sync(data_dir: Path, user_dir: Path) -> dict:
|
||||
elif event["type"] == "error":
|
||||
raise RuntimeError(event["message"])
|
||||
return result
|
||||
|
||||
|
||||
def import_garmin_gear(data_dir: Path, user_dir: Path) -> dict:
|
||||
"""Backfill gear for all existing activities by querying Garmin's gear-activities API.
|
||||
|
||||
For each gear item, fetches the list of activities from Garmin and matches them
|
||||
to local activities by UTC start timestamp (±60 s). Writes a sidecar and calls
|
||||
merge_one for each match that doesn't already have gear set.
|
||||
|
||||
Returns {"gear_added": int, "activities_updated": int}.
|
||||
"""
|
||||
import contextlib
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import yaml
|
||||
|
||||
from bincio.extract.garmin_api import GarminError, get_client
|
||||
from bincio.render.merge import merge_one
|
||||
from bincio.serve.routers.gear import _load as _gear_load
|
||||
from bincio.serve.routers.gear import _save as _gear_save
|
||||
|
||||
client = get_client(data_dir, user_dir)
|
||||
|
||||
# Fetch gear list from Garmin
|
||||
prof = client.connectapi("/userprofile-service/socialProfile")
|
||||
profile_id = prof.get("profileId") if isinstance(prof, dict) else None
|
||||
if not profile_id:
|
||||
raise GarminError("Could not read Garmin profile ID")
|
||||
garmin_gear = client.get_gear(profile_id)
|
||||
|
||||
if not isinstance(garmin_gear, list) or not garmin_gear:
|
||||
return {"gear_added": 0, "activities_updated": 0}
|
||||
|
||||
# Build / update local gear registry
|
||||
registry = _gear_load(user_dir)
|
||||
known = {g.get("garmin_id") for g in registry if g.get("garmin_id")}
|
||||
uuid_to_name: dict[str, str] = {}
|
||||
gear_added = 0
|
||||
|
||||
for g in garmin_gear:
|
||||
guuid = g.get("uuid") or ""
|
||||
name = (g.get("customMakeModel") or g.get("displayName") or
|
||||
f"{g.get('gearMakeName', '')} {g.get('gearModelName', '')}".strip())
|
||||
if not name or not guuid:
|
||||
continue
|
||||
uuid_to_name[guuid] = name
|
||||
if guuid not in known:
|
||||
gear_type = g.get("gearTypeName", "").lower()
|
||||
if gear_type not in ("bike", "shoes", "skis"):
|
||||
gear_type = "other"
|
||||
retired = g.get("gearStatusName") == "retired"
|
||||
registry.append({"id": str(uuid.uuid4()), "name": name,
|
||||
"type": gear_type, "retired": retired, "garmin_id": guuid})
|
||||
known.add(guuid)
|
||||
gear_added += 1
|
||||
else:
|
||||
for item in registry:
|
||||
if item.get("garmin_id") == guuid:
|
||||
item["name"] = name
|
||||
|
||||
_gear_save(user_dir, registry)
|
||||
|
||||
# Build timestamp → activity_id map from index shards
|
||||
ts_to_id: dict[int, str] = {}
|
||||
merged_dir = user_dir / "_merged"
|
||||
shard_dir = merged_dir if merged_dir.exists() else user_dir
|
||||
for shard_path in sorted(shard_dir.glob("index*.json")):
|
||||
try:
|
||||
idx = json.loads(shard_path.read_text(encoding="utf-8"))
|
||||
for a in idx.get("activities", []):
|
||||
started = a.get("started_at") or ""
|
||||
if started and a.get("id"):
|
||||
dt = datetime.fromisoformat(started.replace("Z", "+00:00"))
|
||||
ts_to_id[int(dt.astimezone(UTC).timestamp())] = a["id"]
|
||||
except (OSError, json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
edits_dir = user_dir / "edits"
|
||||
edits_dir.mkdir(exist_ok=True)
|
||||
activities_updated = 0
|
||||
|
||||
for guuid, gear_name in uuid_to_name.items():
|
||||
try:
|
||||
gear_acts = client.get_gear_activities(guuid, limit=10000)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(gear_acts, list):
|
||||
continue
|
||||
|
||||
for ga in gear_acts:
|
||||
gmt = ga.get("startTimeGMT") or ""
|
||||
if not gmt:
|
||||
continue
|
||||
try:
|
||||
dt = datetime.strptime(gmt, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC)
|
||||
ts = int(dt.timestamp())
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
act_id = None
|
||||
for delta in range(0, 61):
|
||||
act_id = ts_to_id.get(ts + delta) or ts_to_id.get(ts - delta)
|
||||
if act_id:
|
||||
break
|
||||
if not act_id:
|
||||
continue
|
||||
|
||||
# Skip if activity already has gear set
|
||||
act_json = user_dir / "activities" / f"{act_id}.json"
|
||||
if act_json.exists():
|
||||
try:
|
||||
if json.loads(act_json.read_text(encoding="utf-8")).get("gear"):
|
||||
continue
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
sidecar = edits_dir / f"{act_id}.md"
|
||||
fm, body = {}, ""
|
||||
if sidecar.exists():
|
||||
try:
|
||||
text = sidecar.read_text(encoding="utf-8")
|
||||
parts = re.split(r"^---[ \t]*$", text, maxsplit=2, flags=re.MULTILINE)
|
||||
if len(parts) >= 3:
|
||||
fm = yaml.safe_load(parts[1]) or {}
|
||||
body = parts[2].strip()
|
||||
except Exception:
|
||||
pass
|
||||
if fm.get("gear"):
|
||||
continue
|
||||
|
||||
fm["gear"] = gear_name
|
||||
fm_text = yaml.safe_dump(fm, default_flow_style=False, allow_unicode=True).strip()
|
||||
content = f"---\n{fm_text}\n---\n"
|
||||
if body:
|
||||
content += f"\n{body}\n"
|
||||
sidecar.write_text(content, encoding="utf-8")
|
||||
with contextlib.suppress(Exception):
|
||||
merge_one(user_dir, act_id)
|
||||
activities_updated += 1
|
||||
|
||||
return {"gear_added": gear_added, "activities_updated": activities_updated}
|
||||
|
||||
@@ -74,6 +74,15 @@ def ingest_parsed(
|
||||
pass
|
||||
write_athlete_json(list(summaries.values()), data_dir, athlete_config)
|
||||
|
||||
# Detect segment efforts for this activity (non-fatal if it fails).
|
||||
try:
|
||||
from bincio.segments.detect import track_from_parsed, detect_all
|
||||
track = track_from_parsed(parsed, activity_id)
|
||||
if track is not None:
|
||||
detect_all(track, data_dir.name, data_dir.parent)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return activity_id
|
||||
|
||||
|
||||
@@ -91,18 +100,22 @@ def strava_sync_iter(
|
||||
- ``"done"`` — final summary; keys: imported, skipped, error_count, errors
|
||||
- ``"error"`` — fatal error before processing started; key: message
|
||||
"""
|
||||
import contextlib
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from bincio.extract.strava_api import (
|
||||
StravaError,
|
||||
ensure_fresh,
|
||||
fetch_activities,
|
||||
fetch_gear,
|
||||
fetch_streams,
|
||||
save_token,
|
||||
strava_meta_to_partial,
|
||||
strava_to_parsed,
|
||||
)
|
||||
from bincio.extract.writer import make_activity_id
|
||||
from bincio.serve.routers.gear import _load as _gear_load, _save as _gear_save
|
||||
|
||||
if not client_id or not client_secret:
|
||||
yield {"type": "error", "message": "Strava not configured"}
|
||||
@@ -128,6 +141,35 @@ def strava_sync_iter(
|
||||
skipped = 0
|
||||
errors: list[str] = []
|
||||
|
||||
# Cache: strava gear_id → gear name (avoid duplicate API calls within one sync)
|
||||
_gear_name_cache: dict[str, str] = {}
|
||||
|
||||
def _resolve_gear(gear_id: str) -> str:
|
||||
"""Return gear name for a Strava gear_id, adding to registry if new."""
|
||||
if gear_id in _gear_name_cache:
|
||||
return _gear_name_cache[gear_id]
|
||||
# Check registry first
|
||||
registry = _gear_load(data_dir)
|
||||
existing = next((g for g in registry if g.get("strava_id") == gear_id), None)
|
||||
if existing:
|
||||
name = existing["name"]
|
||||
_gear_name_cache[gear_id] = name
|
||||
return name
|
||||
# Fetch from Strava
|
||||
details = fetch_gear(token["access_token"], gear_id)
|
||||
name = details.get("name") or ""
|
||||
if not name:
|
||||
_gear_name_cache[gear_id] = ""
|
||||
return ""
|
||||
# Strava gear IDs: "b" prefix = bike, "g" prefix = shoes
|
||||
gear_type = "shoes" if gear_id.startswith("g") else "bike"
|
||||
# Add to registry
|
||||
new_item: dict = {"id": str(uuid.uuid4()), "name": name, "type": gear_type, "retired": False, "strava_id": gear_id}
|
||||
registry.append(new_item)
|
||||
_gear_save(data_dir, registry)
|
||||
_gear_name_cache[gear_id] = name
|
||||
return name
|
||||
|
||||
for n, meta in enumerate(activities, 1):
|
||||
name = meta.get("name", "Untitled")
|
||||
try:
|
||||
@@ -137,6 +179,11 @@ def strava_sync_iter(
|
||||
yield {"type": "progress", "n": n, "total": total, "name": name, "status": "skipped"}
|
||||
continue
|
||||
streams = fetch_streams(token["access_token"], meta["id"])
|
||||
# Resolve gear name before converting
|
||||
gear_id = meta.get("gear_id") or ""
|
||||
if gear_id:
|
||||
with contextlib.suppress(Exception):
|
||||
meta["_gear_name"] = _resolve_gear(gear_id)
|
||||
if originals_dir is not None:
|
||||
orig_path = originals_dir / f"{activity_id}.json"
|
||||
orig_path.write_text(
|
||||
|
||||
+268
-36
@@ -14,6 +14,8 @@ from bincio.extract.models import DataPoint, ParsedActivity
|
||||
# Standard MMP durations (seconds). Log-spaced so the curve looks good on a log-x axis.
|
||||
MMP_DURATIONS_S = [1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 300, 600, 1200, 1800, 3600]
|
||||
|
||||
_VAM_SPORTS = frozenset({"cycling", "running", "hiking", "walking"})
|
||||
|
||||
# Standard best-effort distances (km) per sport.
|
||||
BEST_EFFORT_DISTANCES: dict[str, list[float]] = {
|
||||
"running": [0.4, 1.0, 1.609, 5.0, 10.0, 21.097, 42.195],
|
||||
@@ -53,6 +55,7 @@ class ComputedMetrics:
|
||||
max_hr_bpm: Optional[int]
|
||||
avg_cadence_rpm: Optional[int]
|
||||
avg_power_w: Optional[int]
|
||||
np_power_w: Optional[int]
|
||||
max_power_w: Optional[int]
|
||||
bbox: Optional[tuple[float, float, float, float]] # min_lon, min_lat, max_lon, max_lat
|
||||
start_latlng: Optional[tuple[float, float]]
|
||||
@@ -61,6 +64,8 @@ class ComputedMetrics:
|
||||
# [[distance_km, time_s], ...] sorted by distance — None if sport has no distance targets
|
||||
best_efforts: Optional[list[list[float]]]
|
||||
best_climb_m: Optional[float] # max net elevation gain in one contiguous window (cycling only)
|
||||
climbing_vam_mh: Optional[int] # average VAM on ascending segments only (m/h)
|
||||
climbing_time_s: Optional[int] # total ascending seconds used to compute VAM
|
||||
|
||||
|
||||
def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
@@ -70,15 +75,19 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
|
||||
duration_s = _duration(pts)
|
||||
distance_m, moving_time_s, avg_speed_kmh, max_speed_kmh = _gps_stats(pts)
|
||||
gain, loss = _elevation(pts, activity.altitude_source)
|
||||
inferred_source = "strava_export" if activity.strava_id else ""
|
||||
gain, loss = _elevation(pts, activity.altitude_source, inferred_source)
|
||||
avg_hr, max_hr = _hr_stats(pts)
|
||||
avg_cad = _avg_nonnull([p.cadence_rpm for p in pts])
|
||||
avg_pow = _avg_nonnull([p.power_w for p in pts])
|
||||
np_pow = _np_power(pts, activity.started_at)
|
||||
max_pow = _max_nonnull([p.power_w for p in pts])
|
||||
bbox = _bbox(pts)
|
||||
start_ll, end_ll = _endpoints(pts)
|
||||
mmp = compute_mmp(pts, activity.started_at)
|
||||
best_efforts, best_climb_m = compute_best_efforts(pts, activity.started_at, activity.sport)
|
||||
_vam = compute_vam(pts, activity.started_at, activity.sport)
|
||||
climbing_vam_mh, climbing_time_s = _vam if _vam else (None, None)
|
||||
|
||||
return ComputedMetrics(
|
||||
distance_m=distance_m,
|
||||
@@ -92,6 +101,7 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
max_hr_bpm=max_hr,
|
||||
avg_cadence_rpm=avg_cad,
|
||||
avg_power_w=avg_pow,
|
||||
np_power_w=np_pow,
|
||||
max_power_w=max_pow,
|
||||
bbox=bbox,
|
||||
start_latlng=start_ll,
|
||||
@@ -99,6 +109,8 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
mmp=mmp,
|
||||
best_efforts=best_efforts,
|
||||
best_climb_m=best_climb_m,
|
||||
climbing_vam_mh=climbing_vam_mh,
|
||||
climbing_time_s=climbing_time_s,
|
||||
)
|
||||
|
||||
|
||||
@@ -158,6 +170,97 @@ def compute_mmp(pts: list[DataPoint], started_at: datetime) -> Optional[list[lis
|
||||
return results if results else None
|
||||
|
||||
|
||||
# ── VAM (Velocità Ascensionale Media) ────────────────────────────────────────
|
||||
|
||||
def _rolling_mean_ele(data: list[float], win: int) -> list[float]:
|
||||
"""O(n) rolling mean via prefix sums."""
|
||||
n = len(data)
|
||||
prefix = [0.0] * (n + 1)
|
||||
for i, v in enumerate(data):
|
||||
prefix[i + 1] = prefix[i] + v
|
||||
half = win // 2
|
||||
result = []
|
||||
for i in range(n):
|
||||
lo = max(0, i - half)
|
||||
hi = min(n, i + half + 1)
|
||||
result.append((prefix[hi] - prefix[lo]) / (hi - lo))
|
||||
return result
|
||||
|
||||
|
||||
def _vam_from_ele_1hz(ele_1hz: list[float]) -> Optional[tuple[int, int]]:
|
||||
"""Climbing VAM from a dense 1 Hz elevation array.
|
||||
|
||||
Accumulates gain and time only on ascending seconds, identified by a 30 s
|
||||
forward-lookahead on the smoothed elevation signal.
|
||||
Returns (climbing_vam_mh, climbing_time_s), or None when there is too little
|
||||
climbing data.
|
||||
"""
|
||||
n = len(ele_1hz)
|
||||
if n < 60:
|
||||
return None
|
||||
ele_smooth = _rolling_mean_ele(ele_1hz, 30)
|
||||
|
||||
climbing_gain = 0.0
|
||||
climbing_time = 0
|
||||
for i in range(n - 1):
|
||||
look = min(i + 30, n - 1)
|
||||
if ele_smooth[look] - ele_smooth[i] >= 2.0:
|
||||
inst = ele_smooth[i + 1] - ele_smooth[i]
|
||||
if inst > 0:
|
||||
climbing_gain += inst
|
||||
climbing_time += 1
|
||||
|
||||
if climbing_time >= 60 and climbing_gain >= 5.0:
|
||||
return round(climbing_gain * 3600.0 / climbing_time), climbing_time
|
||||
return None
|
||||
|
||||
|
||||
def _build_ele_1hz(sparse: dict[int, Optional[float]]) -> Optional[list[float]]:
|
||||
"""Build a dense 1 Hz elevation array from a {t: ele} sparse dict, forward-filling gaps."""
|
||||
if not sparse:
|
||||
return None
|
||||
t_min = min(sparse)
|
||||
t_max = max(sparse)
|
||||
if t_max - t_min > 7 * 24 * 3600:
|
||||
return None
|
||||
ele_raw: list[Optional[float]] = []
|
||||
last_known: Optional[float] = None
|
||||
for t in range(t_min, t_max + 1):
|
||||
v = sparse.get(t)
|
||||
if v is not None:
|
||||
last_known = v
|
||||
ele_raw.append(last_known)
|
||||
if sum(1 for e in ele_raw if e is not None) < 60:
|
||||
return None
|
||||
first_valid = next((e for e in ele_raw if e is not None), None)
|
||||
if first_valid is None:
|
||||
return None
|
||||
return [e if e is not None else first_valid for e in ele_raw]
|
||||
|
||||
|
||||
def compute_vam(pts: list[DataPoint], started_at: datetime, sport: str) -> Optional[tuple[int, int]]:
|
||||
"""Compute average climbing VAM (m/h) from DataPoints.
|
||||
|
||||
Only computed for cycling, running, hiking, walking.
|
||||
Returns (climbing_vam_mh, climbing_time_s), or None when there is insufficient
|
||||
climbing data.
|
||||
"""
|
||||
if sport not in _VAM_SPORTS:
|
||||
return None
|
||||
sparse: dict[int, Optional[float]] = {}
|
||||
last_t = -1
|
||||
for p in pts:
|
||||
t = int((p.timestamp - started_at).total_seconds())
|
||||
if t < 0 or t == last_t:
|
||||
continue
|
||||
sparse[t] = p.elevation_m
|
||||
last_t = t
|
||||
ele_1hz = _build_ele_1hz(sparse)
|
||||
if ele_1hz is None:
|
||||
return None
|
||||
return _vam_from_ele_1hz(ele_1hz)
|
||||
|
||||
|
||||
# ── best efforts & best climb ─────────────────────────────────────────────────
|
||||
|
||||
def compute_best_efforts(
|
||||
@@ -178,16 +281,32 @@ def compute_best_efforts(
|
||||
# Build dense 1 Hz speed (km/h) and elevation (m) arrays with gap zero-filling.
|
||||
# Zero-filling speed gaps (0 km/h) prevents best-effort windows from spanning
|
||||
# recording pauses and producing artificially fast times.
|
||||
# When the device didn't record speed (common in older FIT files), fall back to
|
||||
# GPS-derived speed: spread the haversine segment speed evenly across the interval
|
||||
# so the sliding window accumulates the correct distance.
|
||||
sparse_speed: dict[int, float] = {}
|
||||
sparse_ele: dict[int, Optional[float]] = {}
|
||||
last_t = -1
|
||||
_prev: Optional[DataPoint] = None
|
||||
for p in pts:
|
||||
t = int((p.timestamp - started_at).total_seconds())
|
||||
if t < 0 or t == last_t:
|
||||
continue
|
||||
last_t = t
|
||||
sparse_speed[t] = p.speed_kmh if p.speed_kmh is not None else 0.0
|
||||
sparse_ele[t] = p.elevation_m
|
||||
if p.speed_kmh is not None:
|
||||
sparse_speed[t] = p.speed_kmh
|
||||
elif (_prev is not None
|
||||
and _prev.lat is not None and _prev.lon is not None
|
||||
and p.lat is not None and p.lon is not None):
|
||||
dt_s = t - last_t
|
||||
seg_m = _haversine_m(_prev.lat, _prev.lon, p.lat, p.lon)
|
||||
seg_kmh = (seg_m / dt_s) * 3.6
|
||||
for slot in range(last_t, t):
|
||||
sparse_speed[slot] = seg_kmh
|
||||
else:
|
||||
sparse_speed[t] = 0.0
|
||||
last_t = t
|
||||
_prev = p
|
||||
|
||||
if not sparse_speed:
|
||||
return None, None
|
||||
@@ -212,7 +331,23 @@ def compute_best_efforts(
|
||||
|
||||
best_climb_m: Optional[float] = None
|
||||
if sport == "cycling":
|
||||
best_climb_m = _best_climb(ele_1hz)
|
||||
# Use cumulative device distance as the x-axis so recording pauses
|
||||
# (where distance doesn't increase) don't create gaps that reset the window.
|
||||
# Fall back to elapsed-time ordering when no device distance is recorded.
|
||||
dist_ele = sorted(
|
||||
(p.distance_m, p.elevation_m)
|
||||
for p in pts
|
||||
if p.distance_m is not None and p.elevation_m is not None
|
||||
)
|
||||
if not dist_ele:
|
||||
dist_ele = sorted(
|
||||
(int((p.timestamp - started_at).total_seconds()), p.elevation_m)
|
||||
for p in pts
|
||||
if p.elevation_m is not None
|
||||
and int((p.timestamp - started_at).total_seconds()) >= 0
|
||||
)
|
||||
if len(dist_ele) >= 2:
|
||||
best_climb_m = _best_climb(dist_ele)
|
||||
|
||||
return best_efforts, best_climb_m
|
||||
|
||||
@@ -242,32 +377,26 @@ def _fastest_time_for_distance(speed_1hz: list[float], target_km: float) -> Opti
|
||||
return best_s
|
||||
|
||||
|
||||
def _best_climb(ele_1hz: list[Optional[float]]) -> Optional[float]:
|
||||
"""Maximum net elevation gain over any contiguous window (Kadane's on deltas).
|
||||
def _best_climb(pts_sorted: list[tuple[float, float]]) -> Optional[float]:
|
||||
"""Maximum net elevation gain over any contiguous uphill window (Kadane's).
|
||||
|
||||
None samples are treated as breaks between segments — the Kadane window is
|
||||
reset to 0 at each gap so non-contiguous elevation data is never joined.
|
||||
Returns None if fewer than two non-None samples exist.
|
||||
pts_sorted: list of (x, elevation_m) pairs sorted by x, where x is
|
||||
cumulative distance (m) or elapsed time (s). Using cumulative distance
|
||||
means recording pauses (x doesn't increase while stopped) don't create
|
||||
gaps that falsely reset the climbing window.
|
||||
"""
|
||||
non_null = sum(1 for e in ele_1hz if e is not None)
|
||||
if non_null < 2:
|
||||
if len(pts_sorted) < 2:
|
||||
return None
|
||||
|
||||
max_gain = 0.0
|
||||
current = 0.0
|
||||
prev: Optional[float] = None
|
||||
prev_e = pts_sorted[0][1]
|
||||
|
||||
for e in ele_1hz:
|
||||
if e is None:
|
||||
# Gap — reset window so we don't bridge the discontinuity
|
||||
current = 0.0
|
||||
prev = None
|
||||
continue
|
||||
if prev is not None:
|
||||
current = max(0.0, current + (e - prev))
|
||||
if current > max_gain:
|
||||
max_gain = current
|
||||
prev = e
|
||||
for _, e in pts_sorted[1:]:
|
||||
current = max(0.0, current + (e - prev_e))
|
||||
if current > max_gain:
|
||||
max_gain = current
|
||||
prev_e = e
|
||||
|
||||
return round(max_gain, 1) if max_gain > 0 else None
|
||||
|
||||
@@ -347,33 +476,91 @@ def _duration(pts: list[DataPoint]) -> Optional[int]:
|
||||
return int((pts[-1].timestamp - pts[0].timestamp).total_seconds())
|
||||
|
||||
|
||||
# Hysteresis thresholds per altitude source.
|
||||
# Only commit a new elevation when it differs from the last committed value by
|
||||
# at least this amount, filtering out GPS noise and barometric quantization steps.
|
||||
_ELEVATION_THRESHOLD: dict[str, float] = {
|
||||
"barometric": 5.0, # barometric altimeter: smaller steps are real
|
||||
"gps": 10.0, # GPS altitude: noisier, needs wider dead-band
|
||||
"unknown": 10.0, # treat unknown as GPS to be conservative
|
||||
}
|
||||
def elevation_params(altitude_source: str, source: str = "") -> tuple[int, float]:
|
||||
"""Return (ma_window_s, threshold_m) for elevation gain/loss computation.
|
||||
|
||||
Tuned on 37 activities cross-referenced against Strava-reported elevation:
|
||||
|
||||
strava_export — elevation already pre-processed by Strava (smooth 1 m
|
||||
quantisation, 0 steps > 5 m). Light 5 s MA + 1.0 m
|
||||
threshold gives avg −2.8 %, std 4.8 %, 34/37 within ±10 %.
|
||||
|
||||
barometric — raw barometric altimeter from a FIT file. No smoothing
|
||||
needed; 1.5 m threshold gives ~0 % error on available data.
|
||||
|
||||
gps / unknown — raw GPS or unidentified non-Strava source. Light 5 s MA
|
||||
+ 1.5–2.0 m threshold suppresses GPS jitter while keeping
|
||||
real terrain changes.
|
||||
"""
|
||||
if source == "strava_export":
|
||||
return (5, 1.0)
|
||||
if altitude_source == "barometric":
|
||||
return (0, 1.5)
|
||||
if altitude_source == "gps":
|
||||
return (5, 2.0)
|
||||
return (5, 1.5) # unknown non-strava: conservative middle ground
|
||||
|
||||
|
||||
def _ele_moving_average(values: list[float], window: int) -> list[float]:
|
||||
if window <= 1:
|
||||
return list(values)
|
||||
half = window // 2
|
||||
n = len(values)
|
||||
cumsum = [0.0] * (n + 1)
|
||||
for i, v in enumerate(values):
|
||||
cumsum[i + 1] = cumsum[i] + v
|
||||
return [
|
||||
(cumsum[min(n, i + half + 1)] - cumsum[max(0, i - half)])
|
||||
/ (min(n, i + half + 1) - max(0, i - half))
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def _elevation(
|
||||
pts: list[DataPoint],
|
||||
altitude_source: str = "unknown",
|
||||
source: str = "",
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""Hysteresis-based elevation accumulation.
|
||||
|
||||
Only commits a new elevation when it differs from the last committed value
|
||||
by at least the source-specific threshold, filtering GPS jitter and
|
||||
barometric quantization noise that would otherwise inflate the gain figure.
|
||||
Applies a short moving-average pre-smoothing then commits a new elevation
|
||||
level only when it differs from the last committed value by at least the
|
||||
source-specific threshold. Parameters are chosen per data source via
|
||||
:func:`elevation_params`.
|
||||
"""
|
||||
elevations = [p.elevation_m for p in pts if p.elevation_m is not None]
|
||||
if len(elevations) < 2:
|
||||
return None, None
|
||||
threshold = _ELEVATION_THRESHOLD.get(altitude_source, 10.0)
|
||||
ma_window, threshold = elevation_params(altitude_source, source)
|
||||
|
||||
# Some devices (e.g. Apple Watch) record exactly 0.0 for the initial samples
|
||||
# while waiting for barometric/GPS lock, then jump to the real altitude.
|
||||
# Only activate when there are at least 2 consecutive near-zero leading
|
||||
# values — a single 0.0 is a legitimate sea-level starting point.
|
||||
start = 0
|
||||
if abs(elevations[0]) < 0.5:
|
||||
n_leading = 0
|
||||
for e in elevations:
|
||||
if abs(e) < 0.5:
|
||||
n_leading += 1
|
||||
else:
|
||||
break
|
||||
if n_leading > 1:
|
||||
for i, e in enumerate(elevations):
|
||||
if abs(e) > threshold:
|
||||
start = i
|
||||
break
|
||||
|
||||
elevations = _ele_moving_average(elevations[start:], ma_window)
|
||||
|
||||
gain = loss = 0.0
|
||||
committed = elevations[0]
|
||||
for e in elevations[1:]:
|
||||
# Skip near-zero values that appear mid-recording while we are at a
|
||||
# significant elevation — these are sensor dropouts (device lost GPS/
|
||||
# barometric lock), not genuine sea-level crossings.
|
||||
if abs(e) < 1.0 and abs(committed) > threshold:
|
||||
continue
|
||||
diff = e - committed
|
||||
if abs(diff) >= threshold:
|
||||
if diff > 0:
|
||||
@@ -401,6 +588,50 @@ def _max_nonnull(values: list) -> Optional[int]:
|
||||
return max(v) if v else None
|
||||
|
||||
|
||||
def _np_power(pts: list[DataPoint], started_at: datetime) -> Optional[int]:
|
||||
"""Normalized power (Coggan method): 30 s rolling average → 4th power → mean → 4th root.
|
||||
|
||||
Uses a dense 1 Hz series (gaps zero-filled) identical to the MMP pipeline.
|
||||
Returns None when the activity has no power data or is shorter than 30 s.
|
||||
"""
|
||||
sparse: dict[int, int] = {}
|
||||
last_t = -1
|
||||
for p in pts:
|
||||
t = int((p.timestamp - started_at).total_seconds())
|
||||
if t < 0 or t == last_t:
|
||||
continue
|
||||
last_t = t
|
||||
if p.power_w is not None:
|
||||
sparse[t] = p.power_w
|
||||
|
||||
if len(sparse) < 2:
|
||||
return None
|
||||
|
||||
t_min, t_max = min(sparse), max(sparse)
|
||||
if t_max - t_min > 7 * 24 * 3600:
|
||||
return None
|
||||
|
||||
power_1hz = [sparse.get(t, 0) for t in range(t_min, t_max + 1)]
|
||||
n = len(power_1hz)
|
||||
win = 30
|
||||
if n < win:
|
||||
return None
|
||||
|
||||
# 30 s centred rolling mean, then raise to 4th power
|
||||
half = win // 2
|
||||
total = sum(power_1hz[:win])
|
||||
fourth_powers: list[float] = []
|
||||
for i in range(half, n - half):
|
||||
avg = total / win
|
||||
fourth_powers.append(avg ** 4)
|
||||
if i + half + 1 < n:
|
||||
total += power_1hz[i + half + 1] - power_1hz[i - half]
|
||||
|
||||
if not fourth_powers:
|
||||
return None
|
||||
return int(round((sum(fourth_powers) / len(fourth_powers)) ** 0.25))
|
||||
|
||||
|
||||
def _bbox(pts: list[DataPoint]) -> Optional[tuple[float, float, float, float]]:
|
||||
lats = [p.lat for p in pts if p.lat is not None]
|
||||
lons = [p.lon for p in pts if p.lon is not None]
|
||||
@@ -424,7 +655,8 @@ def _empty() -> ComputedMetrics:
|
||||
elevation_gain_m=None, elevation_loss_m=None,
|
||||
avg_speed_kmh=None, max_speed_kmh=None,
|
||||
avg_hr_bpm=None, max_hr_bpm=None,
|
||||
avg_cadence_rpm=None, avg_power_w=None, max_power_w=None,
|
||||
avg_cadence_rpm=None, avg_power_w=None, np_power_w=None, max_power_w=None,
|
||||
bbox=None, start_latlng=None, end_latlng=None,
|
||||
mmp=None, best_efforts=None, best_climb_m=None,
|
||||
climbing_vam_mh=None, climbing_time_s=None,
|
||||
)
|
||||
|
||||
@@ -5,9 +5,27 @@ It gets fed into metrics computation and the BAS JSON writer.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
# Any timestamp before this is almost certainly an uninitialised sensor value
|
||||
# (epoch 0, FIT "no-data" sentinel, RTC not yet synced, etc.).
|
||||
_MIN_TIMESTAMP = datetime(2000, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def strip_bogus_leading_points(points: list["DataPoint"]) -> list["DataPoint"]:
|
||||
"""Drop leading points whose timestamp predates the year 2000.
|
||||
|
||||
FIT files occasionally emit a record with timestamp=0 (or another
|
||||
pre-2000 value) as an uninitialised sentinel before the real data
|
||||
begins. Keeping such a point as points[0] produces a 1970 start
|
||||
time and an absurdly large duration_s.
|
||||
"""
|
||||
i = 0
|
||||
while i < len(points) and points[i].timestamp < _MIN_TIMESTAMP:
|
||||
i += 1
|
||||
return points[i:]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataPoint:
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
import fitdecode
|
||||
|
||||
from bincio.extract.models import DataPoint, LapData, ParsedActivity
|
||||
from bincio.extract.models import DataPoint, LapData, ParsedActivity, strip_bogus_leading_points
|
||||
from bincio.extract.sport import normalise_sport
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ class FitParser:
|
||||
)
|
||||
)
|
||||
|
||||
points = strip_bogus_leading_points(points)
|
||||
if not points:
|
||||
raise ValueError(f"No record messages found in {path.name}")
|
||||
|
||||
@@ -146,11 +147,13 @@ def _normalise_sub_sport(value: Any) -> str | None:
|
||||
mapping = {
|
||||
"generic": None, # FIT default — unspecified
|
||||
"virtual_activity": "indoor",
|
||||
"virtual": "indoor",
|
||||
"road": "road",
|
||||
"mountain": "mountain",
|
||||
"gravel_cycling": "gravel",
|
||||
"cyclocross": "gravel",
|
||||
"indoor_cycling": "indoor",
|
||||
"treadmill": "indoor",
|
||||
"trail": "trail",
|
||||
"track": "track",
|
||||
"cross_country_skiing": "nordic",
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
import gpxpy
|
||||
import gpxpy.gpx
|
||||
|
||||
from bincio.extract.models import DataPoint, ParsedActivity
|
||||
from bincio.extract.models import DataPoint, ParsedActivity, strip_bogus_leading_points
|
||||
from bincio.extract.parsers.base import BaseParser
|
||||
from bincio.extract.sport import normalise_sport, normalise_sub_sport
|
||||
|
||||
@@ -38,6 +38,7 @@ class GpxParser(BaseParser):
|
||||
_apply_extensions(pt, dp)
|
||||
points.append(dp)
|
||||
|
||||
points = strip_bogus_leading_points(points)
|
||||
if not points:
|
||||
raise ValueError(f"No trackpoints found in {path.name}")
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from bincio.extract.models import DataPoint, ParsedActivity
|
||||
from bincio.extract.models import DataPoint, ParsedActivity, strip_bogus_leading_points
|
||||
from bincio.extract.sport import normalise_sport, normalise_sub_sport
|
||||
|
||||
_NS_HTTP = {
|
||||
@@ -73,6 +73,7 @@ class TcxParser:
|
||||
)
|
||||
points.append(dp)
|
||||
|
||||
points = strip_bogus_leading_points(points)
|
||||
if not points:
|
||||
raise ValueError(f"No trackpoints found in {path.name}")
|
||||
|
||||
|
||||
@@ -99,6 +99,14 @@ _SUB_SPORT_MAPPING: dict[str, str] = {
|
||||
|
||||
BAS_SPORTS = {"cycling", "running", "hiking", "walking", "swimming", "skiing", "other"}
|
||||
|
||||
# Valid sub_sport values per sport, in display order.
|
||||
SUB_SPORTS: dict[str, list[str]] = {
|
||||
"cycling": ["road", "mountain", "gravel", "indoor"],
|
||||
"running": ["trail", "track", "indoor"],
|
||||
"swimming": ["open_water", "pool"],
|
||||
"skiing": ["nordic", "alpine"],
|
||||
}
|
||||
|
||||
|
||||
def _normalise_key(raw: object) -> str:
|
||||
key = str(raw).strip()
|
||||
|
||||
@@ -150,6 +150,15 @@ def fetch_streams(access_token: str, activity_id: int) -> dict:
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
|
||||
def fetch_gear(access_token: str, gear_id: str) -> dict:
|
||||
"""Fetch gear details for a single gear item. Returns {} on error."""
|
||||
try:
|
||||
result = _api_get(f"{_API_BASE}/gear/{gear_id}", access_token)
|
||||
return result if isinstance(result, dict) else {}
|
||||
except StravaError:
|
||||
return {}
|
||||
|
||||
|
||||
# ── Model conversion ───────────────────────────────────────────────────────────
|
||||
|
||||
def strava_meta_to_partial(meta: dict) -> ParsedActivity:
|
||||
@@ -215,4 +224,5 @@ def strava_to_parsed(meta: dict, streams: dict) -> ParsedActivity:
|
||||
description=meta.get("description") or None,
|
||||
strava_id=str(meta["id"]),
|
||||
privacy="unlisted" if is_private else "public",
|
||||
gear=meta.get("_gear_name") or None,
|
||||
)
|
||||
|
||||
@@ -115,6 +115,8 @@ def strava_zip_iter(
|
||||
parsed.description = meta_row["Activity Description"].strip()
|
||||
if not parsed.strava_id and meta_row.get("Activity ID"):
|
||||
parsed.strava_id = meta_row["Activity ID"].strip()
|
||||
if not parsed.gear and meta_row.get("Gear"):
|
||||
parsed.gear = meta_row["Gear"].strip()
|
||||
|
||||
if originals_dir is not None:
|
||||
import shutil
|
||||
|
||||
@@ -2,11 +2,104 @@
|
||||
the BAS timeseries object (parallel arrays)."""
|
||||
|
||||
from datetime import datetime
|
||||
from math import atan2, cos, radians, sin, sqrt
|
||||
from typing import Optional
|
||||
|
||||
from bincio.extract.models import DataPoint
|
||||
|
||||
|
||||
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Great-circle distance in metres between two GPS points."""
|
||||
dlat = radians(lat2 - lat1)
|
||||
dlon = radians(lon2 - lon1)
|
||||
a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
|
||||
return 2 * 6_371_000.0 * atan2(sqrt(a), sqrt(1 - a))
|
||||
|
||||
|
||||
_SPATIAL_RESOLUTION_M = 10.0
|
||||
|
||||
|
||||
def _spatial_downsample(
|
||||
sampled: list[DataPoint],
|
||||
resolution_m: float = _SPATIAL_RESOLUTION_M,
|
||||
) -> list[DataPoint]:
|
||||
"""Keep one sample per `resolution_m` of cumulative distance traveled.
|
||||
|
||||
Distance source priority:
|
||||
1. GPS haversine (lat/lon present on both consecutive points)
|
||||
2. speed_kmh × Δt (fallback when GPS absent or gapped)
|
||||
If neither source is available (indoor, no speed data), returns `sampled`
|
||||
unchanged. Always retains the first and last points.
|
||||
"""
|
||||
if len(sampled) < 2:
|
||||
return sampled
|
||||
|
||||
has_gps = any(p.lat is not None and p.lon is not None for p in sampled)
|
||||
has_speed = any(p.speed_kmh is not None for p in sampled)
|
||||
if not has_gps and not has_speed:
|
||||
return sampled
|
||||
|
||||
result: list[DataPoint] = [sampled[0]]
|
||||
cum_dist = 0.0
|
||||
last_kept = 0.0
|
||||
prev_speed = 0.0
|
||||
|
||||
for i in range(1, len(sampled)):
|
||||
prev, cur = sampled[i - 1], sampled[i]
|
||||
dt = (cur.timestamp - prev.timestamp).total_seconds()
|
||||
|
||||
if (has_gps
|
||||
and prev.lat is not None and prev.lon is not None
|
||||
and cur.lat is not None and cur.lon is not None):
|
||||
dist_m = _haversine_m(prev.lat, prev.lon, cur.lat, cur.lon)
|
||||
else:
|
||||
spd = cur.speed_kmh if cur.speed_kmh is not None else prev_speed
|
||||
dist_m = (spd / 3.6) * max(dt, 0)
|
||||
|
||||
if cur.speed_kmh is not None:
|
||||
prev_speed = cur.speed_kmh
|
||||
|
||||
cum_dist += dist_m
|
||||
if cum_dist - last_kept >= resolution_m:
|
||||
result.append(cur)
|
||||
last_kept = cum_dist
|
||||
|
||||
if result[-1] is not sampled[-1]:
|
||||
result.append(sampled[-1])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _gps_speed_kmh(
|
||||
lat_vals: list[Optional[float]],
|
||||
lon_vals: list[Optional[float]],
|
||||
ts_vals: list[int],
|
||||
) -> list[Optional[float]]:
|
||||
"""Compute speed (km/h) from consecutive GPS coordinates via haversine.
|
||||
Applies a 5-point centred moving-average to reduce GPS noise.
|
||||
"""
|
||||
n = len(ts_vals)
|
||||
raw: list[Optional[float]] = [None] * n
|
||||
for i in range(1, n):
|
||||
la0, lo0 = lat_vals[i - 1], lon_vals[i - 1]
|
||||
la1, lo1 = lat_vals[i], lon_vals[i]
|
||||
dt = ts_vals[i] - ts_vals[i - 1]
|
||||
if la0 is None or lo0 is None or la1 is None or lo1 is None or dt <= 0:
|
||||
continue
|
||||
d_km = _haversine_m(la0, lo0, la1, lo1) / 1000.0
|
||||
raw[i] = d_km / dt * 3600.0
|
||||
|
||||
# 5-point centred moving average (skip None anchors)
|
||||
half = 2
|
||||
smoothed: list[Optional[float]] = [None] * n
|
||||
for i in range(n):
|
||||
vals = [raw[j] for j in range(max(0, i - half), min(n, i + half + 1)) if raw[j] is not None]
|
||||
if vals:
|
||||
smoothed[i] = round(sum(vals) / len(vals), 2)
|
||||
|
||||
return smoothed
|
||||
|
||||
|
||||
def build_timeseries(
|
||||
points: list[DataPoint],
|
||||
started_at: datetime,
|
||||
@@ -35,11 +128,18 @@ def build_timeseries(
|
||||
sampled.append(p)
|
||||
last_t = t
|
||||
|
||||
sampled = _spatial_downsample(sampled)
|
||||
|
||||
ts_vals = [int((p.timestamp - started_at).total_seconds()) for p in sampled]
|
||||
lat_vals = [round(p.lat, 7) if p.lat is not None else None for p in sampled] if include_gps else None
|
||||
lon_vals = [round(p.lon, 7) if p.lon is not None else None for p in sampled] if include_gps else None
|
||||
ele_vals = [round(p.elevation_m, 1) if p.elevation_m is not None else None for p in sampled]
|
||||
spd_vals = [round(p.speed_kmh, 2) if p.speed_kmh is not None else None for p in sampled]
|
||||
|
||||
# Derive speed from GPS when the device didn't record per-second speed.
|
||||
if include_gps and lat_vals and lon_vals and all(v is None for v in spd_vals):
|
||||
spd_vals = _gps_speed_kmh(lat_vals, lon_vals, ts_vals)
|
||||
|
||||
hr_vals = [p.hr_bpm for p in sampled]
|
||||
cad_vals = [p.cadence_rpm for p in sampled]
|
||||
pwr_vals = [p.power_w for p in sampled]
|
||||
|
||||
@@ -10,6 +10,18 @@ from bincio.extract.models import LapData, ParsedActivity
|
||||
from bincio.extract.simplify import build_geojson, preview_coords
|
||||
from bincio.extract.timeseries import build_timeseries
|
||||
|
||||
# Titles that reliably identify indoor/virtual activities regardless of sub_sport metadata.
|
||||
# Strava imports from Zwift and FTP-builder platforms lose sub_sport on export.
|
||||
_INDOOR_TITLE_RE = re.compile(
|
||||
r'\b(zwift|ftp[\s\-]builder|turbo[\s\-]?trainer|rodillo)\b',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _infer_indoor_title(title: str) -> bool:
|
||||
"""Return True if the title reliably identifies an indoor/virtual activity."""
|
||||
return bool(_INDOOR_TITLE_RE.search(title))
|
||||
|
||||
|
||||
def make_activity_id(activity: ParsedActivity) -> str:
|
||||
"""Generate a BAS activity ID from started_at + optional title slug.
|
||||
@@ -79,6 +91,7 @@ def write_activity(
|
||||
"max_hr_bpm": metrics.max_hr_bpm,
|
||||
"avg_cadence_rpm": metrics.avg_cadence_rpm,
|
||||
"avg_power_w": metrics.avg_power_w,
|
||||
"np_power_w": metrics.np_power_w,
|
||||
"max_power_w": metrics.max_power_w,
|
||||
"gear": activity.gear,
|
||||
"device": activity.device,
|
||||
@@ -88,6 +101,8 @@ def write_activity(
|
||||
"mmp": metrics.mmp,
|
||||
"best_efforts": metrics.best_efforts,
|
||||
"best_climb_m": metrics.best_climb_m,
|
||||
"climbing_vam_mh": metrics.climbing_vam_mh,
|
||||
"climbing_time_s": metrics.climbing_time_s,
|
||||
"laps": [_serialise_lap(lap) for lap in activity.laps],
|
||||
"timeseries_url": f"activities/{activity_id}.timeseries.json" if timeseries else None,
|
||||
"source": source,
|
||||
@@ -244,6 +259,9 @@ def build_summary(
|
||||
"mmp": metrics.mmp,
|
||||
"best_efforts": metrics.best_efforts,
|
||||
"best_climb_m": metrics.best_climb_m,
|
||||
"climbing_vam_mh": metrics.climbing_vam_mh,
|
||||
"climbing_time_s": metrics.climbing_time_s,
|
||||
"gear": activity.gear,
|
||||
"source": _infer_source(activity),
|
||||
"privacy": privacy,
|
||||
"detail_url": f"activities/{activity_id}.json",
|
||||
@@ -276,9 +294,16 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
|
||||
best[d] = w
|
||||
return [[d, w] for d, w in sorted(best.items())]
|
||||
|
||||
all_mmps = [s["mmp"] for s in summaries if s.get("mmp")]
|
||||
mmps_365 = [s["mmp"] for s in summaries if s.get("mmp") and s["started_at"] >= cutoff_365]
|
||||
mmps_90 = [s["mmp"] for s in summaries if s.get("mmp") and s["started_at"] >= cutoff_90]
|
||||
_INDOOR_SUB_SPORTS = {"indoor", "treadmill", "virtual"}
|
||||
|
||||
def _is_outdoor(s: dict) -> bool:
|
||||
if s.get("sub_sport") in _INDOOR_SUB_SPORTS:
|
||||
return False
|
||||
return not _infer_indoor_title(s.get("title") or "")
|
||||
|
||||
all_mmps = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s)]
|
||||
mmps_365 = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s) and s["started_at"] >= cutoff_365]
|
||||
mmps_90 = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s) and s["started_at"] >= cutoff_90]
|
||||
|
||||
# ── Personal records aggregation ──────────────────────────────────────────
|
||||
# records[sport][distance_km] = {time_s, activity_id, started_at, title}
|
||||
@@ -289,6 +314,8 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
|
||||
best_climb: list[dict] = [] # top 10 best climbs for cycling
|
||||
|
||||
for s in summaries:
|
||||
if not _is_outdoor(s):
|
||||
continue
|
||||
sport = s.get("sport", "other")
|
||||
act_id = s.get("id", "")
|
||||
started = s.get("started_at", "")
|
||||
@@ -355,7 +382,9 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
|
||||
**athlete_config,
|
||||
}
|
||||
(output_dir / "athlete.json").write_text(
|
||||
json.dumps(athlete, indent=2, ensure_ascii=False)
|
||||
json.dumps(athlete, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -343,9 +343,24 @@ def sync(
|
||||
owner = index_data.get("owner", {})
|
||||
summaries: dict[str, dict] = {s["id"]: s for s in index_data.get("activities", [])}
|
||||
|
||||
# ── build timestamp-prefix index of existing activities ──────────────────
|
||||
# Maps "YYYY-MM-DDTHHMMSSZ" → first matching activity filename (stem).
|
||||
# Used to detect when a FIT-file upload already covers a Strava activity.
|
||||
acts_dir = output_dir / "activities"
|
||||
existing_ts: set[str] = set()
|
||||
if acts_dir.is_dir():
|
||||
for p in acts_dir.iterdir():
|
||||
if p.suffix == ".json" and not p.name.endswith(".timeseries.json"):
|
||||
stem = p.stem
|
||||
# ID format: YYYY-MM-DDTHHMMSSZ[-optional-slug]
|
||||
z_pos = stem.find("Z")
|
||||
if z_pos != -1:
|
||||
existing_ts.add(stem[: z_pos + 1])
|
||||
|
||||
# ── import loop ───────────────────────────────────────────────────────────
|
||||
errors: list[tuple[str, str]] = []
|
||||
imported = 0
|
||||
skipped_existing = 0
|
||||
|
||||
with Progress(
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
@@ -362,11 +377,20 @@ def sync(
|
||||
try:
|
||||
streams = client.get_streams(act["id"])
|
||||
parsed = _strava_to_parsed(act, streams)
|
||||
|
||||
# Skip if any activity already exists for the same start time
|
||||
ts_part = parsed.started_at.astimezone(timezone.utc).strftime("%Y-%m-%dT%H%M%SZ")
|
||||
if ts_part in existing_ts:
|
||||
imported_ids.add(strava_id)
|
||||
skipped_existing += 1
|
||||
continue
|
||||
|
||||
metrics = compute(parsed)
|
||||
metrics = _patch_from_summary(metrics, act)
|
||||
act_id = make_activity_id(parsed)
|
||||
write_activity(parsed, metrics, output_dir, privacy="public")
|
||||
summaries[act_id] = build_summary(parsed, metrics, act_id, "public")
|
||||
existing_ts.add(ts_part)
|
||||
imported_ids.add(strava_id)
|
||||
imported += 1
|
||||
except Exception as exc:
|
||||
@@ -384,9 +408,11 @@ def sync(
|
||||
from bincio.render.merge import merge_all
|
||||
merge_all(output_dir)
|
||||
|
||||
skipped_msg = f", skipped [bold]{skipped_existing}[/bold] already covered by local uploads" if skipped_existing else ""
|
||||
console.print(
|
||||
f"\n[green]Done.[/green] "
|
||||
f"Imported [bold]{imported}[/bold] activities, "
|
||||
f"Imported [bold]{imported}[/bold] activities"
|
||||
f"{skipped_msg}, "
|
||||
f"errors [bold]{len(errors)}[/bold]."
|
||||
)
|
||||
if errors:
|
||||
|
||||
+508
-1
@@ -92,6 +92,225 @@ def _merge_edits(data: Path, handle: str | None = None) -> None:
|
||||
console.print("No sidecars found — _merged/ dirs mirror extracted data.")
|
||||
|
||||
|
||||
def _bake_tracks(data: Path, handle: str | None = None) -> None:
|
||||
"""Bake tracks.json for one user or all users."""
|
||||
from bincio.explore import bake_tracks
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
try:
|
||||
n = bake_tracks(user_dir.name, data)
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {n} track(s) baked")
|
||||
except Exception as exc:
|
||||
console.print(f" [yellow]{user_dir.name}[/yellow]: bake_tracks failed: {exc}")
|
||||
|
||||
|
||||
def _rebuild_athlete_json(data: Path, handle: str | None = None) -> None:
|
||||
"""Rebuild athlete.json for one user or all users.
|
||||
|
||||
Reads raw index.json summaries, applies any sidecar edits in-memory (so
|
||||
overrides like sub_sport: indoor are respected), then calls write_athlete_json.
|
||||
"""
|
||||
import json
|
||||
from bincio.extract.writer import write_athlete_json
|
||||
from bincio.render.merge import parse_sidecar, _apply_sidecar_summary
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
_COMPUTED = {"bas_version", "generated_at", "power_curve", "records", "best_climbs"}
|
||||
for user_dir in targets:
|
||||
index_path = user_dir / "index.json"
|
||||
if not index_path.exists():
|
||||
continue
|
||||
try:
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
summaries = index_data.get("activities", [])
|
||||
if not summaries:
|
||||
continue
|
||||
|
||||
# Apply sidecar edits so overrides (e.g. sub_sport: indoor) are visible
|
||||
# to write_athlete_json without stripping best_efforts/best_climb_m.
|
||||
edits_dir = user_dir / "edits"
|
||||
if edits_dir.exists():
|
||||
sidecars: dict[str, dict] = {}
|
||||
for sc_path in edits_dir.glob("*.md"):
|
||||
try:
|
||||
fm, _ = parse_sidecar(sc_path)
|
||||
sidecars[sc_path.stem] = fm
|
||||
except Exception:
|
||||
pass
|
||||
if sidecars:
|
||||
summaries = [
|
||||
_apply_sidecar_summary(s, sidecars[s["id"]])
|
||||
if s.get("id") in sidecars else s
|
||||
for s in summaries
|
||||
]
|
||||
|
||||
athlete_config: dict = {}
|
||||
athlete_path = user_dir / "athlete.json"
|
||||
if athlete_path.exists():
|
||||
try:
|
||||
existing = json.loads(athlete_path.read_text(encoding="utf-8"))
|
||||
athlete_config = {k: v for k, v in existing.items() if k not in _COMPUTED}
|
||||
except Exception:
|
||||
pass
|
||||
write_athlete_json(summaries, user_dir, athlete_config)
|
||||
except Exception as exc:
|
||||
console.print(f" [yellow]{user_dir.name}[/yellow]: rebuild_athlete failed: {exc}")
|
||||
|
||||
|
||||
def _recompute_best_climbs(data: Path, handle: str | None = None) -> None:
|
||||
"""Recompute best_climb_m for all cycling activities from their stored timeseries.
|
||||
|
||||
Updates activities/*.json and index.json in-place. Run this once after
|
||||
upgrading the climb algorithm to fix values computed by the old code.
|
||||
"""
|
||||
import json
|
||||
from bincio.extract.metrics import _best_climb
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
acts_dir = user_dir / "activities"
|
||||
index_path = user_dir / "index.json"
|
||||
if not acts_dir.exists() or not index_path.exists():
|
||||
continue
|
||||
try:
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
updated = 0
|
||||
for act_path in acts_dir.glob("*.json"):
|
||||
if act_path.stem.endswith((".timeseries", ".geojson")):
|
||||
continue
|
||||
ts_path = acts_dir / f"{act_path.stem}.timeseries.json"
|
||||
if not ts_path.exists():
|
||||
continue
|
||||
try:
|
||||
detail = json.loads(act_path.read_text(encoding="utf-8"))
|
||||
if detail.get("sport") != "cycling":
|
||||
continue
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
t_vals = ts.get("t", [])
|
||||
e_vals = ts.get("elevation_m", [])
|
||||
pairs = sorted(
|
||||
(t, e) for t, e in zip(t_vals, e_vals) if e is not None
|
||||
)
|
||||
if len(pairs) < 2:
|
||||
continue
|
||||
new_val = _best_climb(pairs)
|
||||
if new_val == detail.get("best_climb_m"):
|
||||
continue
|
||||
detail["best_climb_m"] = new_val
|
||||
act_path.write_text(
|
||||
json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
act_id = act_path.stem
|
||||
for s in index_data.get("activities", []):
|
||||
if s.get("id") == act_id:
|
||||
s["best_climb_m"] = new_val
|
||||
break
|
||||
updated += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if updated:
|
||||
index_path.write_text(
|
||||
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} climb(s) recomputed")
|
||||
|
||||
|
||||
def _recompute_elevation(data: Path, handle: str | None = None) -> None:
|
||||
"""Recompute elevation_gain_m / elevation_loss_m for all activities.
|
||||
|
||||
Applies the dropout-skip fix (near-zero values mid-recording) so stored
|
||||
values computed by older code are corrected. Updates activities/*.json
|
||||
and index.json in-place.
|
||||
"""
|
||||
import json
|
||||
from bincio.extract.metrics import _ELEVATION_THRESHOLD
|
||||
|
||||
def _accumulate(elevations: list[float], altitude_source: str) -> tuple[float, float]:
|
||||
if len(elevations) < 2:
|
||||
return 0.0, 0.0
|
||||
threshold = _ELEVATION_THRESHOLD.get(altitude_source, 10.0)
|
||||
# Skip leading near-zeros (device acquiring lock)
|
||||
start = 0
|
||||
if abs(elevations[0]) < 0.5:
|
||||
n_leading = sum(1 for e in elevations if abs(e) < 0.5)
|
||||
if n_leading > 1:
|
||||
for i, e in enumerate(elevations):
|
||||
if abs(e) > threshold:
|
||||
start = i
|
||||
break
|
||||
gain = loss = 0.0
|
||||
committed = elevations[start]
|
||||
for e in elevations[start + 1:]:
|
||||
if abs(e) < 1.0 and abs(committed) > threshold:
|
||||
continue
|
||||
diff = e - committed
|
||||
if abs(diff) >= threshold:
|
||||
if diff > 0:
|
||||
gain += diff
|
||||
else:
|
||||
loss += diff
|
||||
committed = e
|
||||
return gain, loss
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
acts_dir = user_dir / "activities"
|
||||
index_path = user_dir / "index.json"
|
||||
if not acts_dir.exists() or not index_path.exists():
|
||||
continue
|
||||
try:
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
updated = 0
|
||||
for act_path in acts_dir.glob("*.json"):
|
||||
if act_path.stem.endswith((".timeseries", ".geojson")):
|
||||
continue
|
||||
ts_path = acts_dir / f"{act_path.stem}.timeseries.json"
|
||||
if not ts_path.exists():
|
||||
continue
|
||||
try:
|
||||
detail = json.loads(act_path.read_text(encoding="utf-8"))
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
raw = ts.get("elevation_m", [])
|
||||
elevations = [e for e in raw if e is not None]
|
||||
if len(elevations) < 2:
|
||||
continue
|
||||
alt_src = detail.get("altitude_source", "unknown")
|
||||
new_gain, new_loss = _accumulate(elevations, alt_src)
|
||||
new_gain_r = round(new_gain, 1) if new_gain else None
|
||||
new_loss_r = round(abs(new_loss), 1) if new_loss else None
|
||||
if (new_gain_r == detail.get("elevation_gain_m") and
|
||||
new_loss_r == detail.get("elevation_loss_m")):
|
||||
continue
|
||||
detail["elevation_gain_m"] = new_gain_r
|
||||
detail["elevation_loss_m"] = new_loss_r
|
||||
act_path.write_text(
|
||||
json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
act_id = act_path.stem
|
||||
for s in index_data.get("activities", []):
|
||||
if s.get("id") == act_id:
|
||||
s["elevation_gain_m"] = new_gain_r
|
||||
s["elevation_loss_m"] = new_loss_r
|
||||
break
|
||||
updated += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if updated:
|
||||
index_path.write_text(
|
||||
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} elevation(s) recomputed")
|
||||
|
||||
|
||||
def _write_root_manifest(data: Path) -> None:
|
||||
"""Rewrite the root index.json shard manifest from current user dirs."""
|
||||
import json
|
||||
@@ -158,6 +377,236 @@ def _link_data(site: Path, data: Path) -> None:
|
||||
console.print(f"Linked data: [cyan]{target}[/cyan] → [cyan]{public_data}[/cyan]")
|
||||
|
||||
|
||||
def _recompute_vam(data: Path, handle: str | None = None) -> None:
|
||||
"""Recompute climbing_vam_mh and climbing_time_s for all activities.
|
||||
|
||||
Reads the stored timeseries, re-runs the VAM algorithm, and patches both
|
||||
activities/*.json and index.json in-place. Run once after adding
|
||||
climbing_time_s to the schema so the NerdCorner VAM chart can filter short
|
||||
climbs and opacity-encode confidence.
|
||||
"""
|
||||
import json
|
||||
from bincio.extract.metrics import _VAM_SPORTS, _build_ele_1hz, _vam_from_ele_1hz
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
acts_dir = user_dir / "activities"
|
||||
index_path = user_dir / "index.json"
|
||||
if not acts_dir.exists() or not index_path.exists():
|
||||
continue
|
||||
try:
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
index_by_id = {s["id"]: s for s in index_data.get("activities", [])}
|
||||
updated = 0
|
||||
|
||||
for act_path in sorted(acts_dir.glob("*.json")):
|
||||
if act_path.stem.endswith((".timeseries", ".geojson")):
|
||||
continue
|
||||
ts_path = acts_dir / f"{act_path.stem}.timeseries.json"
|
||||
if not ts_path.exists():
|
||||
continue
|
||||
try:
|
||||
detail = json.loads(act_path.read_text(encoding="utf-8"))
|
||||
if detail.get("sport") not in _VAM_SPORTS:
|
||||
continue
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
t_vals = ts.get("t", [])
|
||||
e_vals = ts.get("elevation_m", [])
|
||||
sparse: dict[int, float | None] = {int(t): e for t, e in zip(t_vals, e_vals)}
|
||||
ele_1hz = _build_ele_1hz(sparse)
|
||||
result = _vam_from_ele_1hz(ele_1hz) if ele_1hz else None
|
||||
new_vam, new_climb_t = result if result else (None, None)
|
||||
if (new_vam == detail.get("climbing_vam_mh") and
|
||||
new_climb_t == detail.get("climbing_time_s")):
|
||||
continue
|
||||
detail["climbing_vam_mh"] = new_vam
|
||||
detail["climbing_time_s"] = new_climb_t
|
||||
act_path.write_text(
|
||||
json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
summary = index_by_id.get(act_path.stem)
|
||||
if summary is not None:
|
||||
summary["climbing_vam_mh"] = new_vam
|
||||
summary["climbing_time_s"] = new_climb_t
|
||||
updated += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if updated:
|
||||
index_path.write_text(
|
||||
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} VAM(s) recomputed")
|
||||
|
||||
|
||||
def _backfill_vam_summary(data: Path, handle: str | None = None) -> None:
|
||||
"""Copy climbing_vam_mh from detail JSONs into index.json summaries.
|
||||
|
||||
Needed once after the vam_curve→climbing_vam_mh-in-summary migration.
|
||||
"""
|
||||
import json
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
acts_dir = user_dir / "activities"
|
||||
index_path = user_dir / "index.json"
|
||||
if not acts_dir.exists() or not index_path.exists():
|
||||
continue
|
||||
try:
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
updated = 0
|
||||
for s in index_data.get("activities", []):
|
||||
if "climbing_vam_mh" in s:
|
||||
continue # already backfilled
|
||||
act_path = acts_dir / f"{s['id']}.json"
|
||||
if not act_path.exists():
|
||||
continue
|
||||
try:
|
||||
detail = json.loads(act_path.read_text(encoding="utf-8"))
|
||||
vam = detail.get("climbing_vam_mh")
|
||||
if vam is not None:
|
||||
s["climbing_vam_mh"] = vam
|
||||
updated += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if updated:
|
||||
index_path.write_text(
|
||||
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} summary(ies) updated")
|
||||
|
||||
|
||||
def _backfill_speed(data: Path, handle: str | None = None) -> None:
|
||||
"""Compute GPS-derived speed for timeseries files where speed_kmh is all null.
|
||||
|
||||
Reads each *.timeseries.json, fills speed_kmh from haversine distances when
|
||||
the device did not record per-second speed, and writes the file back.
|
||||
"""
|
||||
import json
|
||||
from bincio.extract.timeseries import _gps_speed_kmh
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
acts_dir = user_dir / "activities"
|
||||
if not acts_dir.exists():
|
||||
continue
|
||||
updated = 0
|
||||
for ts_path in sorted(acts_dir.glob("*.timeseries.json")):
|
||||
try:
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
spd = ts.get("speed_kmh", [])
|
||||
if not spd or any(v is not None for v in spd):
|
||||
continue # already has speed data
|
||||
lat_vals = ts.get("lat") or []
|
||||
lon_vals = ts.get("lon") or []
|
||||
t_vals = ts.get("t") or []
|
||||
if not lat_vals or not lon_vals or not t_vals:
|
||||
continue
|
||||
ts["speed_kmh"] = _gps_speed_kmh(lat_vals, lon_vals, t_vals)
|
||||
ts_path.write_text(json.dumps(ts, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
updated += 1
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} timeseries updated with GPS speed")
|
||||
|
||||
|
||||
def _downsample_timeseries(data: Path, handle: str | None = None) -> None:
|
||||
"""Apply 10 m spatial downsampling to all stored timeseries files in activities/.
|
||||
|
||||
Reads the parallel JSON arrays, computes which indices to keep using the
|
||||
same distance logic as _spatial_downsample, slices every channel, and
|
||||
writes the file back. Run bincio render --no-build afterward so _merge_edits
|
||||
regenerates _merged/ from the smaller source files.
|
||||
"""
|
||||
import json
|
||||
from bincio.extract.timeseries import _haversine_m, _SPATIAL_RESOLUTION_M
|
||||
|
||||
_CHANNELS = ("t", "lat", "lon", "elevation_m", "speed_kmh",
|
||||
"hr_bpm", "cadence_rpm", "power_w", "temperature_c")
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
acts_dir = user_dir / "activities"
|
||||
if not acts_dir.exists():
|
||||
continue
|
||||
updated = skipped = 0
|
||||
for ts_path in sorted(acts_dir.glob("*.timeseries.json")):
|
||||
try:
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
t_vals = ts.get("t") or []
|
||||
lat_vals = ts.get("lat") or []
|
||||
lon_vals = ts.get("lon") or []
|
||||
spd_vals = ts.get("speed_kmh") or []
|
||||
n = len(t_vals)
|
||||
if n < 2:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
has_gps = any(v is not None for v in lat_vals)
|
||||
has_speed = any(v is not None for v in spd_vals)
|
||||
if not has_gps and not has_speed:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
kept: list[int] = [0]
|
||||
cum_dist = last_kept = prev_speed = 0.0
|
||||
|
||||
for i in range(1, n):
|
||||
dt = t_vals[i] - t_vals[i - 1]
|
||||
la0 = lat_vals[i - 1] if lat_vals else None
|
||||
lo0 = lon_vals[i - 1] if lon_vals else None
|
||||
la1 = lat_vals[i] if lat_vals else None
|
||||
lo1 = lon_vals[i] if lon_vals else None
|
||||
|
||||
if (has_gps and la0 is not None and lo0 is not None
|
||||
and la1 is not None and lo1 is not None):
|
||||
dist_m = _haversine_m(la0, lo0, la1, lo1)
|
||||
else:
|
||||
spd = (spd_vals[i] if spd_vals and spd_vals[i] is not None
|
||||
else prev_speed)
|
||||
dist_m = (spd / 3.6) * max(dt, 0)
|
||||
|
||||
if spd_vals and spd_vals[i] is not None:
|
||||
prev_speed = spd_vals[i]
|
||||
|
||||
cum_dist += dist_m
|
||||
if cum_dist - last_kept >= _SPATIAL_RESOLUTION_M:
|
||||
kept.append(i)
|
||||
last_kept = cum_dist
|
||||
|
||||
if kept[-1] != n - 1:
|
||||
kept.append(n - 1)
|
||||
|
||||
if len(kept) >= n:
|
||||
skipped += 1
|
||||
continue # already sparse (very short / indoor / rest-stop heavy)
|
||||
|
||||
for key in _CHANNELS:
|
||||
ch = ts.get(key)
|
||||
if ch:
|
||||
ts[key] = [ch[i] for i in kept]
|
||||
|
||||
ts_path.write_text(
|
||||
json.dumps(ts, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
updated += 1
|
||||
|
||||
console.print(
|
||||
f" [cyan]{user_dir.name}[/cyan]: "
|
||||
f"{updated} downsampled, {skipped} skipped (indoor / short / already sparse)"
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--config", "config_path", default=None,
|
||||
help="Path to extract_config.yaml (reads output.dir from it).")
|
||||
@@ -175,6 +624,24 @@ def _link_data(site: Path, data: Path) -> None:
|
||||
help="(Multi-user) Incrementally re-merge one user's shard only.")
|
||||
@click.option("--no-build", "no_build", is_flag=True,
|
||||
help="Skip the Astro build step (just merge sidecars and update manifests).")
|
||||
@click.option("--recompute-climbs", "recompute_climbs", is_flag=True,
|
||||
help="Recompute best_climb_m for all cycling activities from stored timeseries "
|
||||
"(run once after upgrading the climb algorithm).")
|
||||
@click.option("--recompute-elevation", "recompute_elevation", is_flag=True,
|
||||
help="Recompute elevation_gain_m/loss_m for all activities from stored timeseries "
|
||||
"(run once after upgrading the dropout-skip fix).")
|
||||
@click.option("--recompute-vam", "recompute_vam", is_flag=True,
|
||||
help="Recompute climbing_vam_mh and climbing_time_s for all activities from stored "
|
||||
"timeseries (run once after adding climbing_time_s to the schema).")
|
||||
@click.option("--backfill-vam-summary", "backfill_vam_summary", is_flag=True,
|
||||
help="Copy climbing_vam_mh from detail JSONs into index.json summaries "
|
||||
"(run once after the VAM curve → summary migration).")
|
||||
@click.option("--backfill-speed", "backfill_speed", is_flag=True,
|
||||
help="Compute GPS-derived speed for timeseries where the device didn't record "
|
||||
"per-second speed (run once to enable speed map coloring on older activities).")
|
||||
@click.option("--downsample-timeseries", "downsample_timeseries", is_flag=True,
|
||||
help="Apply 10 m spatial downsampling to all stored timeseries files "
|
||||
"(run once after deploying the downsampling code).")
|
||||
def render(
|
||||
config_path: Optional[str],
|
||||
data_dir: Optional[str],
|
||||
@@ -184,6 +651,12 @@ def render(
|
||||
deploy: Optional[str],
|
||||
handle: Optional[str],
|
||||
no_build: bool,
|
||||
recompute_climbs: bool,
|
||||
recompute_elevation: bool,
|
||||
recompute_vam: bool,
|
||||
backfill_vam_summary: bool,
|
||||
backfill_speed: bool,
|
||||
downsample_timeseries: bool,
|
||||
) -> None:
|
||||
"""Build (or serve) the BincioActivity static site from a BAS data store."""
|
||||
|
||||
@@ -193,7 +666,33 @@ def render(
|
||||
console.print(f"Site: [cyan]{site}[/cyan]")
|
||||
console.print(f"Data: [cyan]{data}[/cyan]")
|
||||
|
||||
if recompute_climbs:
|
||||
console.print("Recomputing best climbs from timeseries…")
|
||||
_recompute_best_climbs(data, handle=handle)
|
||||
|
||||
if recompute_elevation:
|
||||
console.print("Recomputing elevation gain/loss from timeseries…")
|
||||
_recompute_elevation(data, handle=handle)
|
||||
|
||||
if recompute_vam:
|
||||
console.print("Recomputing VAM and climbing time from timeseries…")
|
||||
_recompute_vam(data, handle=handle)
|
||||
|
||||
if backfill_vam_summary:
|
||||
console.print("Backfilling climbing_vam_mh into summaries…")
|
||||
_backfill_vam_summary(data, handle=handle)
|
||||
|
||||
if backfill_speed:
|
||||
console.print("Backfilling GPS-derived speed into timeseries…")
|
||||
_backfill_speed(data, handle=handle)
|
||||
|
||||
if downsample_timeseries:
|
||||
console.print("Applying spatial downsampling to timeseries…")
|
||||
_downsample_timeseries(data, handle=handle)
|
||||
|
||||
_merge_edits(data, handle=handle)
|
||||
_rebuild_athlete_json(data, handle=handle)
|
||||
_bake_tracks(data, handle=handle)
|
||||
_write_root_manifest(data)
|
||||
|
||||
if no_build:
|
||||
@@ -201,15 +700,23 @@ def render(
|
||||
return
|
||||
|
||||
_ensure_npm(site)
|
||||
_link_data(site, data)
|
||||
|
||||
env = {**os.environ, "BINCIO_DATA_DIR": str(data)}
|
||||
|
||||
if serve:
|
||||
# Dev server needs to serve /data/ files at runtime from public/
|
||||
_link_data(site, data)
|
||||
console.print("Starting [cyan]astro dev[/cyan]…")
|
||||
subprocess.run(["npm", "run", "dev"], cwd=site, env=env)
|
||||
return
|
||||
|
||||
# Production build: BINCIO_DATA_DIR is already set so manifest.ts reads
|
||||
# data directly; remove any leftover public/data symlink so Astro doesn't
|
||||
# copy the full data directory (9+ GB) into dist/.
|
||||
public_data = site / "public" / "data"
|
||||
if public_data.is_symlink():
|
||||
public_data.unlink()
|
||||
|
||||
# Build
|
||||
cmd = ["npm", "run", "build"]
|
||||
if out_dir:
|
||||
|
||||
+121
-34
@@ -21,6 +21,31 @@ import yaml
|
||||
|
||||
# Per-user-directory lock so concurrent upload requests and the dev file-watcher
|
||||
# cannot run merge_all simultaneously on the same directory.
|
||||
|
||||
|
||||
def _fix_surrogates(obj: object) -> object:
|
||||
"""Recursively replace surrogate pairs in strings with proper Unicode code points.
|
||||
|
||||
Surrogate pairs (U+D800–U+DFFF) are valid in Python str but not in UTF-8.
|
||||
They typically arise when emoji from UTF-16-encoded sources (Strava, some FIT
|
||||
devices) are decoded incorrectly. encode/decode via utf-16 with surrogatepass
|
||||
reconstructs the intended characters.
|
||||
"""
|
||||
if isinstance(obj, str):
|
||||
try:
|
||||
obj.encode("utf-8")
|
||||
return obj
|
||||
except UnicodeEncodeError:
|
||||
return obj.encode("utf-16", "surrogatepass").decode("utf-16")
|
||||
if isinstance(obj, dict):
|
||||
return {k: _fix_surrogates(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_fix_surrogates(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _dumps(obj: object) -> str:
|
||||
return json.dumps(_fix_surrogates(obj), indent=2, ensure_ascii=False)
|
||||
_merge_locks: dict[str, threading.Lock] = {}
|
||||
_merge_locks_mu = threading.Lock()
|
||||
|
||||
@@ -44,8 +69,9 @@ 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)
|
||||
d.setdefault("custom", {})
|
||||
d["custom"] = dict(d["custom"]) # don't mutate original
|
||||
@@ -54,6 +80,11 @@ def apply_sidecar(detail: dict, fm: dict, body: str) -> dict:
|
||||
d["title"] = str(fm["title"])
|
||||
if "sport" in fm:
|
||||
d["sport"] = str(fm["sport"])
|
||||
if "sub_sport" in fm:
|
||||
d["sub_sport"] = str(fm["sub_sport"]) if fm["sub_sport"] else None
|
||||
# Infer indoor from title when sub_sport is still absent after sidecar
|
||||
if not d.get("sub_sport") and _infer_indoor_title(d.get("title") or ""):
|
||||
d["sub_sport"] = "indoor"
|
||||
if "gear" in fm:
|
||||
d["gear"] = str(fm["gear"]) if fm["gear"] else d.get("gear")
|
||||
if body:
|
||||
@@ -66,12 +97,19 @@ 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 [])]
|
||||
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
|
||||
|
||||
|
||||
def _apply_sidecar_summary(summary: dict, fm: dict) -> dict:
|
||||
"""Apply sidecar overrides to an index summary entry."""
|
||||
from bincio.extract.writer import _infer_indoor_title
|
||||
s = dict(summary)
|
||||
s.setdefault("custom", {})
|
||||
s["custom"] = dict(s["custom"])
|
||||
@@ -80,10 +118,17 @@ def _apply_sidecar_summary(summary: dict, fm: dict) -> dict:
|
||||
s["title"] = str(fm["title"])
|
||||
if "sport" in fm:
|
||||
s["sport"] = str(fm["sport"])
|
||||
if "sub_sport" in fm:
|
||||
s["sub_sport"] = str(fm["sub_sport"]) if fm["sub_sport"] else None
|
||||
if "gear" in fm:
|
||||
s["gear"] = str(fm["gear"]) if fm["gear"] else s.get("gear")
|
||||
if "highlight" in fm:
|
||||
s["custom"]["highlight"] = bool(fm["highlight"])
|
||||
if "private" in fm:
|
||||
s["privacy"] = "unlisted" if fm["private"] else summary.get("privacy", "public")
|
||||
# Infer indoor from title when sub_sport is still absent
|
||||
if not s.get("sub_sport") and _infer_indoor_title(s.get("title") or ""):
|
||||
s["sub_sport"] = "indoor"
|
||||
|
||||
return s
|
||||
|
||||
@@ -127,6 +172,12 @@ def _merge_one_locked(data_dir: Path, activity_id: str) -> None:
|
||||
)
|
||||
|
||||
needs_merge = has_sidecar or bool(image_files)
|
||||
# Also need a real file (not symlink) when title inference would change sub_sport
|
||||
if not needs_merge and not has_sidecar:
|
||||
from bincio.extract.writer import _infer_indoor_title
|
||||
_peek = json.loads(src.read_text(encoding="utf-8"))
|
||||
if not _peek.get("sub_sport") and _infer_indoor_title(_peek.get("title") or ""):
|
||||
needs_merge = True
|
||||
|
||||
# Symlink the timeseries file (never merged — always points to the extract output)
|
||||
ts_src = acts_dir / f"{activity_id}.timeseries.json"
|
||||
@@ -141,14 +192,17 @@ def _merge_one_locked(data_dir: Path, activity_id: str) -> None:
|
||||
dest.unlink()
|
||||
|
||||
if needs_merge:
|
||||
detail = json.loads(src.read_text(encoding="utf-8"))
|
||||
detail = locals().get("_peek") or json.loads(src.read_text(encoding="utf-8"))
|
||||
if has_sidecar:
|
||||
fm, body = parse_sidecar(sidecar_path) # type: ignore[arg-type]
|
||||
detail = apply_sidecar(detail, fm, body)
|
||||
else:
|
||||
# No sidecar — still apply title inference
|
||||
detail = apply_sidecar(detail, {}, "")
|
||||
if image_files:
|
||||
detail["custom"] = dict(detail.get("custom") or {})
|
||||
detail["custom"]["images"] = image_files
|
||||
dest.write_text(json.dumps(detail, indent=2, ensure_ascii=False))
|
||||
dest.write_text(_dumps(detail))
|
||||
else:
|
||||
dest.symlink_to(src.resolve())
|
||||
|
||||
@@ -166,9 +220,8 @@ def _merge_one_locked(data_dir: Path, activity_id: str) -> None:
|
||||
activities = []
|
||||
for s in index.get("activities", []):
|
||||
aid = s.get("id", "")
|
||||
if aid in all_sidecars:
|
||||
fm, _ = all_sidecars[aid]
|
||||
s = _apply_sidecar_summary(s, fm)
|
||||
fm, _ = all_sidecars[aid] if aid in all_sidecars else ({}, "")
|
||||
s = _apply_sidecar_summary(s, fm)
|
||||
activities.append(s)
|
||||
|
||||
activities.sort(key=lambda a: a.get("started_at", ""), reverse=True)
|
||||
@@ -192,6 +245,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():
|
||||
@@ -214,6 +274,17 @@ def _merge_all_locked(data_dir: Path) -> int:
|
||||
|
||||
to_merge = set(sidecars) | set(image_lists)
|
||||
|
||||
# Also include activities whose title implies indoor (no sidecar required)
|
||||
_index_path = data_dir / "index.json"
|
||||
_cached_index: dict | None = None
|
||||
if _index_path.exists():
|
||||
from bincio.extract.writer import _infer_indoor_title
|
||||
_cached_index = json.loads(_index_path.read_text(encoding="utf-8"))
|
||||
for _s in _cached_index.get("activities", []):
|
||||
_aid = _s.get("id", "")
|
||||
if _aid and not _s.get("sub_sport") and _infer_indoor_title(_s.get("title") or ""):
|
||||
to_merge.add(_aid)
|
||||
|
||||
# Wipe and recreate _merged/activities/
|
||||
shutil.rmtree(merged_acts, ignore_errors=True)
|
||||
merged_acts.mkdir(parents=True, exist_ok=True)
|
||||
@@ -229,11 +300,13 @@ 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, {}, "", 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]
|
||||
dest.write_text(json.dumps(detail, indent=2, ensure_ascii=False))
|
||||
dest.write_text(_dumps(detail))
|
||||
else:
|
||||
if not dest.exists() and not dest.is_symlink():
|
||||
dest.symlink_to(src.resolve())
|
||||
@@ -253,7 +326,7 @@ def _merge_all_locked(data_dir: Path) -> int:
|
||||
athlete_dest = merged_dir / "athlete.json"
|
||||
if athlete_dest.exists() or athlete_dest.is_symlink():
|
||||
athlete_dest.unlink()
|
||||
if athlete_src.exists():
|
||||
if athlete_src.exists() and athlete_src.stat().st_size > 0:
|
||||
athlete_edits_path = data_dir / "edits" / "athlete.yaml"
|
||||
if athlete_edits_path.exists():
|
||||
try:
|
||||
@@ -265,22 +338,24 @@ def _merge_all_locked(data_dir: Path) -> int:
|
||||
edits = {}
|
||||
_ATHLETE_EDITABLE = {"max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"}
|
||||
if edits:
|
||||
athlete_data = json.loads(athlete_src.read_text(encoding="utf-8"))
|
||||
athlete_data.update({k: v for k, v in edits.items() if k in _ATHLETE_EDITABLE})
|
||||
athlete_dest.write_text(json.dumps(athlete_data, indent=2, ensure_ascii=False))
|
||||
try:
|
||||
athlete_data = json.loads(athlete_src.read_text(encoding="utf-8"))
|
||||
athlete_data.update({k: v for k, v in edits.items() if k in _ATHLETE_EDITABLE})
|
||||
athlete_dest.write_text(_dumps(athlete_data))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
else:
|
||||
athlete_dest.symlink_to(athlete_src.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"))
|
||||
index = _cached_index or 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)
|
||||
fm, _ = sidecars[aid] if aid in sidecars else ({}, "")
|
||||
s = _apply_sidecar_summary(s, fm)
|
||||
activities.append(s)
|
||||
|
||||
# "unlisted" (and legacy "private") activities are kept in the index so
|
||||
@@ -334,7 +409,7 @@ def _write_year_shards(merged_dir: Path, activities: list[dict], index_meta: dic
|
||||
"activities": by_year[year],
|
||||
}
|
||||
fname = f"index-{year}.json"
|
||||
(merged_dir / fname).write_text(json.dumps(shard_doc, indent=2, ensure_ascii=False))
|
||||
(merged_dir / fname).write_text(_dumps(shard_doc))
|
||||
shards.append({"url": fname, "year": int(year) if year.isdigit() else 0,
|
||||
"count": len(by_year[year])})
|
||||
|
||||
@@ -343,7 +418,7 @@ def _write_year_shards(merged_dir: Path, activities: list[dict], index_meta: dic
|
||||
"shards": shards,
|
||||
"activities": [],
|
||||
}
|
||||
(merged_dir / "index.json").write_text(json.dumps(root_doc, indent=2, ensure_ascii=False))
|
||||
(merged_dir / "index.json").write_text(_dumps(root_doc))
|
||||
|
||||
|
||||
FEED_PAGE_SIZE = 50
|
||||
@@ -355,10 +430,11 @@ _COMBINED_FEED_STRIP = _FEED_STRIP | {"mmp"}
|
||||
|
||||
|
||||
def write_combined_feed(data_dir: Path) -> int:
|
||||
"""Build data_dir/feed.json — the N most recent activities across all users.
|
||||
"""Build data_dir/feed.json and per-month data_dir/feed-YYYY-MM.json shards.
|
||||
|
||||
The global feed page loads this single file instead of resolving 20+ user
|
||||
shards recursively. Returns the number of activities written.
|
||||
feed.json is a BAS shard index (same format as per-user index.json).
|
||||
Each feed-YYYY-MM.json contains all activities for that month across all users,
|
||||
sorted newest-first. Returns the number of activities written.
|
||||
"""
|
||||
user_dirs = sorted(
|
||||
p for p in data_dir.iterdir()
|
||||
@@ -401,24 +477,35 @@ def write_combined_feed(data_dir: Path) -> int:
|
||||
|
||||
all_activities.sort(key=lambda a: a.get("started_at", ""), reverse=True)
|
||||
|
||||
# Remove stale feed pages
|
||||
# Remove stale feed files (sequential pages and old year shards)
|
||||
for f in data_dir.glob("feed*.json"):
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
if not all_activities:
|
||||
return 0
|
||||
|
||||
pages = [all_activities[i:i + FEED_PAGE_SIZE] for i in range(0, len(all_activities), FEED_PAGE_SIZE)]
|
||||
for page_num, page in enumerate(pages):
|
||||
slim = [{k: v for k, v in a.items() if k not in _COMBINED_FEED_STRIP} for a in page]
|
||||
fname = "feed.json" if page_num == 0 else f"feed-{page_num + 1}.json"
|
||||
doc = {
|
||||
"bas_version": "1.0",
|
||||
"page": page_num + 1,
|
||||
"total_pages": len(pages),
|
||||
"total_activities": len(all_activities),
|
||||
"activities": slim,
|
||||
}
|
||||
(data_dir / fname).write_text(json.dumps(doc, indent=2, ensure_ascii=False))
|
||||
# Group by YYYY-MM (month), preserving newest-first order within each bucket
|
||||
by_month: dict[str, list[dict]] = {}
|
||||
for a in all_activities:
|
||||
ym = (a.get("started_at") or "")[:7] # "YYYY-MM"
|
||||
if len(ym) == 7 and ym[4] == "-":
|
||||
by_month.setdefault(ym, []).append(a)
|
||||
|
||||
months_desc = sorted(by_month.keys(), reverse=True)
|
||||
|
||||
# Write per-month shard files (~150-200 acts each → ~25 KB gzip)
|
||||
for ym, acts in by_month.items():
|
||||
slim = [{k: v for k, v in a.items() if k not in _COMBINED_FEED_STRIP} for a in acts]
|
||||
doc: dict = {"bas_version": "1.0", "activities": slim}
|
||||
(data_dir / f"feed-{ym}.json").write_text(_dumps(doc))
|
||||
|
||||
# Write feed.json as a BAS shard index (same pattern as per-user index.json)
|
||||
index_doc: dict = {
|
||||
"bas_version": "1.0",
|
||||
"total_activities": len(all_activities),
|
||||
"shards": [{"url": f"feed-{ym}.json"} for ym in months_desc],
|
||||
"activities": [],
|
||||
}
|
||||
(data_dir / "feed.json").write_text(_dumps(index_doc))
|
||||
|
||||
return len(all_activities)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""OG image generation — 400×400 track-on-dark PNG for social link previews."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Colour stops matching ActivityMap.svelte _linearColor stops
|
||||
_STOPS: list[tuple[float, tuple[int, int, int]]] = [
|
||||
(0.00, (59, 130, 246)), # blue-500 (low)
|
||||
(0.33, (74, 222, 128)), # green-400
|
||||
(0.66, (250, 204, 21)), # yellow-400
|
||||
(1.00, (239, 68, 68)), # red-500 (high)
|
||||
]
|
||||
|
||||
_BG = (9, 9, 11) # zinc-950
|
||||
_SIZE = 400
|
||||
_PAD = 28
|
||||
_WIDTH = 5 # logical line width; rendered at 2× then downscaled
|
||||
|
||||
|
||||
def _lerp_color(t: float) -> tuple[int, int, int]:
|
||||
t = max(0.0, min(1.0, t))
|
||||
for i in range(len(_STOPS) - 1):
|
||||
t0, c0 = _STOPS[i]
|
||||
t1, c1 = _STOPS[i + 1]
|
||||
if t <= t1:
|
||||
f = (t - t0) / (t1 - t0) if t1 > t0 else 0.0
|
||||
return (
|
||||
round(c0[0] + f * (c1[0] - c0[0])),
|
||||
round(c0[1] + f * (c1[1] - c0[1])),
|
||||
round(c0[2] + f * (c1[2] - c0[2])),
|
||||
)
|
||||
return _STOPS[-1][1]
|
||||
|
||||
|
||||
def generate(
|
||||
lat_arr: list[Optional[float]],
|
||||
lon_arr: list[Optional[float]],
|
||||
ele_arr: list[Optional[float]],
|
||||
) -> bytes:
|
||||
"""Return PNG bytes for a 400×400 elevation-coloured track image.
|
||||
|
||||
Any of the three arrays may have None gaps (no-GPS seconds).
|
||||
Returns a plain dark square if there are fewer than 2 valid GPS points.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image, ImageDraw # type: ignore[import]
|
||||
except ImportError as e:
|
||||
raise RuntimeError("Pillow is required for OG image generation") from e
|
||||
|
||||
# Collect valid GPS points paired with elevation (None → 0 for colouring)
|
||||
pts: list[tuple[float, float, float]] = []
|
||||
for lat, lon, ele in zip(lat_arr, lon_arr, ele_arr):
|
||||
if lat is not None and lon is not None:
|
||||
pts.append((float(lat), float(lon), float(ele) if ele is not None else 0.0))
|
||||
|
||||
if len(pts) < 2:
|
||||
img = Image.new("RGB", (_SIZE, _SIZE), _BG)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, "PNG", optimize=True)
|
||||
return buf.getvalue()
|
||||
|
||||
lats = [p[0] for p in pts]
|
||||
lons = [p[1] for p in pts]
|
||||
eles = [p[2] for p in pts]
|
||||
|
||||
min_lat, max_lat = min(lats), max(lats)
|
||||
min_lon, max_lon = min(lons), max(lons)
|
||||
min_ele, max_ele = min(eles), max(eles)
|
||||
ele_range = max_ele - min_ele or 1.0
|
||||
|
||||
# Mercator correction: compress longitude range by cos(mid_lat) so the
|
||||
# track doesn't look stretched horizontally at higher latitudes.
|
||||
cos_lat = math.cos(math.radians((min_lat + max_lat) / 2))
|
||||
|
||||
usable = _SIZE - 2 * _PAD
|
||||
lat_span = max_lat - min_lat or 1e-6
|
||||
lon_span = (max_lon - min_lon) * cos_lat or 1e-6
|
||||
scale = min(usable / lat_span, usable / lon_span)
|
||||
|
||||
# Centre the track within the canvas
|
||||
x_off = _PAD + (usable - (max_lon - min_lon) * cos_lat * scale) / 2
|
||||
y_off = _PAD + (usable - lat_span * scale) / 2
|
||||
|
||||
def project(lat: float, lon: float) -> tuple[float, float]:
|
||||
x = x_off + (lon - min_lon) * cos_lat * scale
|
||||
y = _SIZE - (y_off + (lat - min_lat) * scale)
|
||||
return x, y
|
||||
|
||||
# Render at 2× resolution then downscale for cheap anti-aliasing
|
||||
S = _SIZE * 2
|
||||
lw = _WIDTH * 2
|
||||
img = Image.new("RGB", (S, S), _BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
for i in range(len(pts) - 1):
|
||||
x0, y0 = project(pts[i][0], pts[i][1])
|
||||
x1, y1 = project(pts[i+1][0], pts[i+1][1])
|
||||
t = (eles[i] - min_ele) / ele_range
|
||||
color = _lerp_color(t)
|
||||
draw.line([(x0 * 2, y0 * 2), (x1 * 2, y1 * 2)], fill=color, width=lw)
|
||||
|
||||
img = img.resize((_SIZE, _SIZE), Image.LANCZOS)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, "PNG", optimize=True)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def generate_for_activity(ts_path: Path) -> bytes:
|
||||
"""Convenience wrapper: read a .timeseries.json file and call generate()."""
|
||||
import json
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
return generate(
|
||||
ts.get("lat") or [],
|
||||
ts.get("lon") or [],
|
||||
ts.get("elevation_m") or [],
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""bincio segments — segment management CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def _dt(s: str) -> datetime:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
@click.group("segments")
|
||||
def segments_group() -> None:
|
||||
"""Manage segments and detect efforts."""
|
||||
|
||||
|
||||
@segments_group.command("detect")
|
||||
@click.option("--data-dir", required=True, type=click.Path(), help="BAS data directory (e.g. /var/bincio)")
|
||||
@click.option("--handle", required=True, help="User handle to run detection for")
|
||||
@click.option("--activity-id", default=None, help="Limit to a single activity ID (optional)")
|
||||
@click.option("--segment-id", default=None, help="Limit to a single segment ID (optional)")
|
||||
@click.option("--fresh", is_flag=True, default=False, help="Clear existing efforts before detecting")
|
||||
def detect_cmd(data_dir: str, handle: str, activity_id: str | None, segment_id: str | None, fresh: bool) -> None:
|
||||
"""Retroactively detect segment efforts for stored activities.
|
||||
|
||||
Walks every activity with GPS data, runs the detection algorithm against
|
||||
all (or a single) segment, and persists any new efforts found.
|
||||
"""
|
||||
from bincio.segments.detect import track_from_timeseries_json, detect_one, detect_all
|
||||
from bincio.segments import store as _store
|
||||
|
||||
dd = Path(data_dir).expanduser().resolve()
|
||||
user_dir = dd / handle
|
||||
acts_dir = user_dir / "activities"
|
||||
|
||||
if not acts_dir.exists():
|
||||
click.echo(f"No activities directory at {acts_dir}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Choose which segments to check.
|
||||
if segment_id:
|
||||
seg = _store.load_segment(dd, segment_id)
|
||||
if seg is None:
|
||||
click.echo(f"Segment not found: {segment_id}", err=True)
|
||||
sys.exit(1)
|
||||
segments = [seg]
|
||||
else:
|
||||
segments = _store.list_segments(dd)
|
||||
|
||||
if not segments:
|
||||
click.echo("No segments defined.", err=True)
|
||||
sys.exit(0)
|
||||
|
||||
if fresh:
|
||||
for seg in segments:
|
||||
_store.save_efforts(dd, handle, seg.id, [])
|
||||
click.echo(f"Cleared existing efforts for {len(segments)} segment(s).")
|
||||
|
||||
# Choose which activities to process.
|
||||
if activity_id:
|
||||
detail_files = [acts_dir / f"{activity_id}.json"]
|
||||
else:
|
||||
detail_files = sorted(acts_dir.glob("*.json"))
|
||||
# Exclude timeseries files.
|
||||
detail_files = [f for f in detail_files if ".timeseries." not in f.name]
|
||||
|
||||
total_efforts = 0
|
||||
processed = 0
|
||||
|
||||
for detail_path in detail_files:
|
||||
try:
|
||||
detail = json.loads(detail_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
ts_url = detail.get("timeseries_url")
|
||||
if not ts_url:
|
||||
continue
|
||||
|
||||
act_id = detail.get("id", detail_path.stem)
|
||||
sport = detail.get("sport", "other")
|
||||
started = detail.get("started_at")
|
||||
if not started:
|
||||
continue
|
||||
try:
|
||||
started_at = _dt(started)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
ts_path = user_dir / ts_url
|
||||
if not ts_path.exists():
|
||||
continue
|
||||
try:
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
track = track_from_timeseries_json(ts, act_id, sport, started_at)
|
||||
if track is None:
|
||||
continue
|
||||
|
||||
processed += 1
|
||||
for seg in segments:
|
||||
from bincio.segments.detect import detect_one
|
||||
efforts = detect_one(track, seg)
|
||||
for effort in efforts:
|
||||
_store.add_effort(dd, handle, seg.id, effort)
|
||||
if efforts:
|
||||
click.echo(
|
||||
f" {act_id}: {len(efforts)} effort(s) on '{seg.name}' "
|
||||
f"({', '.join(str(e.elapsed_s) + 's' for e in efforts)})"
|
||||
)
|
||||
total_efforts += len(efforts)
|
||||
|
||||
click.echo(f"\nProcessed {processed} activities, found {total_efforts} effort(s).")
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Segment effort detection.
|
||||
|
||||
Matches GPS tracks against stored segment polylines and produces SegmentEffort
|
||||
records. Works from either a live ParsedActivity (ingest path) or from a
|
||||
stored timeseries JSON (retroactive path).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from bincio.segments.models import Segment, SegmentEffort
|
||||
|
||||
# ── tuning constants ──────────────────────────────────────────────────────────
|
||||
|
||||
MATCH_RADIUS_M = 25 # max distance to segment start/end to open/close an effort
|
||||
CONFORMANCE_MAX_DEV_M = 50 # max allowed deviation for each interior segment point
|
||||
CONFORMANCE_MAX_FRAC = 0.30 # max fraction of interior points allowed to deviate
|
||||
|
||||
# Minimum geometric speed (segment_distance / elapsed_s) per sport, in m/s.
|
||||
# Rejects false matches from long circuit rides where the track passes the
|
||||
# segment start early and the segment end hours later.
|
||||
_MIN_SPEED_MS: dict[str, float] = {
|
||||
'cycling': 2.0, # ~7.2 km/h — below any realistic cyclist even on brutal climbs
|
||||
'running': 0.8, # ~2.9 km/h
|
||||
}
|
||||
_MIN_SPEED_DEFAULT = 0.3 # hiking / walking / unknown
|
||||
|
||||
# Maximum geometric speed per sport in m/s — rejects GPS glitch matches.
|
||||
_MAX_SPEED_MS: dict[str, float] = {
|
||||
'cycling': 30.0, # ~108 km/h
|
||||
'running': 12.0, # ~43 km/h
|
||||
}
|
||||
_MAX_SPEED_DEFAULT = 20.0
|
||||
|
||||
# ── fast distance approximation ───────────────────────────────────────────────
|
||||
|
||||
_R = 6_371_000.0 # Earth radius in metres
|
||||
|
||||
|
||||
def _dist(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Equirectangular approximation — fast, accurate to <0.1% within 100 km."""
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
mlat = math.radians((lat1 + lat2) / 2.0)
|
||||
return math.hypot(dlat * _R, dlon * _R * math.cos(mlat))
|
||||
|
||||
|
||||
# ── activity track representation ────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class ActivityTrack:
|
||||
"""Common internal representation for detection, independent of source format."""
|
||||
activity_id: str
|
||||
sport: str
|
||||
started_at: datetime
|
||||
# Parallel arrays — all same length, GPS-only points (lat/lon not None).
|
||||
lats: list[float]
|
||||
lons: list[float]
|
||||
times: list[int] # seconds from started_at
|
||||
speeds: list[Optional[float]]
|
||||
hrs: list[Optional[int]]
|
||||
powers: list[Optional[int]]
|
||||
bbox: list[float] = field(default_factory=list) # [lon_min, lat_min, lon_max, lat_max]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.lats and not self.bbox:
|
||||
self.bbox = [
|
||||
min(self.lons), min(self.lats),
|
||||
max(self.lons), max(self.lats),
|
||||
]
|
||||
|
||||
|
||||
def track_from_parsed(parsed: "ParsedActivity", activity_id: str) -> Optional[ActivityTrack]: # noqa: F821
|
||||
"""Build an ActivityTrack from a ParsedActivity (used during ingest)."""
|
||||
lats, lons, times, speeds, hrs, powers = [], [], [], [], [], []
|
||||
last_t = -1
|
||||
for p in parsed.points:
|
||||
if p.lat is None or p.lon is None:
|
||||
continue
|
||||
t = int((p.timestamp - parsed.started_at).total_seconds())
|
||||
if t < 0 or t == last_t:
|
||||
continue
|
||||
last_t = t
|
||||
lats.append(p.lat)
|
||||
lons.append(p.lon)
|
||||
times.append(t)
|
||||
speeds.append(p.speed_kmh)
|
||||
hrs.append(p.hr_bpm)
|
||||
powers.append(p.power_w)
|
||||
if len(lats) < 2:
|
||||
return None
|
||||
return ActivityTrack(
|
||||
activity_id=activity_id,
|
||||
sport=parsed.sport,
|
||||
started_at=parsed.started_at,
|
||||
lats=lats, lons=lons, times=times,
|
||||
speeds=speeds, hrs=hrs, powers=powers,
|
||||
)
|
||||
|
||||
|
||||
def track_from_timeseries_json(
|
||||
ts: dict,
|
||||
activity_id: str,
|
||||
sport: str,
|
||||
started_at: datetime,
|
||||
) -> Optional[ActivityTrack]:
|
||||
"""Build an ActivityTrack from a stored timeseries JSON dict."""
|
||||
raw_lats = ts.get("lat") or []
|
||||
raw_lons = ts.get("lon") or []
|
||||
raw_t = ts.get("t") or []
|
||||
raw_spd = ts.get("speed_kmh") or []
|
||||
raw_hr = ts.get("hr_bpm") or []
|
||||
raw_pwr = ts.get("power_w") or []
|
||||
n = len(raw_t)
|
||||
if n < 2 or not raw_lats or len(raw_lats) != n:
|
||||
return None
|
||||
|
||||
def _pad(arr: list, length: int) -> list:
|
||||
return arr + [None] * (length - len(arr))
|
||||
|
||||
raw_spd = _pad(raw_spd, n)
|
||||
raw_hr = _pad(raw_hr, n)
|
||||
raw_pwr = _pad(raw_pwr, n)
|
||||
|
||||
lats, lons, times, speeds, hrs, powers = [], [], [], [], [], []
|
||||
for i in range(n):
|
||||
if raw_lats[i] is None or raw_lons[i] is None:
|
||||
continue
|
||||
lats.append(float(raw_lats[i]))
|
||||
lons.append(float(raw_lons[i]))
|
||||
times.append(int(raw_t[i]))
|
||||
speeds.append(raw_spd[i])
|
||||
hrs.append(raw_hr[i])
|
||||
powers.append(raw_pwr[i])
|
||||
|
||||
if len(lats) < 2:
|
||||
return None
|
||||
return ActivityTrack(
|
||||
activity_id=activity_id,
|
||||
sport=sport,
|
||||
started_at=started_at,
|
||||
lats=lats, lons=lons, times=times,
|
||||
speeds=speeds, hrs=hrs, powers=powers,
|
||||
)
|
||||
|
||||
|
||||
# ── effort metric helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _avg_nonnull(vals: list, lo: int, hi: int) -> Optional[float]:
|
||||
nums = [v for v in vals[lo:hi + 1] if v is not None]
|
||||
return sum(nums) / len(nums) if nums else None
|
||||
|
||||
|
||||
def _np_power(powers: list[Optional[int]], lo: int, hi: int) -> Optional[int]:
|
||||
"""Coggan NP from a slice of 1Hz power data (may have gaps/nulls)."""
|
||||
WIN = 30
|
||||
chunk = powers[lo:hi + 1]
|
||||
filled = [v if v is not None else 0 for v in chunk]
|
||||
n = len(filled)
|
||||
if n < WIN:
|
||||
# Too short for rolling average — just return avg power.
|
||||
non_null = [v for v in chunk if v is not None]
|
||||
return int(round(sum(non_null) / len(non_null))) if non_null else None
|
||||
half = WIN // 2
|
||||
window_sum = sum(filled[:WIN])
|
||||
fourth_powers = []
|
||||
for i in range(half, n - half):
|
||||
fourth_powers.append((window_sum / WIN) ** 4)
|
||||
if i + half + 1 < n:
|
||||
window_sum += filled[i + half + 1] - filled[i - half]
|
||||
if not fourth_powers:
|
||||
return None
|
||||
return int(round((sum(fourth_powers) / len(fourth_powers)) ** 0.25))
|
||||
|
||||
|
||||
# ── detection algorithm ───────────────────────────────────────────────────────
|
||||
|
||||
def _bboxes_overlap(a: list[float], b: list[float]) -> bool:
|
||||
return not (a[2] < b[0] or b[2] < a[0] or a[3] < b[1] or b[3] < a[1])
|
||||
|
||||
|
||||
def _conformance_ok(
|
||||
track: ActivityTrack,
|
||||
seg: Segment,
|
||||
i: int,
|
||||
j: int,
|
||||
) -> bool:
|
||||
"""Check that the track slice [i..j] follows the segment polyline."""
|
||||
interior = seg.polyline[1:-1]
|
||||
if not interior:
|
||||
return True # trivial 2-point segment
|
||||
failing = 0
|
||||
for sp in interior:
|
||||
slat, slon = sp[0], sp[1]
|
||||
min_d = min(
|
||||
_dist(slat, slon, track.lats[k], track.lons[k])
|
||||
for k in range(i, j + 1)
|
||||
)
|
||||
if min_d > CONFORMANCE_MAX_DEV_M:
|
||||
failing += 1
|
||||
return (failing / len(interior)) <= CONFORMANCE_MAX_FRAC
|
||||
|
||||
|
||||
def _extract_effort(
|
||||
track: ActivityTrack,
|
||||
seg: Segment,
|
||||
i: int,
|
||||
j: int,
|
||||
) -> SegmentEffort:
|
||||
elapsed_s = track.times[j] - track.times[i]
|
||||
started_at = (track.started_at + timedelta(seconds=track.times[i])).replace(microsecond=0)
|
||||
# Always derive avg speed from segment distance / elapsed time. Device-recorded
|
||||
# speed is unreliable across formats (m/s vs km/h in older FIT files) and
|
||||
# averaging instantaneous GPS speed over a slice gives different results anyway.
|
||||
avg_speed = (seg.distance_m / elapsed_s * 3.6) if elapsed_s > 0 else None
|
||||
avg_hr_raw = _avg_nonnull(track.hrs, i, j)
|
||||
avg_hr = int(round(avg_hr_raw)) if avg_hr_raw is not None else None
|
||||
avg_pwr_raw = _avg_nonnull(track.powers, i, j)
|
||||
avg_pwr = int(round(avg_pwr_raw)) if avg_pwr_raw is not None else None
|
||||
np_pwr = _np_power(track.powers, i, j) if any(v is not None for v in track.powers[i:j + 1]) else None
|
||||
return SegmentEffort(
|
||||
activity_id=track.activity_id,
|
||||
started_at=started_at,
|
||||
elapsed_s=max(1, elapsed_s),
|
||||
avg_speed_kmh=round(avg_speed, 2) if avg_speed is not None else None,
|
||||
avg_hr_bpm=avg_hr,
|
||||
avg_power_w=avg_pwr,
|
||||
np_power_w=np_pwr,
|
||||
detected_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def detect_one(track: ActivityTrack, seg: Segment) -> list[SegmentEffort]:
|
||||
"""Return all matching efforts for a single segment against a track."""
|
||||
if not track.bbox or not _bboxes_overlap(track.bbox, seg.bbox):
|
||||
return []
|
||||
if seg.sport and seg.sport != track.sport:
|
||||
return []
|
||||
|
||||
seg_start_lat, seg_start_lon = seg.polyline[0][0], seg.polyline[0][1]
|
||||
seg_end_lat, seg_end_lon = seg.polyline[-1][0], seg.polyline[-1][1]
|
||||
n = len(track.lats)
|
||||
efforts: list[SegmentEffort] = []
|
||||
|
||||
search_from = 0
|
||||
while search_from < n - 1:
|
||||
# Find next start candidate from search_from.
|
||||
start_idx = None
|
||||
for i in range(search_from, n):
|
||||
if _dist(seg_start_lat, seg_start_lon, track.lats[i], track.lons[i]) <= MATCH_RADIUS_M:
|
||||
start_idx = i
|
||||
break
|
||||
if start_idx is None:
|
||||
break
|
||||
|
||||
# Scan forward from start_idx for an end candidate.
|
||||
end_idx = None
|
||||
for j in range(start_idx + 1, n):
|
||||
if _dist(seg_end_lat, seg_end_lon, track.lats[j], track.lons[j]) <= MATCH_RADIUS_M:
|
||||
end_idx = j
|
||||
break
|
||||
|
||||
if end_idx is None:
|
||||
# No end found — no more efforts possible starting at or after start_idx.
|
||||
break
|
||||
|
||||
# Reject implausibly slow or fast matches.
|
||||
elapsed = track.times[end_idx] - track.times[start_idx]
|
||||
if elapsed > 0:
|
||||
geo_speed = seg.distance_m / elapsed
|
||||
min_speed = _MIN_SPEED_MS.get(track.sport, _MIN_SPEED_DEFAULT)
|
||||
max_speed = _MAX_SPEED_MS.get(track.sport, _MAX_SPEED_DEFAULT)
|
||||
if geo_speed < min_speed or geo_speed > max_speed:
|
||||
search_from = start_idx + 1
|
||||
continue
|
||||
|
||||
if _conformance_ok(track, seg, start_idx, end_idx):
|
||||
efforts.append(_extract_effort(track, seg, start_idx, end_idx))
|
||||
search_from = end_idx + 1
|
||||
else:
|
||||
# Conformance failed; try next start candidate after start_idx.
|
||||
search_from = start_idx + 1
|
||||
|
||||
return efforts
|
||||
|
||||
|
||||
def detect_all(
|
||||
track: ActivityTrack,
|
||||
handle: str,
|
||||
data_dir: Path,
|
||||
) -> int:
|
||||
"""Detect efforts for all segments and persist them. Returns effort count."""
|
||||
from bincio.segments import store as _store
|
||||
|
||||
segments = _store.list_segments(data_dir)
|
||||
total = 0
|
||||
for seg in segments:
|
||||
efforts = detect_one(track, seg)
|
||||
for effort in efforts:
|
||||
_store.add_effort(data_dir, handle, seg.id, effort)
|
||||
total += len(efforts)
|
||||
return total
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Segment and SegmentEffort data models."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
id: str
|
||||
name: str
|
||||
polyline: list[list[float]] # [[lat, lon], ...]
|
||||
distance_m: float
|
||||
bbox: list[float] # [lon_min, lat_min, lon_max, lat_max]
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
sport: Optional[str] = None # None = any sport
|
||||
|
||||
|
||||
@dataclass
|
||||
class SegmentEffort:
|
||||
activity_id: str
|
||||
started_at: datetime
|
||||
elapsed_s: int
|
||||
detected_at: datetime
|
||||
avg_speed_kmh: Optional[float] = None
|
||||
avg_hr_bpm: Optional[int] = None
|
||||
avg_power_w: Optional[int] = None
|
||||
np_power_w: Optional[int] = None
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Read/write segments and segment efforts to/from /var/bincio."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from bincio.segments.models import Segment, SegmentEffort
|
||||
|
||||
# /var/bincio/segments/{id}.json
|
||||
_SEGMENTS_DIR = "segments"
|
||||
# /var/bincio/data/{handle}/segment_efforts/{segment_id}.json
|
||||
_EFFORTS_SUBDIR = "segment_efforts"
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _segments_dir(data_dir: Path) -> Path:
|
||||
d = data_dir / _SEGMENTS_DIR
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _efforts_dir(data_dir: Path, handle: str) -> Path:
|
||||
d = data_dir / handle / _EFFORTS_SUBDIR
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
s = name.lower().strip()
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||
return s.strip("-")[:48]
|
||||
|
||||
|
||||
def _make_id(name: str) -> str:
|
||||
slug = _slugify(name)
|
||||
suffix = hashlib.sha256(name.encode()).hexdigest()[:4]
|
||||
return f"{slug}-{suffix}"
|
||||
|
||||
|
||||
def _dt(s: str) -> datetime:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def _iso(dt: datetime) -> str:
|
||||
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
# ── serialisation ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _segment_to_dict(seg: Segment) -> dict:
|
||||
return {
|
||||
"id": seg.id,
|
||||
"name": seg.name,
|
||||
"sport": seg.sport,
|
||||
"polyline": seg.polyline,
|
||||
"distance_m": round(seg.distance_m, 1),
|
||||
"bbox": [round(v, 6) for v in seg.bbox],
|
||||
"created_by": seg.created_by,
|
||||
"created_at": _iso(seg.created_at),
|
||||
}
|
||||
|
||||
|
||||
def _segment_from_dict(d: dict) -> Segment:
|
||||
return Segment(
|
||||
id=d["id"],
|
||||
name=d["name"],
|
||||
sport=d.get("sport"),
|
||||
polyline=d["polyline"],
|
||||
distance_m=float(d["distance_m"]),
|
||||
bbox=d["bbox"],
|
||||
created_by=d["created_by"],
|
||||
created_at=_dt(d["created_at"]),
|
||||
)
|
||||
|
||||
|
||||
def _effort_to_dict(e: SegmentEffort) -> dict:
|
||||
return {
|
||||
"activity_id": e.activity_id,
|
||||
"started_at": _iso(e.started_at),
|
||||
"elapsed_s": e.elapsed_s,
|
||||
"avg_speed_kmh": e.avg_speed_kmh,
|
||||
"avg_hr_bpm": e.avg_hr_bpm,
|
||||
"avg_power_w": e.avg_power_w,
|
||||
"np_power_w": e.np_power_w,
|
||||
"detected_at": _iso(e.detected_at),
|
||||
}
|
||||
|
||||
|
||||
def _effort_from_dict(d: dict) -> SegmentEffort:
|
||||
return SegmentEffort(
|
||||
activity_id=d["activity_id"],
|
||||
started_at=_dt(d["started_at"]),
|
||||
elapsed_s=int(d["elapsed_s"]),
|
||||
avg_speed_kmh=d.get("avg_speed_kmh"),
|
||||
avg_hr_bpm=d.get("avg_hr_bpm"),
|
||||
avg_power_w=d.get("avg_power_w"),
|
||||
np_power_w=d.get("np_power_w"),
|
||||
detected_at=_dt(d["detected_at"]),
|
||||
)
|
||||
|
||||
|
||||
# ── public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def make_segment_id(name: str) -> str:
|
||||
return _make_id(name)
|
||||
|
||||
|
||||
def save_segment(data_dir: Path, seg: Segment) -> None:
|
||||
path = _segments_dir(data_dir) / f"{seg.id}.json"
|
||||
path.write_text(json.dumps(_segment_to_dict(seg), ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def load_segment(data_dir: Path, segment_id: str) -> Optional[Segment]:
|
||||
path = _segments_dir(data_dir) / f"{segment_id}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
return _segment_from_dict(json.loads(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def delete_segment(data_dir: Path, segment_id: str) -> bool:
|
||||
path = _segments_dir(data_dir) / f"{segment_id}.json"
|
||||
if not path.exists():
|
||||
return False
|
||||
path.unlink()
|
||||
return True
|
||||
|
||||
|
||||
def list_segments(data_dir: Path, bbox: Optional[list[float]] = None) -> list[Segment]:
|
||||
"""Return all segments, optionally filtered to those overlapping bbox.
|
||||
|
||||
bbox = [lon_min, lat_min, lon_max, lat_max]
|
||||
"""
|
||||
segs = []
|
||||
for path in sorted(_segments_dir(data_dir).glob("*.json")):
|
||||
try:
|
||||
seg = _segment_from_dict(json.loads(path.read_text(encoding="utf-8")))
|
||||
except Exception:
|
||||
continue
|
||||
if bbox is not None and not _bboxes_overlap(seg.bbox, bbox):
|
||||
continue
|
||||
segs.append(seg)
|
||||
return segs
|
||||
|
||||
|
||||
def _bboxes_overlap(a: list[float], b: list[float]) -> bool:
|
||||
"""True if two [lon_min, lat_min, lon_max, lat_max] boxes overlap."""
|
||||
return not (a[2] < b[0] or b[2] < a[0] or a[3] < b[1] or b[3] < a[1])
|
||||
|
||||
|
||||
# ── efforts ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_efforts(data_dir: Path, handle: str, segment_id: str) -> list[SegmentEffort]:
|
||||
path = _efforts_dir(data_dir, handle) / f"{segment_id}.json"
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
return [_effort_from_dict(d) for d in json.loads(path.read_text(encoding="utf-8"))]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def save_efforts(data_dir: Path, handle: str, segment_id: str, efforts: list[SegmentEffort]) -> None:
|
||||
path = _efforts_dir(data_dir, handle) / f"{segment_id}.json"
|
||||
data = [_effort_to_dict(e) for e in efforts]
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def add_effort(data_dir: Path, handle: str, segment_id: str, effort: SegmentEffort) -> None:
|
||||
"""Append an effort, replacing any existing effort at the same start time.
|
||||
|
||||
Deduplicating by started_at (not activity_id) handles the case where the
|
||||
same ride is stored under two activity IDs (e.g. re-imported with a different
|
||||
source hash), which would otherwise produce two identical-time efforts.
|
||||
"""
|
||||
efforts = load_efforts(data_dir, handle, segment_id)
|
||||
key = _iso(effort.started_at)
|
||||
efforts = [e for e in efforts if _iso(e.started_at) != key]
|
||||
efforts.append(effort)
|
||||
efforts.sort(key=lambda e: e.started_at, reverse=True)
|
||||
save_efforts(data_dir, handle, segment_id, efforts)
|
||||
+35
-20
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
@@ -22,10 +21,14 @@ console = Console()
|
||||
@click.option("--public-url", default=None, envvar="PUBLIC_URL", help="Public base URL (e.g. https://yourdomain.com). Required for Strava OAuth to work behind a reverse proxy.")
|
||||
@click.option("--webroot", default=None, type=click.Path(), help="Nginx webroot (e.g. /var/www/bincio). When set, uploads trigger a full Astro build + rsync so new activity pages are immediately accessible without a git push.")
|
||||
@click.option("--dem-url", default=None, envvar="DEM_URL", help="Base URL of an Open-Elevation-compatible API (default: https://api.open-elevation.com).")
|
||||
def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
||||
strava_client_id: Optional[str], strava_client_secret: Optional[str],
|
||||
max_users: Optional[int], public_url: Optional[str],
|
||||
webroot: Optional[str], dem_url: Optional[str]) -> None:
|
||||
@click.option("--sync-secret", default=None, envvar="BINCIO_SYNC_SECRET", help="Shared secret for POST /api/internal/rebuild (used by the sync-strava systemd timer).")
|
||||
@click.option("--jwt-secret", default=None, envvar="BINCIO_AUTH_JWT_SECRET", help="Shared JWT secret from bincio-auth. When set, validates JWTs locally instead of DB session lookup.")
|
||||
@click.option("--auth-api", default=None, envvar="BINCIO_AUTH_API", help="Internal URL of the bincio-auth API (e.g. http://127.0.0.1:4040). When set, admin user-state operations are proxied to bincio-auth.")
|
||||
def serve(data_dir: str, site_dir: str | None, host: str, port: int,
|
||||
strava_client_id: str | None, strava_client_secret: str | None,
|
||||
max_users: int | None, public_url: str | None,
|
||||
webroot: str | None, dem_url: str | None,
|
||||
sync_secret: str | None, jwt_secret: str | None, auth_api: str | None) -> None:
|
||||
"""Start the bincio multi-user application server.
|
||||
|
||||
Handles auth, user management, and write operations.
|
||||
@@ -34,8 +37,10 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
||||
Requires a data directory initialised with `bincio init`.
|
||||
"""
|
||||
import uvicorn
|
||||
|
||||
import bincio.serve.server as srv
|
||||
from bincio.serve.db import open_db, set_setting, get_setting
|
||||
from bincio.serve import deps
|
||||
from bincio.serve.db import get_setting, open_db, set_setting
|
||||
|
||||
dd = Path(data_dir).expanduser().resolve()
|
||||
if not (dd / "instance.db").exists():
|
||||
@@ -48,36 +53,46 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
||||
set_setting(db, "max_users", str(max_users))
|
||||
db.close()
|
||||
|
||||
srv.data_dir = dd
|
||||
deps.data_dir = dd
|
||||
if site_dir:
|
||||
srv.site_dir = Path(site_dir).expanduser().resolve()
|
||||
deps.site_dir = Path(site_dir).expanduser().resolve()
|
||||
if strava_client_id:
|
||||
srv.strava_client_id = strava_client_id
|
||||
deps.strava_client_id = strava_client_id
|
||||
if strava_client_secret:
|
||||
srv.strava_client_secret = strava_client_secret
|
||||
deps.strava_client_secret = strava_client_secret
|
||||
if public_url:
|
||||
srv.public_url = public_url
|
||||
deps.public_url = public_url
|
||||
if webroot and site_dir:
|
||||
srv.webroot = Path(webroot).expanduser().resolve()
|
||||
deps.webroot = Path(webroot).expanduser().resolve()
|
||||
if dem_url:
|
||||
srv.dem_url = dem_url
|
||||
deps.dem_url = dem_url
|
||||
if sync_secret:
|
||||
deps.sync_secret = sync_secret
|
||||
if jwt_secret:
|
||||
deps.jwt_secret = jwt_secret
|
||||
if auth_api:
|
||||
deps.auth_api = auth_api.rstrip("/")
|
||||
|
||||
db = open_db(dd)
|
||||
current_limit = get_setting(db, "max_users")
|
||||
db.close()
|
||||
|
||||
console.print(f"[bold]bincio serve[/bold]")
|
||||
console.print("[bold]bincio serve[/bold]")
|
||||
console.print(f" Data: [cyan]{dd}[/cyan]")
|
||||
if srv.site_dir:
|
||||
console.print(f" Site: [cyan]{srv.site_dir}[/cyan]")
|
||||
if srv.webroot:
|
||||
console.print(f" Web: [cyan]{srv.webroot}[/cyan] (auto-rebuild on upload)")
|
||||
if deps.site_dir:
|
||||
console.print(f" Site: [cyan]{deps.site_dir}[/cyan]")
|
||||
if deps.webroot:
|
||||
console.print(f" Web: [cyan]{deps.webroot}[/cyan] (auto-rebuild on upload)")
|
||||
console.print(f" URL: [cyan]http://{host}:{port}[/cyan]")
|
||||
if current_limit and int(current_limit) > 0:
|
||||
console.print(f" Users: [yellow]max {current_limit}[/yellow]")
|
||||
else:
|
||||
console.print(f" Users: [dim]unlimited[/dim]")
|
||||
console.print(f" DEM: [cyan]{srv.dem_url}[/cyan]")
|
||||
console.print(" Users: [dim]unlimited[/dim]")
|
||||
console.print(f" DEM: [cyan]{deps.dem_url}[/cyan]")
|
||||
if deps.jwt_secret:
|
||||
console.print(" Auth: [green]JWT (bincio-auth)[/green]")
|
||||
else:
|
||||
console.print(" Auth: [dim]local DB sessions[/dim]")
|
||||
console.print()
|
||||
|
||||
log_config = uvicorn.config.LOGGING_CONFIG.copy()
|
||||
|
||||
+24
-4
@@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
wiki_access INTEGER NOT NULL DEFAULT 1,
|
||||
activity_access INTEGER NOT NULL DEFAULT 0,
|
||||
suspended INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -89,6 +90,7 @@ class User:
|
||||
is_admin: bool
|
||||
wiki_access: bool
|
||||
activity_access: bool
|
||||
suspended: bool
|
||||
created_at: int
|
||||
|
||||
|
||||
@@ -115,6 +117,10 @@ def open_db(data_dir: Path) -> sqlite3.Connection:
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
db.executescript(_SCHEMA)
|
||||
# Migration: add suspended column to pre-existing databases
|
||||
cols = {r[1] for r in db.execute("PRAGMA table_info(users)")}
|
||||
if "suspended" not in cols:
|
||||
db.execute("ALTER TABLE users ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0")
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
@@ -140,7 +146,8 @@ def create_user(
|
||||
)
|
||||
db.commit()
|
||||
return User(handle=handle, display_name=display_name, is_admin=is_admin,
|
||||
wiki_access=wiki_access, activity_access=activity_access, created_at=now)
|
||||
wiki_access=wiki_access, activity_access=activity_access,
|
||||
suspended=False, created_at=now)
|
||||
|
||||
|
||||
def get_user(db: sqlite3.Connection, handle: str) -> Optional[User]:
|
||||
@@ -153,12 +160,13 @@ def get_user(db: sqlite3.Connection, handle: str) -> Optional[User]:
|
||||
is_admin=bool(row["is_admin"]),
|
||||
wiki_access=bool(row["wiki_access"]),
|
||||
activity_access=bool(row["activity_access"]),
|
||||
suspended=bool(row["suspended"]),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional[User]:
|
||||
"""Return the User if credentials are valid, else None."""
|
||||
"""Return the User if credentials are valid and account is not suspended, else None."""
|
||||
row = db.execute(
|
||||
"SELECT * FROM users WHERE handle = ?", (handle,)
|
||||
).fetchone()
|
||||
@@ -166,12 +174,15 @@ def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional
|
||||
return None
|
||||
if not bcrypt.checkpw(password.encode(), row["password_hash"].encode()):
|
||||
return None
|
||||
if row["suspended"]:
|
||||
return None
|
||||
return User(
|
||||
handle=row["handle"],
|
||||
display_name=row["display_name"],
|
||||
is_admin=bool(row["is_admin"]),
|
||||
wiki_access=bool(row["wiki_access"]),
|
||||
activity_access=bool(row["activity_access"]),
|
||||
suspended=False,
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
@@ -188,6 +199,7 @@ def list_users(db: sqlite3.Connection) -> list[User]:
|
||||
return [User(handle=r["handle"], display_name=r["display_name"],
|
||||
is_admin=bool(r["is_admin"]), wiki_access=bool(r["wiki_access"]),
|
||||
activity_access=bool(r["activity_access"]),
|
||||
suspended=bool(r["suspended"]),
|
||||
created_at=r["created_at"]) for r in rows]
|
||||
|
||||
|
||||
@@ -196,6 +208,11 @@ def delete_user(db: sqlite3.Connection, handle: str) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def set_suspended(db: sqlite3.Connection, handle: str, suspended: bool) -> None:
|
||||
db.execute("UPDATE users SET suspended = ? WHERE handle = ?", (int(suspended), handle))
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_member_tree(db: sqlite3.Connection) -> list[dict]:
|
||||
"""Return users with their inviter handle and join timestamp.
|
||||
|
||||
@@ -271,10 +288,10 @@ def create_session(db: sqlite3.Connection, handle: str) -> str:
|
||||
|
||||
|
||||
def get_session(db: sqlite3.Connection, token: str) -> Optional[User]:
|
||||
"""Return the User owning this session, or None if expired/invalid."""
|
||||
"""Return the User owning this session, or None if expired/invalid/suspended."""
|
||||
row = db.execute(
|
||||
"SELECT s.handle, s.expires_at, u.display_name, u.is_admin, "
|
||||
"u.wiki_access, u.activity_access, u.created_at "
|
||||
"u.wiki_access, u.activity_access, u.suspended, u.created_at "
|
||||
"FROM sessions s JOIN users u ON s.handle = u.handle "
|
||||
"WHERE s.token = ?",
|
||||
(token,),
|
||||
@@ -284,12 +301,15 @@ def get_session(db: sqlite3.Connection, token: str) -> Optional[User]:
|
||||
if row["expires_at"] < int(time.time()):
|
||||
delete_session(db, token)
|
||||
return None
|
||||
if row["suspended"]:
|
||||
return None
|
||||
return User(
|
||||
handle=row["handle"],
|
||||
display_name=row["display_name"],
|
||||
is_admin=bool(row["is_admin"]),
|
||||
wiki_access=bool(row["wiki_access"]),
|
||||
activity_access=bool(row["activity_access"]),
|
||||
suspended=False,
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Shared state and FastAPI dependency functions for bincio.serve.
|
||||
|
||||
All module-level globals live here so routers can import them without
|
||||
creating circular dependencies through server.py.
|
||||
The CLI sets these before uvicorn starts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import jwt as _jwt
|
||||
from fastapi import Cookie, HTTPException, Request, Response
|
||||
|
||||
from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID
|
||||
from bincio.serve.db import (
|
||||
User,
|
||||
get_session,
|
||||
open_db,
|
||||
)
|
||||
from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES # noqa: F401
|
||||
from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES # noqa: F401
|
||||
from bincio.shared.images import unique_image_name as _unique_image_name # noqa: F401
|
||||
|
||||
# ── Module-level state (set by CLI before uvicorn starts) ─────────────────────
|
||||
|
||||
data_dir: Path | None = None
|
||||
site_dir: Path | None = None
|
||||
webroot: Path | None = None
|
||||
strava_client_id: str = ""
|
||||
strava_client_secret: str = ""
|
||||
public_url: str = ""
|
||||
dem_url: str = "https://api.open-elevation.com"
|
||||
sync_secret: str = ""
|
||||
jwt_secret: str = "" # when set, validates JWTs from bincio-auth instead of DB session lookup
|
||||
auth_api: str = "" # when set, proxies user-state admin ops to bincio-auth (e.g. http://127.0.0.1:4040)
|
||||
_db = None
|
||||
_strava_sync_running = False
|
||||
_strava_sync_lock = threading.Lock()
|
||||
_garmin_sync_running = False
|
||||
_garmin_sync_lock = threading.Lock()
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
|
||||
_SESSION_COOKIE = "bincio_session"
|
||||
_COOKIE_MAX_AGE = 30 * 86400 # 30 days
|
||||
_SESSION_DOMAIN = os.environ.get("SESSION_DOMAIN") or None
|
||||
|
||||
_STRAVA_CREDS_FILE = "strava_credentials.json"
|
||||
|
||||
_login_attempts: dict[str, list[float]] = {}
|
||||
_register_attempts: dict[str, list[float]] = {}
|
||||
_RATE_WINDOW = 900 # 15 minutes
|
||||
_LOGIN_RATE_LIMIT = 10
|
||||
_REGISTER_RATE_LIMIT = 5
|
||||
|
||||
# ── Core helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_data_dir() -> Path:
|
||||
if data_dir is None:
|
||||
raise HTTPException(500, "Server not configured")
|
||||
return data_dir
|
||||
|
||||
|
||||
def _get_db():
|
||||
global _db
|
||||
if _db is None:
|
||||
_db = open_db(_get_data_dir())
|
||||
return _db
|
||||
|
||||
|
||||
def _strava_creds(handle: str) -> tuple[str, str]:
|
||||
"""Return (client_id, client_secret) for a user.
|
||||
|
||||
Per-user credentials take precedence over the instance-level globals.
|
||||
Returns ("", "") when neither is configured.
|
||||
"""
|
||||
creds_path = _get_data_dir() / handle / _STRAVA_CREDS_FILE
|
||||
if creds_path.exists():
|
||||
try:
|
||||
d = json.loads(creds_path.read_text(encoding="utf-8"))
|
||||
cid = str(d.get("client_id", "")).strip()
|
||||
csec = str(d.get("client_secret", "")).strip()
|
||||
if cid and csec:
|
||||
return cid, csec
|
||||
except (OSError, json.JSONDecodeError, KeyError, ValueError):
|
||||
pass
|
||||
return strava_client_id, strava_client_secret
|
||||
|
||||
|
||||
def _check_id(activity_id: str) -> str:
|
||||
if not _VALID_ACTIVITY_ID.match(activity_id):
|
||||
raise HTTPException(400, "Invalid activity ID")
|
||||
return activity_id
|
||||
|
||||
# ── Rate limiting ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _check_rate_limit(
|
||||
ip: str,
|
||||
store: dict[str, list[float]],
|
||||
limit: int,
|
||||
msg: str = "Too many attempts. Try again later.",
|
||||
) -> None:
|
||||
now = time.time()
|
||||
attempts = [t for t in store.get(ip, []) if now - t < _RATE_WINDOW]
|
||||
store[ip] = attempts
|
||||
if len(attempts) >= limit:
|
||||
raise HTTPException(429, msg)
|
||||
attempts.append(now)
|
||||
store[ip] = attempts
|
||||
|
||||
# ── Auth dependency functions ─────────────────────────────────────────────────
|
||||
|
||||
def _decode_jwt(token: str) -> User | None:
|
||||
"""Decode a bincio-auth JWT and return a User. Returns None on any failure."""
|
||||
try:
|
||||
payload = _jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
||||
except _jwt.PyJWTError:
|
||||
return None
|
||||
handle = payload.get("sub")
|
||||
if not handle:
|
||||
return None
|
||||
return User(
|
||||
handle=handle,
|
||||
display_name=payload.get("display_name", ""),
|
||||
is_admin=bool(payload.get("is_admin", False)),
|
||||
wiki_access=bool(payload.get("wiki_access", True)),
|
||||
activity_access=bool(payload.get("activity_access", False)),
|
||||
suspended=False,
|
||||
created_at=0,
|
||||
)
|
||||
|
||||
|
||||
def _current_user(bincio_session: str | None = Cookie(default=None)) -> User | None:
|
||||
if not bincio_session:
|
||||
return None
|
||||
if jwt_secret:
|
||||
return _decode_jwt(bincio_session)
|
||||
return get_session(_get_db(), bincio_session)
|
||||
|
||||
|
||||
def _require_user(bincio_session: str | None = Cookie(default=None)) -> User:
|
||||
user = _current_user(bincio_session)
|
||||
if not user:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
def _require_admin(bincio_session: str | None = Cookie(default=None)) -> User:
|
||||
user = _require_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Admin required")
|
||||
return user
|
||||
|
||||
|
||||
def _require_auth(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> User:
|
||||
"""Accept session cookie (web) OR Authorization: Bearer token (mobile)."""
|
||||
token = bincio_session
|
||||
if not token:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
token = auth[7:]
|
||||
if not token:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
user = _decode_jwt(token) if jwt_secret else get_session(_get_db(), token)
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired session")
|
||||
return user
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
kwargs: dict = dict(
|
||||
key=_SESSION_COOKIE,
|
||||
value=token,
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False,
|
||||
)
|
||||
if _SESSION_DOMAIN:
|
||||
kwargs["domain"] = _SESSION_DOMAIN
|
||||
response.set_cookie(**kwargs)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Pydantic request/response models for bincio.serve."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
handle: str = Field(..., description="User handle (username)")
|
||||
password: str = Field(..., description="User password")
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
ok: bool = Field(True, description="Success flag")
|
||||
handle: str = Field(..., description="User handle")
|
||||
display_name: str = Field(..., description="User's display name")
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
handle: str = Field(..., description="User handle")
|
||||
code: str = Field(..., description="Reset code (24 hours valid)")
|
||||
password: str = Field(..., description="New password (min 8 chars)")
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
code: str = Field(..., description="Invite code")
|
||||
handle: str = Field(..., description="Desired username (lowercase, 1-30 chars)")
|
||||
password: str = Field(..., description="Password (min 8 characters)")
|
||||
display_name: str = Field(default="", description="Full name (optional, defaults to handle)")
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
ok: bool = Field(True, description="Success flag")
|
||||
handle: str = Field(..., description="New user's handle")
|
||||
|
||||
|
||||
class CurrentUserResponse(BaseModel):
|
||||
handle: str = Field(..., description="User handle")
|
||||
display_name: str = Field(..., description="User's display name")
|
||||
is_admin: bool = Field(..., description="Whether user is an admin")
|
||||
wiki_access: bool = Field(default=True, description="Whether user has wiki access")
|
||||
activity_access: bool = Field(default=False, description="Whether user has activity access")
|
||||
store_originals_default: bool = Field(
|
||||
default=True,
|
||||
description="Instance-wide default for storing original files"
|
||||
)
|
||||
dem_configured: bool = Field(default=False, description="Whether DEM elevation lookup is configured")
|
||||
|
||||
|
||||
class ActivityEditRequest(BaseModel):
|
||||
title: str | None = Field(default=None, description="Activity title")
|
||||
description: str | None = Field(default=None, description="Activity description (markdown)")
|
||||
sport: str | None = Field(default=None, description="Sport type")
|
||||
sub_sport: str | None = Field(default=None, description="Sport sub-category")
|
||||
private: bool | None = Field(default=None, description="Hide from public feed")
|
||||
highlight: bool | None = Field(default=None, description="Mark as favorite")
|
||||
gear: str | None = Field(default=None, description="Gear used")
|
||||
download_disabled: bool | None = Field(default=None, description="Prevent others from downloading files")
|
||||
|
||||
|
||||
class ActivityEditResponse(BaseModel):
|
||||
ok: bool = Field(True, description="Success flag")
|
||||
|
||||
|
||||
class ResetPasswordCodeResponse(BaseModel):
|
||||
ok: bool = Field(True, description="Success flag")
|
||||
code: str = Field(..., description="One-time reset code")
|
||||
expires_in_hours: int = Field(24, description="Code validity period in hours")
|
||||
|
||||
|
||||
class GenericResponse(BaseModel):
|
||||
ok: bool = Field(True, description="Success flag")
|
||||
|
||||
|
||||
class CreateSegmentRequest(BaseModel):
|
||||
name: str = Field(..., description="Segment name")
|
||||
sport: Optional[str] = Field(default=None, description="Sport filter")
|
||||
polyline: list[list[float]] = Field(..., description="[[lat, lon], ...] GPS points")
|
||||
distance_m: float = Field(..., description="Segment length in metres")
|
||||
|
||||
|
||||
class CreateInviteRequest(BaseModel):
|
||||
grants_activity: bool = Field(default=False)
|
||||
|
||||
|
||||
class IdeaBody(BaseModel):
|
||||
title: str
|
||||
body: str = ""
|
||||
|
||||
|
||||
class IdeaCommentBody(BaseModel):
|
||||
comment: str = ""
|
||||
@@ -0,0 +1,388 @@
|
||||
"""Activity CRUD and athlete endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, File, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
from bincio.serve.models import ActivityEditRequest, ActivityEditResponse, GenericResponse
|
||||
from bincio.serve.db import User
|
||||
from bincio.shared.images import (
|
||||
ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES,
|
||||
MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES,
|
||||
unique_image_name as _unique_image_name,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _user_data_dir(handle: str) -> Path:
|
||||
"""Return the merged data dir for a user, for reading activity files."""
|
||||
dd = deps._get_data_dir()
|
||||
merged = dd / handle / "_merged"
|
||||
return merged if merged.exists() else dd / handle
|
||||
|
||||
|
||||
def _require_owns(activity_id: str, user: User) -> Path:
|
||||
"""Verify the user owns this activity (it lives in their data dir)."""
|
||||
activity_path = _user_data_dir(user.handle) / "activities" / f"{activity_id}.json"
|
||||
if not activity_path.exists():
|
||||
raise HTTPException(404, "Activity not found")
|
||||
return activity_path
|
||||
|
||||
|
||||
@router.get("/api/activity/{activity_id}/geojson")
|
||||
async def get_activity_geojson(
|
||||
activity_id: str,
|
||||
user: User = Depends(deps._require_auth),
|
||||
) -> JSONResponse:
|
||||
"""Return GeoJSON track for an activity (mobile detail screen)."""
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir()
|
||||
user_dir = dd / user.handle
|
||||
for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
|
||||
p = base / f"{activity_id}.geojson"
|
||||
if p.exists():
|
||||
return JSONResponse(json.loads(p.read_text()))
|
||||
raise HTTPException(404, "GeoJSON not found")
|
||||
|
||||
|
||||
@router.get("/api/activity/{activity_id}/timeseries")
|
||||
async def get_activity_timeseries(
|
||||
activity_id: str,
|
||||
user: User = Depends(deps._require_auth),
|
||||
) -> JSONResponse:
|
||||
"""Return timeseries for an activity (mobile detail screen)."""
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir()
|
||||
user_dir = dd / user.handle
|
||||
for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
|
||||
p = base / f"{activity_id}.timeseries.json"
|
||||
if p.exists():
|
||||
return JSONResponse(json.loads(p.read_text()))
|
||||
raise HTTPException(404, "Timeseries not found")
|
||||
|
||||
|
||||
@router.get("/api/activity/{activity_id}")
|
||||
async def get_activity(
|
||||
activity_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
path = _require_owns(activity_id, user)
|
||||
detail = json.loads(path.read_text())
|
||||
# Normalise for EditDrawer: add `private` bool so the drawer works regardless
|
||||
# of whether the raw JSON uses the old "private" or the new "unlisted" value.
|
||||
detail["private"] = detail.get("privacy") in ("private", "unlisted")
|
||||
return JSONResponse(detail)
|
||||
|
||||
|
||||
@router.post("/api/activity/{activity_id}", response_model=ActivityEditResponse)
|
||||
async def post_activity(
|
||||
activity_id: str,
|
||||
edit_req: ActivityEditRequest,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
# Verify the activity belongs to this user before writing
|
||||
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||
raise HTTPException(404, "Activity not found")
|
||||
|
||||
from bincio.edit.ops import apply_sidecar_edit
|
||||
body = edit_req.model_dump(exclude_none=True)
|
||||
apply_sidecar_edit(activity_id, body, dd)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/api/activity/{activity_id}/recalculate-elevation/dem")
|
||||
async def recalculate_elevation_dem_endpoint(
|
||||
activity_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Replace GPS altitude with DEM terrain elevation and recompute gain/loss.
|
||||
|
||||
Requires --dem-url to be set when starting bincio serve.
|
||||
"""
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
if not deps.dem_url:
|
||||
raise HTTPException(503, "DEM URL not configured.")
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||
raise HTTPException(404, "Activity not found")
|
||||
try:
|
||||
from bincio.extract.dem import recalculate_elevation
|
||||
from bincio.render.merge import merge_one
|
||||
result = recalculate_elevation(dd, activity_id, deps.dem_url)
|
||||
merge_one(dd, activity_id)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse(result)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e))
|
||||
|
||||
|
||||
@router.post("/api/activity/{activity_id}/recalculate-elevation/hysteresis")
|
||||
async def recalculate_elevation_hysteresis_endpoint(
|
||||
activity_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Recompute gain/loss from original recorded elevation using source-aware hysteresis."""
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||
raise HTTPException(404, "Activity not found")
|
||||
try:
|
||||
from bincio.extract.dem import recalculate_elevation_hysteresis
|
||||
from bincio.render.merge import merge_one
|
||||
result = recalculate_elevation_hysteresis(dd, activity_id)
|
||||
merge_one(dd, activity_id)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse(result)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e))
|
||||
|
||||
|
||||
@router.delete("/api/activity/{activity_id}", response_model=GenericResponse)
|
||||
async def delete_activity(
|
||||
activity_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Delete a single activity and all associated files for the logged-in user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
acts_dir = dd / "activities"
|
||||
|
||||
json_path = acts_dir / f"{activity_id}.json"
|
||||
if not json_path.exists():
|
||||
raise HTTPException(404, "Activity not found")
|
||||
|
||||
import shutil
|
||||
|
||||
# Remove the source files (activities dir)
|
||||
for suffix in (".json", ".geojson", ".timeseries.json"):
|
||||
p = acts_dir / f"{activity_id}{suffix}"
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
# Remove sidecar edit and images
|
||||
sidecar = dd / "edits" / f"{activity_id}.md"
|
||||
sidecar.unlink(missing_ok=True)
|
||||
images_dir = dd / "edits" / "images" / activity_id
|
||||
if images_dir.exists():
|
||||
shutil.rmtree(images_dir)
|
||||
|
||||
# Remove from the extract-level flat index so merge_all doesn't re-add
|
||||
# the summary even though the detail file is gone.
|
||||
index_path = dd / "index.json"
|
||||
if index_path.exists():
|
||||
try:
|
||||
idx = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
idx["activities"] = [a for a in idx.get("activities", []) if a.get("id") != activity_id]
|
||||
index_path.write_text(json.dumps(idx, indent=2, ensure_ascii=False))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass # corrupt index — merge_all will clean up on next run
|
||||
|
||||
# Remove from dedup cache so the file can be re-uploaded if needed
|
||||
cache_path = dd / ".bincio_cache.json"
|
||||
if cache_path.exists():
|
||||
try:
|
||||
cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
if isinstance(cache, dict) and "activities" in cache:
|
||||
cache["activities"] = [
|
||||
a for a in cache["activities"] if a.get("id") != activity_id
|
||||
]
|
||||
cache_path.write_text(json.dumps(cache, indent=2, ensure_ascii=False))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass # corrupt cache — leave it; next extract will rebuild
|
||||
|
||||
# Full merge needed: activity removed from index
|
||||
from bincio.render.merge import merge_all
|
||||
merge_all(dd)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/activity/{activity_id}/images")
|
||||
async def list_images(
|
||||
activity_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
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({"images": images})
|
||||
|
||||
|
||||
@router.post("/api/activity/{activity_id}/images")
|
||||
async def upload_image(
|
||||
activity_id: str,
|
||||
file: UploadFile = File(...),
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||
raise HTTPException(404, "Activity not found")
|
||||
if not file.filename:
|
||||
raise HTTPException(400, "No filename")
|
||||
ct = file.content_type or ""
|
||||
if ct not in _ALLOWED_IMAGE_TYPES:
|
||||
raise HTTPException(400, "Only JPEG, PNG, WebP, or GIF images are accepted")
|
||||
contents = await file.read()
|
||||
if len(contents) > _MAX_IMAGE_BYTES:
|
||||
raise HTTPException(413, f"Image too large (max {_MAX_IMAGE_BYTES // (1024*1024)} MB)")
|
||||
images_dir = dd / "edits" / "images" / activity_id
|
||||
images_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = _unique_image_name(images_dir, Path(file.filename).name)
|
||||
(images_dir / safe_name).write_bytes(contents)
|
||||
from bincio.render.merge import merge_one
|
||||
merge_one(dd, activity_id)
|
||||
return JSONResponse({"ok": True, "filename": safe_name})
|
||||
|
||||
|
||||
@router.delete("/api/activity/{activity_id}/images/{filename}")
|
||||
async def delete_image(
|
||||
activity_id: str,
|
||||
filename: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
import shutil
|
||||
safe_name = Path(filename).name
|
||||
target = dd / "edits" / "images" / activity_id / safe_name
|
||||
if target.exists() and target.is_file():
|
||||
target.unlink()
|
||||
if target.parent.exists() and not any(target.parent.iterdir()):
|
||||
shutil.rmtree(target.parent)
|
||||
from bincio.render.merge import merge_one
|
||||
merge_one(dd, activity_id)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/athlete")
|
||||
async def get_athlete(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
athlete_path = dd / "athlete.json"
|
||||
data: dict = {}
|
||||
if athlete_path.exists():
|
||||
try:
|
||||
data = json.loads(athlete_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
# Layer edits/athlete.yaml on top
|
||||
edits_path = dd / "edits" / "athlete.yaml"
|
||||
if edits_path.exists():
|
||||
import yaml
|
||||
try:
|
||||
edits = yaml.safe_load(edits_path.read_text(encoding="utf-8")) or {}
|
||||
for k in ("max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"):
|
||||
if k in edits:
|
||||
data[k] = edits[k]
|
||||
except (OSError, yaml.YAMLError):
|
||||
pass
|
||||
return JSONResponse(data)
|
||||
|
||||
|
||||
@router.post("/api/athlete")
|
||||
async def save_athlete(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
athlete_path = dd / "athlete.json"
|
||||
if not athlete_path.exists():
|
||||
from datetime import datetime, timezone
|
||||
athlete_path.write_text(json.dumps({
|
||||
"bas_version": "1.0",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"power_curve": {},
|
||||
}), encoding="utf-8")
|
||||
payload = await request.json()
|
||||
edits_dir = dd / "edits"
|
||||
edits_dir.mkdir(exist_ok=True)
|
||||
overrides: dict[str, Any] = {}
|
||||
if payload.get("max_hr") is not None:
|
||||
overrides["max_hr"] = int(payload["max_hr"])
|
||||
if payload.get("ftp_w") is not None:
|
||||
overrides["ftp_w"] = int(payload["ftp_w"])
|
||||
if payload.get("hr_zones") is not None:
|
||||
overrides["hr_zones"] = [[int(lo), int(hi)] for lo, hi in payload["hr_zones"]]
|
||||
if payload.get("power_zones") is not None:
|
||||
overrides["power_zones"] = [[int(lo), int(hi)] for lo, hi in payload["power_zones"]]
|
||||
if payload.get("seasons") is not None:
|
||||
overrides["seasons"] = [
|
||||
{"name": str(s["name"]), "start": str(s["start"]), "end": str(s["end"])}
|
||||
for s in payload["seasons"]
|
||||
]
|
||||
if payload.get("gear") is not None:
|
||||
overrides["gear"] = payload["gear"]
|
||||
import yaml
|
||||
(edits_dir / "athlete.yaml").write_text(
|
||||
yaml.dump(overrides, allow_unicode=True, default_flow_style=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
from bincio.render.merge import merge_all
|
||||
merge_all(dd)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/activities/{activity_id}/segment_efforts")
|
||||
async def activity_segment_efforts(
|
||||
activity_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Return segment efforts that belong to a specific activity for the logged-in user."""
|
||||
import asyncio
|
||||
from bincio.segments import store as _seg_store
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
|
||||
def _collect() -> list[dict]:
|
||||
efforts_dir = dd / user.handle / "segment_efforts"
|
||||
result: list[dict] = []
|
||||
if not efforts_dir.exists():
|
||||
return result
|
||||
for ef_file in sorted(efforts_dir.glob("*.json")):
|
||||
seg_id = ef_file.stem
|
||||
all_efforts = _seg_store.load_efforts(dd, user.handle, seg_id)
|
||||
matching = [e for e in all_efforts if e.activity_id == activity_id]
|
||||
if not matching:
|
||||
continue
|
||||
seg = _seg_store.load_segment(dd, seg_id)
|
||||
if not seg:
|
||||
continue
|
||||
pr_elapsed = min(e.elapsed_s for e in all_efforts)
|
||||
for eff in matching:
|
||||
result.append({
|
||||
"segment_id": seg.id,
|
||||
"segment_name": seg.name,
|
||||
"segment_distance_m": seg.distance_m,
|
||||
"elapsed_s": eff.elapsed_s,
|
||||
"pr_elapsed_s": pr_elapsed,
|
||||
"started_at": _seg_store._iso(eff.started_at),
|
||||
})
|
||||
return result
|
||||
|
||||
return JSONResponse(await asyncio.to_thread(_collect))
|
||||
@@ -0,0 +1,710 @@
|
||||
"""Admin endpoints (/api/admin/*)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
|
||||
|
||||
async def _auth_proxy(method: str, path: str, cookie: str | None) -> JSONResponse:
|
||||
"""Forward a user-state admin request to bincio-auth and relay the response."""
|
||||
if not deps.auth_api:
|
||||
raise HTTPException(503, "User management is handled by bincio-auth but BINCIO_AUTH_API is not configured.")
|
||||
url = f"{deps.auth_api}{path}"
|
||||
cookies = {"bincio_session": cookie} if cookie else {}
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.request(method, url, cookies=cookies)
|
||||
return JSONResponse(r.json(), status_code=r.status_code)
|
||||
from bincio.serve.models import ResetPasswordCodeResponse
|
||||
from bincio.serve.db import (
|
||||
User,
|
||||
get_user,
|
||||
list_users,
|
||||
)
|
||||
|
||||
log = logging.getLogger("bincio.serve")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _wipe_user_activities(user_dir: Path) -> int:
|
||||
"""Delete all extracted activity files and caches for a user.
|
||||
|
||||
Removes activities/ (JSON + GeoJSON + timeseries), edits/, originals/,
|
||||
_merged/, index.json, athlete.json, and the dedup cache.
|
||||
Leaves the user directory itself intact (account remains in the DB).
|
||||
Returns the number of files deleted.
|
||||
"""
|
||||
import shutil
|
||||
deleted = 0
|
||||
|
||||
for subdir in ("activities", "edits", "originals"):
|
||||
d = user_dir / subdir
|
||||
if d.exists():
|
||||
for f in d.rglob("*"):
|
||||
if f.is_file():
|
||||
deleted += 1
|
||||
shutil.rmtree(d)
|
||||
|
||||
for name in ("_merged", ):
|
||||
d = user_dir / name
|
||||
if d.exists():
|
||||
shutil.rmtree(d)
|
||||
|
||||
for name in ("index.json", "athlete.json", ".bincio_cache.json"):
|
||||
f = user_dir / name
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
@router.get("/api/admin/stats")
|
||||
async def admin_stats(bincio_session: str | None = Cookie(default=None)) -> FileResponse:
|
||||
"""Serve the latest usage stats figure. Admin only."""
|
||||
deps._require_admin(bincio_session)
|
||||
path = deps._get_data_dir().parent / "stats" / "latest.png"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Stats not yet generated — run scripts/usage_stats.py first")
|
||||
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache, no-store"})
|
||||
|
||||
|
||||
@router.get("/api/admin/users")
|
||||
async def admin_users(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
deps._require_admin(bincio_session)
|
||||
users = list_users(deps._get_db())
|
||||
return JSONResponse([{
|
||||
"handle": u.handle,
|
||||
"display_name": u.display_name,
|
||||
"is_admin": u.is_admin,
|
||||
"suspended": u.suspended,
|
||||
"created_at": u.created_at,
|
||||
} for u in users])
|
||||
|
||||
|
||||
@router.get("/api/admin/jobs")
|
||||
async def admin_jobs(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Return currently active upload/processing jobs. Admin only."""
|
||||
deps._require_admin(bincio_session)
|
||||
with tasks._jobs_lock:
|
||||
jobs = list(tasks._active_jobs.values())
|
||||
return JSONResponse(jobs)
|
||||
|
||||
|
||||
@router.get("/api/admin/disk")
|
||||
async def admin_disk(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Per-user disk usage breakdown. Admin only."""
|
||||
deps._require_admin(bincio_session)
|
||||
import shutil
|
||||
|
||||
data_dir = deps._get_data_dir()
|
||||
|
||||
def _mb(path: Path) -> float:
|
||||
if not path.exists():
|
||||
return 0.0
|
||||
# Use lstat to count symlink entries (few bytes each) rather than following
|
||||
# the link to the target — prevents _merged/ from double-counting activities/.
|
||||
total = sum(f.lstat().st_size for f in path.rglob("*") if f.is_file() or f.is_symlink())
|
||||
return round(total / 1_048_576, 1)
|
||||
|
||||
def _count(path: Path, pattern: str = "*") -> int:
|
||||
if not path.exists():
|
||||
return 0
|
||||
return sum(1 for f in path.glob(pattern) if f.is_file())
|
||||
|
||||
db = deps._get_db()
|
||||
from bincio.serve.db import get_user as _get_user
|
||||
users = []
|
||||
for user_dir in sorted(data_dir.iterdir()):
|
||||
if not user_dir.is_dir() or user_dir.name.startswith("_"):
|
||||
continue
|
||||
# leaked tmp zips
|
||||
leaked = [f for f in user_dir.glob("tmp*.zip") if f.is_file()]
|
||||
db_user = _get_user(db, user_dir.name)
|
||||
users.append({
|
||||
"handle": user_dir.name,
|
||||
"in_db": db_user is not None,
|
||||
"suspended": db_user.suspended if db_user else False,
|
||||
"total_mb": _mb(user_dir),
|
||||
"activities_mb": _mb(user_dir / "activities"),
|
||||
"activities_count": _count(user_dir / "activities", "*.json"),
|
||||
"merged_mb": _mb(user_dir / "_merged"),
|
||||
"originals_mb": _mb(user_dir / "originals"),
|
||||
"originals_strava_mb": _mb(user_dir / "originals" / "strava"),
|
||||
"images_mb": _mb(user_dir / "edits" / "images"),
|
||||
"leaked_zips_mb": round(sum(f.stat().st_size for f in leaked) / 1_048_576, 1),
|
||||
"leaked_zips_count": len(leaked),
|
||||
})
|
||||
|
||||
disk = shutil.disk_usage("/")
|
||||
return JSONResponse({
|
||||
"disk": {
|
||||
"total_gb": round(disk.total / 1_073_741_824, 1),
|
||||
"used_gb": round(disk.used / 1_073_741_824, 1),
|
||||
"free_gb": round(disk.free / 1_073_741_824, 1),
|
||||
"percent": round(disk.used / disk.total * 100, 1),
|
||||
},
|
||||
"users": users,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{handle}/reset-password-code", response_model=ResetPasswordCodeResponse)
|
||||
async def admin_reset_password_code(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Generate a one-time password reset code for a user. Proxied to bincio-auth."""
|
||||
return await _auth_proxy("POST", f"/api/admin/users/{handle}/reset-password-code", bincio_session)
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{handle}/suspend")
|
||||
async def admin_suspend(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Suspend a user account. Proxied to bincio-auth."""
|
||||
return await _auth_proxy("POST", f"/api/admin/users/{handle}/suspend", bincio_session)
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{handle}/unsuspend")
|
||||
async def admin_unsuspend(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Re-enable a suspended user account. Proxied to bincio-auth."""
|
||||
return await _auth_proxy("POST", f"/api/admin/users/{handle}/unsuspend", bincio_session)
|
||||
|
||||
|
||||
@router.delete("/api/admin/users/{handle}/account")
|
||||
async def admin_delete_account(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Delete a user account. Proxied to bincio-auth."""
|
||||
return await _auth_proxy("DELETE", f"/api/admin/users/{handle}/account", bincio_session)
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{handle}/rebuild")
|
||||
async def admin_rebuild(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Trigger a merge_all + site rebuild for a user. Admin only."""
|
||||
deps._require_admin(bincio_session)
|
||||
user_dir = deps._get_data_dir() / handle
|
||||
if not user_dir.is_dir():
|
||||
raise HTTPException(404, f"No data directory for user '{handle}'")
|
||||
tasks._trigger_rebuild(handle)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{handle}/rebuild-sync")
|
||||
async def admin_rebuild_sync(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Run merge+rebuild synchronously and return full output. Admin only.
|
||||
|
||||
Unlike /rebuild (fire-and-forget), this blocks until done and returns stdout/stderr.
|
||||
Use for debugging when you need to see what went wrong.
|
||||
"""
|
||||
deps._require_admin(bincio_session)
|
||||
user_dir = deps._get_data_dir() / handle
|
||||
if not user_dir.is_dir():
|
||||
raise HTTPException(404, f"No data directory for user '{handle}'")
|
||||
if deps.site_dir is None:
|
||||
raise HTTPException(503, "Server has no --site-dir configured; rebuild not available")
|
||||
|
||||
uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
|
||||
cmd = [uv, "run", "bincio", "render",
|
||||
"--data-dir", str(deps.data_dir),
|
||||
"--site-dir", str(deps.site_dir),
|
||||
"--handle", handle,
|
||||
"--no-build"]
|
||||
if deps.webroot:
|
||||
cmd = [uv, "run", "bincio", "render",
|
||||
"--data-dir", str(deps.data_dir),
|
||||
"--site-dir", str(deps.site_dir),
|
||||
"--handle", handle]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
resp: dict[str, Any] = {
|
||||
"ok": result.returncode == 0,
|
||||
"returncode": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
}
|
||||
|
||||
if result.returncode == 0 and deps.webroot:
|
||||
dist_data = deps.site_dir / "dist" / "data"
|
||||
if dist_data.exists():
|
||||
shutil.rmtree(dist_data)
|
||||
rsync = subprocess.run(
|
||||
["rsync", "-a", "--delete", "--exclude=data/",
|
||||
f"{deps.site_dir}/dist/", str(deps.webroot) + "/"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
resp["rsync_returncode"] = rsync.returncode
|
||||
resp["rsync_stdout"] = rsync.stdout
|
||||
resp["rsync_stderr"] = rsync.stderr
|
||||
resp["ok"] = rsync.returncode == 0
|
||||
|
||||
return JSONResponse(resp)
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{handle}/reextract-originals")
|
||||
async def admin_reextract_originals(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> StreamingResponse:
|
||||
"""Re-extract activities from stored Strava originals without hitting the API.
|
||||
|
||||
Spawns `bincio reextract-originals` as a subprocess so heavy memory use
|
||||
is isolated from the server process. Streams its JSON-lines output as SSE.
|
||||
Triggers a full rebuild on completion.
|
||||
"""
|
||||
import asyncio
|
||||
deps._require_admin(bincio_session)
|
||||
user_dir = deps._get_data_dir() / handle
|
||||
originals_dir = user_dir / "originals" / "strava"
|
||||
if not originals_dir.exists():
|
||||
raise HTTPException(404, f"No Strava originals directory for '{handle}'")
|
||||
|
||||
# Use the bincio script from the same venv bin dir as the running Python.
|
||||
# This is reliable in systemd environments where PATH may not include uv.
|
||||
import sys as _sys
|
||||
bincio_exe = str(Path(_sys.executable).parent / "bincio")
|
||||
data_dir = str(deps._get_data_dir())
|
||||
|
||||
# Count originals so we can split into memory-safe batches.
|
||||
total_originals = len(list(originals_dir.glob("*.json")))
|
||||
# Each activity can briefly peak at ~10–30 MB; 100 per batch keeps RSS
|
||||
# well under 3 GB even on a cheap VPS.
|
||||
_BATCH = 100
|
||||
log.info("reextract[%s]: %d originals, batch size %d, via %s",
|
||||
handle, total_originals, _BATCH, bincio_exe)
|
||||
|
||||
async def event_stream():
|
||||
total_imported = total_skipped = total_errors = 0
|
||||
offset = 0
|
||||
|
||||
while offset < total_originals:
|
||||
limit = min(_BATCH, total_originals - offset)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
bincio_exe, "reextract-originals",
|
||||
"--data-dir", data_dir,
|
||||
"--handle", handle,
|
||||
"--offset", str(offset),
|
||||
"--limit", str(limit),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
|
||||
async for raw_line in proc.stdout:
|
||||
line = raw_line.decode(errors="replace").strip()
|
||||
if not line:
|
||||
continue
|
||||
yield f"data: {line}\n\n"
|
||||
try:
|
||||
evt = json.loads(line)
|
||||
if evt.get("type") == "done":
|
||||
total_imported += evt.get("imported", 0)
|
||||
total_skipped += evt.get("skipped", 0)
|
||||
total_errors += evt.get("errors", 0)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
await proc.wait()
|
||||
if proc.returncode != 0:
|
||||
stderr_out = await proc.stderr.read() if proc.stderr else b""
|
||||
log.error("reextract[%s]: batch offset=%d exited %d — stderr: %s",
|
||||
handle, offset, proc.returncode,
|
||||
stderr_out.decode(errors="replace")[:500])
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': f'Batch {offset}–{offset+limit} exited with code {proc.returncode}'})}\n\n"
|
||||
return # stop on batch failure
|
||||
|
||||
offset += limit
|
||||
|
||||
# All batches complete
|
||||
log.info("reextract[%s]: all batches done — imported=%d skipped=%d errors=%d; triggering rebuild",
|
||||
handle, total_imported, total_skipped, total_errors)
|
||||
tasks._trigger_rebuild(handle)
|
||||
yield f"data: {json.dumps({'type': 'done', 'imported': total_imported, 'skipped': total_skipped, 'errors': total_errors})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/admin/users/{handle}/diag")
|
||||
async def admin_diag(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Return a diagnostic snapshot of a user's data directory. Admin only."""
|
||||
deps._require_admin(bincio_session)
|
||||
user_dir = deps._get_data_dir() / handle
|
||||
if not user_dir.is_dir():
|
||||
raise HTTPException(404, f"No data directory for user '{handle}'")
|
||||
|
||||
def _count(path: Path, glob: str = "*") -> int:
|
||||
return sum(1 for f in path.glob(glob) if f.is_file()) if path.exists() else 0
|
||||
|
||||
def _size_mb(path: Path) -> float:
|
||||
if not path.exists():
|
||||
return 0.0
|
||||
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) / 1_048_576
|
||||
|
||||
activities_dir = user_dir / "activities"
|
||||
merged_dir = user_dir / "_merged"
|
||||
originals_dir = user_dir / "originals"
|
||||
uploads_dir = user_dir / "_uploads"
|
||||
|
||||
merged_index = merged_dir / "index.json"
|
||||
root_index = user_dir / "index.json"
|
||||
|
||||
merged_activity_count: int | None = None
|
||||
if merged_index.exists():
|
||||
try:
|
||||
idx = json.loads(merged_index.read_text())
|
||||
merged_activity_count = len(idx.get("activities", []))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
merged_activity_count = -1
|
||||
|
||||
root_activity_count: int | None = None
|
||||
if root_index.exists():
|
||||
try:
|
||||
idx = json.loads(root_index.read_text())
|
||||
root_activity_count = len(idx.get("activities", []))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
root_activity_count = -1
|
||||
|
||||
# Peek at a few filenames in activities/ to understand the actual state
|
||||
acts_sample: list[str] = []
|
||||
acts_symlinks = 0
|
||||
if activities_dir.exists():
|
||||
for f in sorted(activities_dir.iterdir())[:10]:
|
||||
acts_sample.append(f.name + (" → symlink" if f.is_symlink() else ""))
|
||||
if f.is_symlink():
|
||||
acts_symlinks += 1
|
||||
|
||||
# Check _merged/activities/ separately
|
||||
merged_acts_dir = merged_dir / "activities"
|
||||
merged_acts_json = _count(merged_acts_dir, "*.json")
|
||||
merged_acts_geojson = _count(merged_acts_dir, "*.geojson")
|
||||
|
||||
# List pending files
|
||||
pending_files: list[str] = []
|
||||
if uploads_dir.exists():
|
||||
pending_files = [f.name for f in uploads_dir.iterdir() if f.is_file()]
|
||||
|
||||
return JSONResponse({
|
||||
"handle": handle,
|
||||
"user_dir": str(user_dir),
|
||||
"activities": {
|
||||
"json_files": _count(activities_dir, "*.json"),
|
||||
"geojson_files": _count(activities_dir, "*.geojson"),
|
||||
"size_mb": round(_size_mb(activities_dir), 2),
|
||||
"sample": acts_sample,
|
||||
"symlink_count": acts_symlinks,
|
||||
},
|
||||
"originals": {
|
||||
"exists": originals_dir.exists(),
|
||||
"size_mb": round(_size_mb(originals_dir), 2),
|
||||
"strava_originals": _count(originals_dir / "strava", "*.json") if (originals_dir / "strava").exists() else 0,
|
||||
},
|
||||
"merged": {
|
||||
"exists": merged_dir.exists(),
|
||||
"activity_count_in_index": merged_activity_count,
|
||||
"size_mb": round(_size_mb(merged_dir), 2),
|
||||
"activities_json": merged_acts_json,
|
||||
"activities_geojson": merged_acts_geojson,
|
||||
},
|
||||
"root_index": {
|
||||
"exists": root_index.exists(),
|
||||
"activity_count": root_activity_count,
|
||||
},
|
||||
"pending_uploads": len(pending_files),
|
||||
"pending_files": pending_files,
|
||||
"dedup_cache_exists": (user_dir / ".bincio_cache.json").exists(),
|
||||
"athlete_json_exists": (user_dir / "athlete.json").exists(),
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/api/admin/users/{handle}/activities")
|
||||
async def admin_delete_activities(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Delete all activity data for a user and wipe the merged cache."""
|
||||
deps._require_admin(bincio_session)
|
||||
user_dir = deps._get_data_dir() / handle
|
||||
if not user_dir.is_dir():
|
||||
raise HTTPException(404, f"No data directory for user '{handle}'")
|
||||
|
||||
deleted = _wipe_user_activities(user_dir)
|
||||
tasks._trigger_rebuild(handle)
|
||||
return JSONResponse({"ok": True, "deleted": deleted})
|
||||
|
||||
|
||||
@router.delete("/api/admin/users/{handle}/directory")
|
||||
async def admin_delete_user_directory(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Delete the entire user directory from disk (for ghost users not in the DB).
|
||||
|
||||
Refuses if the handle exists as an account in the database — use
|
||||
DELETE /api/admin/users/{handle}/activities for registered users.
|
||||
"""
|
||||
import shutil
|
||||
deps._require_admin(bincio_session)
|
||||
db = deps._get_db()
|
||||
from bincio.serve.db import get_user as _get_user
|
||||
if _get_user(db, handle) is not None:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"User '{handle}' is still in the database. Remove the account first, "
|
||||
"or use 'Reset data' to wipe only activity files.",
|
||||
)
|
||||
user_dir = deps._get_data_dir() / handle
|
||||
if not user_dir.is_dir():
|
||||
raise HTTPException(404, f"No directory for '{handle}'")
|
||||
shutil.rmtree(user_dir)
|
||||
# Rebuild root manifest so the ghost shard disappears from the site
|
||||
from bincio.render.cli import _write_root_manifest
|
||||
try:
|
||||
_write_root_manifest(deps._get_data_dir())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/admin/strava-sync")
|
||||
async def admin_strava_sync_status(
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Return per-user Strava sync status for the admin panel."""
|
||||
deps._require_admin(bincio_session)
|
||||
root = deps._get_data_dir()
|
||||
users = []
|
||||
for tf in sorted(root.glob("*/strava_token.json")):
|
||||
user_dir = tf.parent
|
||||
handle = user_dir.name
|
||||
has_creds = (user_dir / "strava_credentials.json").exists()
|
||||
|
||||
last_sync: str | None = None
|
||||
total_imported = 0
|
||||
sync_path = user_dir / "_strava_sync.json"
|
||||
if sync_path.exists():
|
||||
try:
|
||||
sc = json.loads(sync_path.read_text(encoding="utf-8"))
|
||||
last_sync = sc.get("last_sync")
|
||||
total_imported = len(sc.get("imported_ids", []))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
run_status: str | None = None
|
||||
run_imported = 0
|
||||
run_errors = 0
|
||||
run_error_message: str | None = None
|
||||
last_run: str | None = None
|
||||
status_path = user_dir / "_strava_sync_status.json"
|
||||
if status_path.exists():
|
||||
try:
|
||||
ss = json.loads(status_path.read_text(encoding="utf-8"))
|
||||
run_status = ss.get("status")
|
||||
run_imported = ss.get("imported", 0)
|
||||
run_errors = ss.get("errors", 0)
|
||||
run_error_message = ss.get("error_message")
|
||||
last_run = ss.get("last_run")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
users.append({
|
||||
"handle": handle,
|
||||
"has_credentials": has_creds,
|
||||
"last_sync": last_sync,
|
||||
"total_imported": total_imported,
|
||||
"run_status": run_status,
|
||||
"run_imported": run_imported,
|
||||
"run_errors": run_errors,
|
||||
"run_error_message": run_error_message,
|
||||
"last_run": last_run,
|
||||
})
|
||||
|
||||
return JSONResponse({"running": deps._strava_sync_running, "users": users})
|
||||
|
||||
|
||||
@router.post("/api/admin/strava-sync/run")
|
||||
async def admin_strava_sync_run(
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Trigger an immediate Strava sync for all users (admin only)."""
|
||||
deps._require_admin(bincio_session)
|
||||
with deps._strava_sync_lock:
|
||||
if deps._strava_sync_running:
|
||||
raise HTTPException(409, "Sync already running")
|
||||
deps._strava_sync_running = True
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
from bincio.sync_strava import sync_all
|
||||
results = sync_all(deps._get_data_dir())
|
||||
total_new = sum(n for n, _ in results.values())
|
||||
if total_new > 0:
|
||||
tasks._site_rebuild_event.set()
|
||||
except Exception:
|
||||
log.exception("admin_strava_sync_run: unexpected error")
|
||||
finally:
|
||||
deps._strava_sync_running = False
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="admin-strava-sync").start()
|
||||
return JSONResponse({"ok": True}, status_code=202)
|
||||
|
||||
|
||||
@router.get("/api/admin/garmin-sync")
|
||||
async def admin_garmin_sync_status(
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Return per-user Garmin sync status for the admin panel."""
|
||||
deps._require_admin(bincio_session)
|
||||
root = deps._get_data_dir()
|
||||
users = []
|
||||
for cf in sorted(root.glob("*/garmin_creds.json")):
|
||||
user_dir = cf.parent
|
||||
handle = user_dir.name
|
||||
|
||||
last_sync: str | None = None
|
||||
total_imported = 0
|
||||
sync_path = user_dir / "garmin_sync.json"
|
||||
if sync_path.exists():
|
||||
try:
|
||||
sc = json.loads(sync_path.read_text(encoding="utf-8"))
|
||||
last_sync = sc.get("last_sync_at")
|
||||
total_imported = sc.get("total_imported", 0)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
run_status: str | None = None
|
||||
run_imported = 0
|
||||
run_errors = 0
|
||||
run_error_message: str | None = None
|
||||
last_run: str | None = None
|
||||
status_path = user_dir / "_garmin_sync_status.json"
|
||||
if status_path.exists():
|
||||
try:
|
||||
ss = json.loads(status_path.read_text(encoding="utf-8"))
|
||||
run_status = ss.get("status")
|
||||
run_imported = ss.get("imported", 0)
|
||||
run_errors = ss.get("errors", 0)
|
||||
run_error_message = ss.get("error_message")
|
||||
last_run = ss.get("last_run")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
users.append({
|
||||
"handle": handle,
|
||||
"last_sync": last_sync,
|
||||
"total_imported": total_imported,
|
||||
"run_status": run_status,
|
||||
"run_imported": run_imported,
|
||||
"run_errors": run_errors,
|
||||
"run_error_message": run_error_message,
|
||||
"last_run": last_run,
|
||||
})
|
||||
|
||||
return JSONResponse({"running": deps._garmin_sync_running, "users": users})
|
||||
|
||||
|
||||
@router.post("/api/admin/garmin-sync/run")
|
||||
async def admin_garmin_sync_run(
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Trigger an immediate Garmin sync for all users (admin only)."""
|
||||
deps._require_admin(bincio_session)
|
||||
with deps._garmin_sync_lock:
|
||||
if deps._garmin_sync_running:
|
||||
raise HTTPException(409, "Sync already running")
|
||||
deps._garmin_sync_running = True
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
from bincio.sync_garmin import sync_all
|
||||
results = sync_all(deps._get_data_dir())
|
||||
total_new = sum(n for n, _ in results.values())
|
||||
if total_new > 0:
|
||||
tasks._site_rebuild_event.set()
|
||||
except Exception:
|
||||
log.exception("admin_garmin_sync_run: unexpected error")
|
||||
finally:
|
||||
deps._garmin_sync_running = False
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="admin-garmin-sync").start()
|
||||
return JSONResponse({"ok": True}, status_code=202)
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{handle}/recompute-elevation")
|
||||
async def admin_recompute_elevation(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Recompute elevation gain/loss for all activities of a user from stored timeseries.
|
||||
|
||||
Skips activities with altitude_source == 'dem' (already DEM-corrected).
|
||||
Applies the leading-zero no-fix fix and source-aware hysteresis.
|
||||
Returns patched/skipped/error counts.
|
||||
"""
|
||||
deps._require_admin(bincio_session)
|
||||
user_dir = deps._get_data_dir() / handle
|
||||
if not user_dir.is_dir():
|
||||
raise HTTPException(404, f"No data directory for '{handle}'")
|
||||
|
||||
from bincio.extract.dem import recalculate_elevation_hysteresis
|
||||
from bincio.render.merge import merge_one
|
||||
|
||||
patched = skipped = errors = 0
|
||||
acts_dir = user_dir / "activities"
|
||||
for json_path in sorted(acts_dir.glob("*.json")):
|
||||
if json_path.name.endswith(".timeseries.json"):
|
||||
continue
|
||||
activity_id = json_path.stem
|
||||
try:
|
||||
detail = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
if detail.get("altitude_source") == "dem":
|
||||
skipped += 1
|
||||
continue
|
||||
ts_path = acts_dir / f"{activity_id}.timeseries.json"
|
||||
if not ts_path.exists():
|
||||
skipped += 1
|
||||
continue
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
ele_arr = ts.get("elevation_m") or []
|
||||
if not any(e for e in ele_arr if e is not None):
|
||||
skipped += 1
|
||||
continue
|
||||
recalculate_elevation_hysteresis(user_dir, activity_id)
|
||||
merge_one(user_dir, activity_id)
|
||||
patched += 1
|
||||
except Exception as exc:
|
||||
log.warning("recompute-elevation[%s/%s]: %s", handle, activity_id, exc)
|
||||
errors += 1
|
||||
|
||||
if patched > 0:
|
||||
tasks._trigger_rebuild(handle)
|
||||
|
||||
return JSONResponse({"ok": True, "patched": patched, "skipped": skipped, "errors": errors})
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Authentication and registration endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
from bincio.serve.models import (
|
||||
CreateInviteRequest,
|
||||
GenericResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
ResetPasswordRequest,
|
||||
)
|
||||
from bincio.serve.db import (
|
||||
authenticate,
|
||||
count_activity_users,
|
||||
count_wiki_users,
|
||||
create_invite,
|
||||
create_session,
|
||||
create_user,
|
||||
delete_session,
|
||||
get_invite,
|
||||
get_setting,
|
||||
get_user,
|
||||
list_invites,
|
||||
use_invite,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/api/auth/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
login_req: LoginRequest,
|
||||
request: Request,
|
||||
) -> JSONResponse:
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
deps._check_rate_limit(ip, deps._login_attempts, deps._LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.")
|
||||
|
||||
handle = login_req.handle.strip().lower()
|
||||
password = login_req.password
|
||||
|
||||
user = authenticate(deps._get_db(), handle, password)
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
|
||||
token = create_session(deps._get_db(), handle)
|
||||
resp = JSONResponse({
|
||||
"ok": True,
|
||||
"handle": user.handle,
|
||||
"display_name": user.display_name,
|
||||
"wiki_access": user.wiki_access,
|
||||
"activity_access": user.activity_access,
|
||||
})
|
||||
deps._set_session_cookie(resp, token)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/api/auth/logout", response_model=GenericResponse)
|
||||
async def logout(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
if bincio_session:
|
||||
delete_session(deps._get_db(), bincio_session)
|
||||
resp = JSONResponse({"ok": True})
|
||||
kwargs: dict = dict(key=deps._SESSION_COOKIE)
|
||||
if deps._SESSION_DOMAIN:
|
||||
kwargs["domain"] = deps._SESSION_DOMAIN
|
||||
resp.delete_cookie(**kwargs)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/api/auth/token")
|
||||
async def get_token(login_req: LoginRequest, request: Request) -> JSONResponse:
|
||||
"""Mobile auth: same as /api/auth/login but returns the token in the body instead of a cookie."""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
deps._check_rate_limit(ip, deps._login_attempts, deps._LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.")
|
||||
handle = login_req.handle.strip().lower()
|
||||
user = authenticate(deps._get_db(), handle, login_req.password)
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
token = create_session(deps._get_db(), handle)
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"token": token,
|
||||
"handle": user.handle,
|
||||
"display_name": user.display_name,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/auth/reset-password", response_model=GenericResponse)
|
||||
async def reset_password(reset_req: ResetPasswordRequest) -> JSONResponse:
|
||||
"""Validate a reset code and set a new password. Public endpoint."""
|
||||
from bincio.serve.db import use_reset_code, change_password
|
||||
handle = reset_req.handle.strip().lower()
|
||||
code = reset_req.code.strip().upper()
|
||||
new_pw = reset_req.password
|
||||
if len(new_pw) < 8:
|
||||
raise HTTPException(400, "Password must be at least 8 characters")
|
||||
db = deps._get_db()
|
||||
if not use_reset_code(db, code, handle):
|
||||
raise HTTPException(400, "Invalid or expired reset code")
|
||||
change_password(db, handle, new_pw)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ── Registration ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/register", response_model=RegisterResponse)
|
||||
async def register(
|
||||
register_req: RegisterRequest,
|
||||
request: Request,
|
||||
) -> JSONResponse:
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
deps._check_rate_limit(ip, deps._register_attempts, deps._REGISTER_RATE_LIMIT, "Too many registration attempts. Try again later.")
|
||||
|
||||
code = register_req.code.strip().upper()
|
||||
handle = register_req.handle.strip().lower()
|
||||
password = register_req.password
|
||||
display = register_req.display_name.strip() or handle
|
||||
|
||||
if not deps._VALID_HANDLE.match(handle):
|
||||
raise HTTPException(400, "Invalid handle (lowercase letters, numbers, _ - only; max 30 chars)")
|
||||
if len(password) < 8:
|
||||
raise HTTPException(400, "Password must be at least 8 characters")
|
||||
|
||||
invite = get_invite(deps._get_db(), code)
|
||||
if not invite or invite.used:
|
||||
raise HTTPException(400, "Invalid or already-used invite code")
|
||||
if get_user(deps._get_db(), handle):
|
||||
raise HTTPException(409, "Handle already taken")
|
||||
|
||||
db = deps._get_db()
|
||||
max_wiki_val = get_setting(db, "max_wiki_users") or get_setting(db, "max_users")
|
||||
if max_wiki_val is not None:
|
||||
limit = int(max_wiki_val)
|
||||
if limit > 0 and count_wiki_users(db) >= limit:
|
||||
raise HTTPException(403, f"This instance has reached its wiki user limit ({limit})")
|
||||
|
||||
if invite.grants_activity:
|
||||
max_act_val = get_setting(db, "max_activity_users")
|
||||
if max_act_val is not None:
|
||||
limit = int(max_act_val)
|
||||
if limit > 0 and count_activity_users(db) >= limit:
|
||||
raise HTTPException(403, f"This instance has reached its activity user limit ({limit})")
|
||||
|
||||
create_user(deps._get_db(), handle, display, password, is_admin=False,
|
||||
wiki_access=True, activity_access=invite.grants_activity)
|
||||
use_invite(deps._get_db(), code, handle)
|
||||
|
||||
# Create per-user directories
|
||||
dd = deps._get_data_dir()
|
||||
user_dir = dd / handle
|
||||
(user_dir / "activities").mkdir(parents=True, exist_ok=True)
|
||||
(user_dir / "edits").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write an empty index.json so the shard URL resolves immediately,
|
||||
# even before the user uploads any activities.
|
||||
from bincio.extract.writer import write_index
|
||||
index_path = user_dir / "index.json"
|
||||
if not index_path.exists():
|
||||
write_index([], user_dir, {"handle": handle, "display_name": display or handle})
|
||||
|
||||
# Update root manifest so the new user's shard is discoverable immediately
|
||||
from bincio.render.cli import _write_root_manifest
|
||||
_write_root_manifest(dd)
|
||||
|
||||
# Rebuild site so the new user's profile pages exist immediately
|
||||
tasks._trigger_rebuild(handle)
|
||||
|
||||
token = create_session(deps._get_db(), handle)
|
||||
resp = JSONResponse({"ok": True, "handle": handle})
|
||||
deps._set_session_cookie(resp, token)
|
||||
return resp
|
||||
|
||||
|
||||
# ── Invites ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/invites")
|
||||
async def get_invites(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
invites = list_invites(deps._get_db(), user.handle)
|
||||
return JSONResponse([{
|
||||
"code": i.code,
|
||||
"used": i.used,
|
||||
"used_by": i.used_by,
|
||||
"created_at": i.created_at,
|
||||
"used_at": i.used_at,
|
||||
"grants_activity": i.grants_activity,
|
||||
} for i in invites])
|
||||
|
||||
|
||||
@router.post("/api/invites")
|
||||
async def post_invite(
|
||||
body: CreateInviteRequest = CreateInviteRequest(),
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
try:
|
||||
code = create_invite(deps._get_db(), user.handle, grants_activity=body.grants_activity)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return JSONResponse({"ok": True, "code": code, "grants_activity": body.grants_activity})
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Activity file download endpoint.
|
||||
|
||||
GET /api/activity/{activity_id}/download/{fmt}
|
||||
fmt: bas | original | gpx
|
||||
|
||||
Permission:
|
||||
- If activity.download_disabled is true: only the owner (authenticated) may download.
|
||||
- Otherwise: no auth required — anyone who can see the activity can download.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
|
||||
from bincio.serve import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _find_activity(activity_id: str) -> tuple[str, Path] | None:
|
||||
"""Return (handle, detail_path) for whichever user owns this activity."""
|
||||
data_dir = deps._get_data_dir()
|
||||
for user_dir in sorted(data_dir.iterdir()):
|
||||
if not user_dir.is_dir() or user_dir.name.startswith(("_", ".")):
|
||||
continue
|
||||
for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
|
||||
p = base / f"{activity_id}.json"
|
||||
if p.exists():
|
||||
return user_dir.name, p
|
||||
return None
|
||||
|
||||
|
||||
def _check_download_permission(
|
||||
detail: dict, handle: str, bincio_session: Optional[str]
|
||||
) -> None:
|
||||
if not detail.get("download_disabled"):
|
||||
return
|
||||
try:
|
||||
user = deps._require_user(bincio_session)
|
||||
except HTTPException:
|
||||
raise HTTPException(403, "Downloads are disabled for this activity")
|
||||
if user.handle != handle:
|
||||
raise HTTPException(403, "Downloads are disabled for this activity")
|
||||
|
||||
|
||||
def _generate_gpx(detail: dict, ts: dict) -> str:
|
||||
t_vals = ts.get("t") or []
|
||||
lat_vals = ts.get("lat") or []
|
||||
lon_vals = ts.get("lon") or []
|
||||
ele_vals = ts.get("elevation_m") or []
|
||||
hr_vals = ts.get("hr_bpm") or []
|
||||
|
||||
title = (detail.get("title") or "Activity").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
started = detail.get("started_at") or "1970-01-01T00:00:00+00:00"
|
||||
try:
|
||||
t0 = datetime.fromisoformat(started)
|
||||
except ValueError:
|
||||
t0 = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<gpx version="1.1" creator="bincio"'
|
||||
' xmlns="http://www.topografix.com/GPX/1/1"'
|
||||
' xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1">',
|
||||
f' <trk><name>{title}</name><trkseg>',
|
||||
]
|
||||
|
||||
for i, t in enumerate(t_vals):
|
||||
lat = lat_vals[i] if i < len(lat_vals) else None
|
||||
lon = lon_vals[i] if i < len(lon_vals) else None
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
ele = ele_vals[i] if i < len(ele_vals) else None
|
||||
hr = hr_vals[i] if i < len(hr_vals) else None
|
||||
ts_str = (t0 + timedelta(seconds=t)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
trkpt = f' <trkpt lat="{lat}" lon="{lon}">'
|
||||
if ele is not None:
|
||||
trkpt += f"<ele>{ele}</ele>"
|
||||
trkpt += f"<time>{ts_str}</time>"
|
||||
if hr is not None:
|
||||
trkpt += (
|
||||
f"<extensions><gpxtpx:TrackPointExtension>"
|
||||
f"<gpxtpx:hr>{hr}</gpxtpx:hr>"
|
||||
f"</gpxtpx:TrackPointExtension></extensions>"
|
||||
)
|
||||
trkpt += "</trkpt>"
|
||||
lines.append(trkpt)
|
||||
|
||||
lines += [" </trkseg></trk>", "</gpx>"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@router.get("/api/activity/{activity_id}/download/{fmt}")
|
||||
async def download_activity(
|
||||
activity_id: str,
|
||||
fmt: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> Response:
|
||||
deps._check_id(activity_id)
|
||||
if fmt not in ("bas", "original", "gpx"):
|
||||
raise HTTPException(400, "fmt must be bas, original, or gpx")
|
||||
|
||||
result = _find_activity(activity_id)
|
||||
if result is None:
|
||||
raise HTTPException(404, "Activity not found")
|
||||
handle, detail_path = result
|
||||
|
||||
detail = json.loads(detail_path.read_text(encoding="utf-8"))
|
||||
_check_download_permission(detail, handle, bincio_session)
|
||||
|
||||
if fmt == "bas":
|
||||
# Embed the timeseries so the downloaded file is self-contained.
|
||||
ts_path: Path | None = None
|
||||
data_dir = deps._get_data_dir()
|
||||
for base in (
|
||||
data_dir / handle / "_merged" / "activities",
|
||||
data_dir / handle / "activities",
|
||||
):
|
||||
p = base / f"{activity_id}.timeseries.json"
|
||||
if p.exists():
|
||||
ts_path = p
|
||||
break
|
||||
if ts_path:
|
||||
try:
|
||||
detail["timeseries"] = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
detail.pop("timeseries_url", None)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
content = json.dumps(detail, ensure_ascii=False, indent=2)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{activity_id}.json"'},
|
||||
)
|
||||
|
||||
if fmt == "original":
|
||||
source = detail.get("source") or ""
|
||||
source_file = detail.get("source_file") or ""
|
||||
if source not in ("fit_file", "gpx_file") or not source_file:
|
||||
raise HTTPException(404, "No original file available for this activity")
|
||||
safe_name = Path(source_file).name # strip any directory traversal
|
||||
orig_path = deps._get_data_dir() / handle / "originals" / safe_name
|
||||
if not orig_path.exists():
|
||||
raise HTTPException(404, "Original file not found on disk")
|
||||
media_type = "application/octet-stream"
|
||||
if safe_name.endswith(".fit"):
|
||||
media_type = "application/vnd.ant.fit"
|
||||
elif safe_name.endswith(".gpx"):
|
||||
media_type = "application/gpx+xml"
|
||||
return FileResponse(
|
||||
orig_path,
|
||||
media_type=media_type,
|
||||
filename=safe_name,
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
|
||||
)
|
||||
|
||||
# fmt == "gpx"
|
||||
data_dir = deps._get_data_dir()
|
||||
ts_path: Path | None = None
|
||||
for base in (
|
||||
data_dir / handle / "_merged" / "activities",
|
||||
data_dir / handle / "activities",
|
||||
):
|
||||
p = base / f"{activity_id}.timeseries.json"
|
||||
if p.exists():
|
||||
ts_path = p
|
||||
break
|
||||
|
||||
if ts_path is None:
|
||||
raise HTTPException(404, "No GPS data available for this activity")
|
||||
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
lat_vals = ts.get("lat") or []
|
||||
if not any(v is not None for v in lat_vals):
|
||||
raise HTTPException(404, "No GPS data available for this activity")
|
||||
|
||||
gpx_content = _generate_gpx(detail, ts)
|
||||
raw_title = detail.get("title") or activity_id
|
||||
safe_title = "".join(c for c in raw_title if c.isalnum() or c in " -_")[:50].strip()
|
||||
filename = f"{safe_title or activity_id}.gpx"
|
||||
return Response(
|
||||
content=gpx_content,
|
||||
media_type="application/gpx+xml",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Feed and wheel endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
from bincio.serve.models import CurrentUserResponse
|
||||
from bincio.serve.db import (
|
||||
User,
|
||||
get_member_tree,
|
||||
get_setting,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/me", response_model=CurrentUserResponse)
|
||||
async def me(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._current_user(bincio_session)
|
||||
if not user:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
store_orig = get_setting(deps._get_db(), "store_originals")
|
||||
return JSONResponse({
|
||||
"handle": user.handle,
|
||||
"display_name": user.display_name,
|
||||
"is_admin": user.is_admin,
|
||||
"wiki_access": user.wiki_access,
|
||||
"activity_access": user.activity_access,
|
||||
"store_originals_default": store_orig != "false",
|
||||
"dem_configured": bool(deps.dem_url),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/stats")
|
||||
async def stats() -> JSONResponse:
|
||||
"""Public endpoint: member count, join dates, and invitation tree."""
|
||||
import time as _time
|
||||
now = int(_time.time())
|
||||
members = get_member_tree(deps._get_db())
|
||||
return JSONResponse({
|
||||
"user_count": len(members),
|
||||
"members": [
|
||||
{
|
||||
"handle": m["handle"],
|
||||
"display_name": m["display_name"],
|
||||
"member_since": m["created_at"],
|
||||
"member_for_days": (now - m["created_at"]) // 86400,
|
||||
"invited_by": m["invited_by"],
|
||||
}
|
||||
for m in members
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/internal/rebuild")
|
||||
async def internal_rebuild(request: Request) -> JSONResponse:
|
||||
"""Trigger a site rebuild. Authenticated via X-Sync-Secret header.
|
||||
|
||||
Called by the bincio sync-strava systemd timer after syncing new activities.
|
||||
Returns 503 if webroot is not configured (rebuild not possible).
|
||||
Returns 403 if the secret is missing or wrong.
|
||||
"""
|
||||
if not deps.sync_secret:
|
||||
raise HTTPException(503, "Rebuild endpoint not configured (no sync secret set)")
|
||||
if request.headers.get("X-Sync-Secret") != deps.sync_secret:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
if deps.site_dir is None:
|
||||
raise HTTPException(503, "No site dir configured")
|
||||
tasks._site_rebuild_event.set()
|
||||
return JSONResponse({"status": "rebuild queued"})
|
||||
|
||||
|
||||
@router.get("/api/wheel/version")
|
||||
async def wheel_version() -> JSONResponse:
|
||||
"""Public endpoint: current bincio wheel version for mobile app update checks."""
|
||||
import importlib.metadata
|
||||
try:
|
||||
version = importlib.metadata.version("bincio")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
version = "0.1.0"
|
||||
return JSONResponse({
|
||||
"version": version,
|
||||
"url": f"/bincio-{version}-py3-none-any.whl",
|
||||
"api_url": f"/api/wheel/download",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/wheel/download")
|
||||
async def wheel_download() -> FileResponse:
|
||||
"""Serve the bincio wheel directly (used locally; in prod nginx serves /bincio-*.whl)."""
|
||||
import importlib.metadata
|
||||
from pathlib import Path
|
||||
try:
|
||||
version = importlib.metadata.version("bincio")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
version = "0.1.0"
|
||||
wheel_name = f"bincio-{version}-py3-none-any.whl"
|
||||
# Look in dist/ relative to repo root (two levels up from this file)
|
||||
dist_dir = Path(__file__).parent.parent.parent.parent / "dist"
|
||||
wheel_path = dist_dir / wheel_name
|
||||
if not wheel_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"{wheel_name} not found in dist/")
|
||||
return FileResponse(wheel_path, media_type="application/zip", filename=wheel_name)
|
||||
|
||||
|
||||
@router.get("/api/feed")
|
||||
async def get_feed(user: User = Depends(deps._require_auth)) -> JSONResponse:
|
||||
"""Return the authenticated user's activity summaries (mobile feed sync).
|
||||
|
||||
_merged/index.json is a shard manifest (activities: []) when the user has
|
||||
more than FEED_PAGE_SIZE activities. Collect from all shard files.
|
||||
"""
|
||||
dd = deps._get_data_dir()
|
||||
user_dir = dd / user.handle
|
||||
for index_path in (user_dir / "_merged" / "index.json", user_dir / "index.json"):
|
||||
if not index_path.exists():
|
||||
continue
|
||||
index = json.loads(index_path.read_text())
|
||||
activities: list[dict] = index.get("activities", [])
|
||||
for shard in index.get("shards", []):
|
||||
shard_path = index_path.parent / shard["url"]
|
||||
if shard_path.exists():
|
||||
shard_doc = json.loads(shard_path.read_text())
|
||||
activities.extend(shard_doc.get("activities", []))
|
||||
return JSONResponse({"activities": activities})
|
||||
return JSONResponse({"activities": []})
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Garmin Connect endpoints (/api/garmin/*)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _garmin_user_message(exc: Exception) -> str:
|
||||
"""Return a human-friendly error message for common Garmin login failures."""
|
||||
msg = str(exc)
|
||||
fallback = (
|
||||
" In the meantime, you can export your activities from Garmin Connect "
|
||||
"(garmin.com → Activities → Export) or Garmin Express as FIT files "
|
||||
"and upload them directly."
|
||||
)
|
||||
if "429" in msg or "rate limit" in msg.lower():
|
||||
return (
|
||||
"Garmin is rate-limiting this server's IP address (HTTP 429). "
|
||||
"Wait a few hours and try again." + fallback
|
||||
)
|
||||
if "403" in msg:
|
||||
return (
|
||||
"Cloudflare is blocking the login request (HTTP 403). "
|
||||
"This is a known upstream issue — try again later or update garminconnect "
|
||||
"(uv sync --extra garmin)." + fallback
|
||||
)
|
||||
if "GARMIN Authentication Application" in msg or "unexpected title" in msg.lower():
|
||||
return (
|
||||
"Garmin's login page returned a CAPTCHA or MFA challenge that "
|
||||
"cannot be completed automatically. Try again later, or disable "
|
||||
"two-factor authentication on your Garmin account." + fallback
|
||||
)
|
||||
return f"Login failed: {exc}" + fallback
|
||||
|
||||
|
||||
@router.get("/api/garmin/status")
|
||||
async def garmin_status(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Return whether Garmin credentials are stored for the current user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
from bincio.extract.garmin_api import has_credentials
|
||||
from bincio.extract.garmin_sync import _load_sync_state
|
||||
connected = has_credentials(dd)
|
||||
last_sync = None
|
||||
if connected:
|
||||
state = _load_sync_state(dd)
|
||||
last_sync = state.get("last_sync_at")
|
||||
return JSONResponse({"connected": connected, "last_sync": last_sync})
|
||||
|
||||
|
||||
@router.post("/api/garmin/connect")
|
||||
async def garmin_connect(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Test Garmin login with the supplied credentials and save them on success."""
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
email = (body.get("email") or "").strip()
|
||||
password = body.get("password") or ""
|
||||
if not email or not password:
|
||||
raise HTTPException(400, "email and password are required")
|
||||
|
||||
data_dir = deps._get_data_dir()
|
||||
user_dir = data_dir / user.handle
|
||||
from bincio.extract.garmin_api import GarminError, test_login
|
||||
try:
|
||||
info = test_login(data_dir, user_dir, email, password)
|
||||
except GarminError as exc:
|
||||
raise HTTPException(400, _garmin_user_message(exc))
|
||||
return JSONResponse({"ok": True, **info})
|
||||
|
||||
|
||||
@router.post("/api/garmin/disconnect")
|
||||
async def garmin_disconnect(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Remove stored Garmin credentials and session for the current user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
from bincio.extract.garmin_api import delete_credentials
|
||||
delete_credentials(dd)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/garmin/sync/stream")
|
||||
async def garmin_sync_stream(bincio_session: str | None = Cookie(default=None)) -> StreamingResponse:
|
||||
"""SSE endpoint — streams per-activity Garmin sync progress."""
|
||||
user = deps._require_user(bincio_session)
|
||||
data_dir = deps._get_data_dir()
|
||||
user_dir = data_dir / user.handle
|
||||
|
||||
from bincio.extract.garmin_api import GarminError, has_credentials
|
||||
if not has_credentials(user_dir):
|
||||
raise HTTPException(400, "No Garmin credentials stored — connect first")
|
||||
|
||||
from bincio.extract.garmin_sync import garmin_sync_iter
|
||||
|
||||
def event_stream():
|
||||
try:
|
||||
for event in garmin_sync_iter(data_dir, user_dir):
|
||||
if event["type"] == "done":
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
except GarminError as exc:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n"
|
||||
except Exception as exc:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/garmin/import-gear")
|
||||
async def garmin_import_gear(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""One-time backfill: fetch gear registry from Garmin and match to existing activities by timestamp."""
|
||||
from bincio.extract.garmin_api import GarminError, has_credentials
|
||||
from bincio.extract.garmin_sync import import_garmin_gear
|
||||
|
||||
user = deps._require_user(bincio_session)
|
||||
data_dir = deps._get_data_dir()
|
||||
user_dir = data_dir / user.handle
|
||||
|
||||
if not has_credentials(user_dir):
|
||||
raise HTTPException(400, "No Garmin credentials stored — connect first")
|
||||
|
||||
try:
|
||||
result = import_garmin_gear(data_dir, user_dir)
|
||||
except GarminError as exc:
|
||||
raise HTTPException(502, _garmin_user_message(exc))
|
||||
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse({"ok": True, **result})
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Gear registry endpoints (/api/gear)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from bincio.serve import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_GEAR_TYPES = {"bike", "shoes", "skis", "other"}
|
||||
|
||||
|
||||
def _gear_path(user_dir: Path) -> Path:
|
||||
return user_dir / "gear.json"
|
||||
|
||||
|
||||
def _load(user_dir: Path) -> list[dict]:
|
||||
p = _gear_path(user_dir)
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
return data.get("items", [])
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
|
||||
def _save(user_dir: Path, items: list[dict]) -> None:
|
||||
_gear_path(user_dir).write_text(
|
||||
json.dumps({"items": items}, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/gear")
|
||||
async def gear_list(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
items = _load(deps._get_data_dir() / user.handle)
|
||||
return JSONResponse({"items": items})
|
||||
|
||||
|
||||
@router.post("/api/gear")
|
||||
async def gear_add(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
name = str(body.get("name", "")).strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
gear_type = str(body.get("type", "other")).strip()
|
||||
if gear_type not in _GEAR_TYPES:
|
||||
raise HTTPException(400, f"type must be one of: {', '.join(sorted(_GEAR_TYPES))}")
|
||||
strava_id = str(body.get("strava_id", "")).strip() or None
|
||||
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
|
||||
# Deduplicate by strava_id if provided
|
||||
if strava_id and any(i.get("strava_id") == strava_id for i in items):
|
||||
existing = next(i for i in items if i.get("strava_id") == strava_id)
|
||||
return JSONResponse({"ok": True, "item": existing, "created": False})
|
||||
|
||||
item: dict = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name,
|
||||
"type": gear_type,
|
||||
"retired": False,
|
||||
}
|
||||
if strava_id:
|
||||
item["strava_id"] = strava_id
|
||||
|
||||
items.append(item)
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "item": item, "created": True}, status_code=201)
|
||||
|
||||
|
||||
@router.patch("/api/gear/{item_id}")
|
||||
async def gear_update(
|
||||
item_id: str,
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
|
||||
idx = next((i for i, g in enumerate(items) if g["id"] == item_id), None)
|
||||
if idx is None:
|
||||
raise HTTPException(404, "Gear item not found")
|
||||
|
||||
body = await request.json()
|
||||
item = dict(items[idx])
|
||||
|
||||
if "name" in body:
|
||||
name = str(body["name"]).strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name cannot be empty")
|
||||
item["name"] = name
|
||||
if "type" in body:
|
||||
gear_type = str(body["type"]).strip()
|
||||
if gear_type not in _GEAR_TYPES:
|
||||
raise HTTPException(400, f"type must be one of: {', '.join(sorted(_GEAR_TYPES))}")
|
||||
item["type"] = gear_type
|
||||
if "retired" in body:
|
||||
item["retired"] = bool(body["retired"])
|
||||
|
||||
items[idx] = item
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "item": item})
|
||||
|
||||
|
||||
@router.delete("/api/gear/{item_id}")
|
||||
async def gear_delete(
|
||||
item_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
|
||||
before = len(items)
|
||||
items = [g for g in items if g["id"] != item_id]
|
||||
if len(items) == before:
|
||||
raise HTTPException(404, "Gear item not found")
|
||||
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ── Parts ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _find_item(items: list[dict], item_id: str) -> tuple[int, dict]:
|
||||
idx = next((i for i, g in enumerate(items) if g["id"] == item_id), None)
|
||||
if idx is None:
|
||||
raise HTTPException(404, "Gear item not found")
|
||||
return idx, items[idx]
|
||||
|
||||
|
||||
@router.post("/api/gear/{item_id}/parts")
|
||||
async def part_add(
|
||||
item_id: str,
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
idx, item = _find_item(items, item_id)
|
||||
|
||||
body = await request.json()
|
||||
name = str(body.get("name", "")).strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
threshold_km = body.get("threshold_km")
|
||||
if threshold_km is not None:
|
||||
try:
|
||||
threshold_km = float(threshold_km)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "threshold_km must be a number")
|
||||
|
||||
part: dict = {"id": str(uuid.uuid4()), "name": name, "replacements": []}
|
||||
if threshold_km is not None:
|
||||
part["threshold_km"] = threshold_km
|
||||
|
||||
item = dict(item)
|
||||
item.setdefault("parts", [])
|
||||
item["parts"] = [*item["parts"], part]
|
||||
items[idx] = item
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "part": part}, status_code=201)
|
||||
|
||||
|
||||
@router.patch("/api/gear/{item_id}/parts/{part_id}")
|
||||
async def part_update(
|
||||
item_id: str,
|
||||
part_id: str,
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
idx, item = _find_item(items, item_id)
|
||||
|
||||
parts = list(item.get("parts", []))
|
||||
pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None)
|
||||
if pidx is None:
|
||||
raise HTTPException(404, "Part not found")
|
||||
|
||||
body = await request.json()
|
||||
part = dict(parts[pidx])
|
||||
if "name" in body:
|
||||
name = str(body["name"]).strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name cannot be empty")
|
||||
part["name"] = name
|
||||
if "threshold_km" in body:
|
||||
try:
|
||||
part["threshold_km"] = float(body["threshold_km"])
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "threshold_km must be a number")
|
||||
|
||||
parts[pidx] = part
|
||||
item = {**item, "parts": parts}
|
||||
items[idx] = item
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "part": part})
|
||||
|
||||
|
||||
@router.delete("/api/gear/{item_id}/parts/{part_id}")
|
||||
async def part_delete(
|
||||
item_id: str,
|
||||
part_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
idx, item = _find_item(items, item_id)
|
||||
|
||||
parts = [p for p in item.get("parts", []) if p["id"] != part_id]
|
||||
items[idx] = {**item, "parts": parts}
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/api/gear/{item_id}/parts/{part_id}/replacements")
|
||||
async def replacement_add(
|
||||
item_id: str,
|
||||
part_id: str,
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Log a replacement event for a part. date defaults to today (UTC)."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
idx, item = _find_item(items, item_id)
|
||||
|
||||
parts = list(item.get("parts", []))
|
||||
pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None)
|
||||
if pidx is None:
|
||||
raise HTTPException(404, "Part not found")
|
||||
|
||||
body = await request.json()
|
||||
date = str(body.get("date", "")).strip() or datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
note = str(body.get("note", "")).strip() or None
|
||||
|
||||
entry: dict = {"id": str(uuid.uuid4()), "date": date}
|
||||
if note:
|
||||
entry["note"] = note
|
||||
|
||||
part = dict(parts[pidx])
|
||||
part["replacements"] = [*part.get("replacements", []), entry]
|
||||
parts[pidx] = part
|
||||
items[idx] = {**item, "parts": parts}
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "replacement": entry}, status_code=201)
|
||||
|
||||
|
||||
@router.delete("/api/gear/{item_id}/parts/{part_id}/replacements/{replacement_id}")
|
||||
async def replacement_delete(
|
||||
item_id: str,
|
||||
part_id: str,
|
||||
replacement_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
idx, item = _find_item(items, item_id)
|
||||
|
||||
parts = list(item.get("parts", []))
|
||||
pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None)
|
||||
if pidx is None:
|
||||
raise HTTPException(404, "Part not found")
|
||||
|
||||
part = dict(parts[pidx])
|
||||
part["replacements"] = [r for r in part.get("replacements", []) if r["id"] != replacement_id]
|
||||
parts[pidx] = part
|
||||
items[idx] = {**item, "parts": parts}
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True})
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Ideas and feedback endpoints (/api/ideas/*, /api/feedback)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl as _fcntl
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Cookie, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from bincio.serve import deps
|
||||
from bincio.serve.models import IdeaBody, IdeaCommentBody
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
|
||||
_FEEDBACK_MAX_IMAGES = 3
|
||||
_FEEDBACK_MAX_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB
|
||||
|
||||
|
||||
def _ideas_dir(data_dir: Path) -> Path:
|
||||
d = data_dir / "_ideas"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
@router.get("/api/ideas")
|
||||
async def list_ideas(
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
ideas = []
|
||||
for path in sorted(_ideas_dir(dd).glob("*.json")):
|
||||
try:
|
||||
idea = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
votes = idea.get("votes", [])
|
||||
idea["vote_count"] = len(votes)
|
||||
idea["my_vote"] = user.handle in votes
|
||||
ideas.append(idea)
|
||||
def _sort_key(x: dict):
|
||||
s = x.get("status") or "open"
|
||||
order = {"awaiting": 0, "open": 1, "done": 2, "declined": 3}
|
||||
return (order.get(s, 1), -x["vote_count"], -x["created_at"])
|
||||
ideas.sort(key=_sort_key)
|
||||
return JSONResponse({"ideas": ideas})
|
||||
|
||||
|
||||
@router.post("/api/ideas")
|
||||
async def create_idea(
|
||||
data: IdeaBody,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
title = data.title.strip()[:200]
|
||||
body = data.body.strip()[:2000]
|
||||
if not title:
|
||||
raise HTTPException(400, "Title required")
|
||||
dd = deps._get_data_dir()
|
||||
idea_id = secrets.token_hex(8)
|
||||
idea = {
|
||||
"id": idea_id,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"author": user.handle,
|
||||
"created_at": int(time.time()),
|
||||
"votes": [],
|
||||
}
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
path.write_text(json.dumps(idea, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return JSONResponse({"id": idea_id})
|
||||
|
||||
|
||||
@router.post("/api/ideas/{idea_id}/vote")
|
||||
async def toggle_idea_vote(
|
||||
idea_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
votes: list = idea.get("votes", [])
|
||||
if user.handle in votes:
|
||||
votes.remove(user.handle)
|
||||
voted = False
|
||||
else:
|
||||
votes.append(user.handle)
|
||||
voted = True
|
||||
idea["votes"] = votes
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"voted": voted, "votes": len(votes)})
|
||||
|
||||
|
||||
@router.post("/api/ideas/{idea_id}/status")
|
||||
async def toggle_idea_status(
|
||||
idea_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
cycle = {"open": "awaiting", "awaiting": "done", "done": "open"}
|
||||
idea["status"] = cycle.get(idea.get("status") or "open", "awaiting")
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"status": idea["status"]})
|
||||
|
||||
|
||||
@router.post("/api/ideas/{idea_id}/reopen")
|
||||
async def reopen_idea(
|
||||
idea_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
idea["status"] = "open"
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"status": "open"})
|
||||
|
||||
|
||||
@router.post("/api/ideas/{idea_id}/decline")
|
||||
async def decline_idea(
|
||||
idea_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
idea["status"] = "open" if idea.get("status") == "declined" else "declined"
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"status": idea["status"]})
|
||||
|
||||
|
||||
@router.post("/api/ideas/{idea_id}/comment")
|
||||
async def set_idea_comment(
|
||||
idea_id: str,
|
||||
data: IdeaCommentBody,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
comment = data.comment.strip()[:1000]
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
if comment:
|
||||
idea["admin_comment"] = comment
|
||||
else:
|
||||
idea.pop("admin_comment", None)
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"ok": True, "admin_comment": comment or None})
|
||||
|
||||
|
||||
@router.patch("/api/ideas/{idea_id}")
|
||||
async def edit_idea(
|
||||
idea_id: str,
|
||||
data: IdeaBody,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
title = data.title.strip()[:200]
|
||||
body = data.body.strip()[:2000]
|
||||
if not title:
|
||||
raise HTTPException(400, "Title required")
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
if not user.is_admin and idea.get("author") != user.handle:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
idea["title"] = title
|
||||
idea["body"] = body
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"ok": True, "title": title, "body": body})
|
||||
|
||||
|
||||
@router.delete("/api/ideas/{idea_id}")
|
||||
async def delete_idea(
|
||||
idea_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
try:
|
||||
idea = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
raise HTTPException(500, "Could not read idea")
|
||||
if not user.is_admin and idea.get("author") != user.handle:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
path.unlink()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ── Feedback ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/feedback")
|
||||
async def submit_feedback(
|
||||
text: str = Form(""),
|
||||
images: list[UploadFile] = File(default=[]),
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
|
||||
text = text.strip()
|
||||
if not text and not any(f.filename for f in images):
|
||||
raise HTTPException(400, "Feedback must include text or at least one image")
|
||||
if len(images) > _FEEDBACK_MAX_IMAGES:
|
||||
raise HTTPException(400, f"Maximum {_FEEDBACK_MAX_IMAGES} images per submission")
|
||||
|
||||
feedback_dir = deps._get_data_dir() / "_feedback"
|
||||
feedback_dir.mkdir(exist_ok=True)
|
||||
images_dir = feedback_dir / user.handle
|
||||
images_dir.mkdir(exist_ok=True)
|
||||
|
||||
now = int(time.time())
|
||||
submission_id = f"{now}_{secrets.token_hex(4)}"
|
||||
saved_images: list[str] = []
|
||||
|
||||
for img in images:
|
||||
if not img.filename:
|
||||
continue
|
||||
suffix = Path(img.filename).suffix.lower()
|
||||
if suffix not in _FEEDBACK_IMAGE_SUFFIXES:
|
||||
raise HTTPException(400, f"Unsupported image type '{suffix}'")
|
||||
contents = await img.read()
|
||||
if len(contents) > _FEEDBACK_MAX_IMAGE_BYTES:
|
||||
raise HTTPException(413, f"Image '{img.filename}' exceeds 2 MB limit")
|
||||
safe_name = f"{submission_id}_{Path(img.filename).name}"
|
||||
(images_dir / safe_name).write_bytes(contents)
|
||||
saved_images.append(safe_name)
|
||||
|
||||
from datetime import datetime, timezone
|
||||
entry = {
|
||||
"id": submission_id,
|
||||
"handle": user.handle,
|
||||
"submitted_at": datetime.now(timezone.utc).isoformat(),
|
||||
"text": text,
|
||||
"images": saved_images,
|
||||
}
|
||||
|
||||
log_file = feedback_dir / f"{user.handle}.json"
|
||||
existing: list[dict] = []
|
||||
if log_file.exists():
|
||||
try:
|
||||
existing = json.loads(log_file.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
existing = []
|
||||
existing.append(entry)
|
||||
log_file.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
return JSONResponse({"ok": True, "id": submission_id})
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Self-service user settings endpoints (/api/me/*)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
from bincio.serve.db import (
|
||||
authenticate,
|
||||
get_user_prefs,
|
||||
set_user_prefs,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _wipe_user_activities(user_dir: Path) -> int:
|
||||
"""Delete all extracted activity files and caches for a user.
|
||||
|
||||
Removes activities/ (JSON + GeoJSON + timeseries), edits/, originals/,
|
||||
_merged/, index.json, athlete.json, and the dedup cache.
|
||||
Leaves the user directory itself intact (account remains in the DB).
|
||||
Returns the number of files deleted.
|
||||
"""
|
||||
import shutil
|
||||
deleted = 0
|
||||
|
||||
for subdir in ("activities", "edits", "originals"):
|
||||
d = user_dir / subdir
|
||||
if d.exists():
|
||||
for f in d.rglob("*"):
|
||||
if f.is_file():
|
||||
deleted += 1
|
||||
shutil.rmtree(d)
|
||||
|
||||
for name in ("_merged", ):
|
||||
d = user_dir / name
|
||||
if d.exists():
|
||||
shutil.rmtree(d)
|
||||
|
||||
for name in ("index.json", "athlete.json", ".bincio_cache.json", "tracks.json", "tracks_index.json"):
|
||||
f = user_dir / name
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
|
||||
for shard in user_dir.glob("tracks_*.json"):
|
||||
shard.unlink(missing_ok=True)
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
@router.get("/api/me/tracks")
|
||||
async def me_tracks(bincio_session: str | None = Cookie(default=None)) -> Response:
|
||||
"""Return the tracks manifest (years list + total) for the logged-in user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
index_path = deps._get_data_dir() / user.handle / "tracks_index.json"
|
||||
if not index_path.exists():
|
||||
raise HTTPException(404, "Tracks not yet baked — upload an activity first")
|
||||
return Response(content=index_path.read_bytes(), media_type="application/json")
|
||||
|
||||
|
||||
@router.get("/api/me/tracks/{year}")
|
||||
async def me_tracks_year(year: str, bincio_session: str | None = Cookie(default=None)) -> Response:
|
||||
"""Return the pre-baked tracks shard for a specific year."""
|
||||
user = deps._require_user(bincio_session)
|
||||
if not year.isdigit() or len(year) != 4:
|
||||
raise HTTPException(400, "year must be a 4-digit string")
|
||||
shard_path = deps._get_data_dir() / user.handle / f"tracks_{year}.json"
|
||||
if not shard_path.exists():
|
||||
raise HTTPException(404, f"No tracks shard for year {year}")
|
||||
return Response(content=shard_path.read_bytes(), media_type="application/json")
|
||||
|
||||
|
||||
@router.get("/api/me/storage")
|
||||
async def me_storage(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Return per-category disk usage for the logged-in user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
|
||||
def _mb(path: Path) -> float:
|
||||
if not path.exists():
|
||||
return 0.0
|
||||
total = sum(f.lstat().st_size for f in path.rglob("*") if f.is_file() or f.is_symlink())
|
||||
return round(total / 1_048_576, 2)
|
||||
|
||||
def _count(path: Path, pattern: str = "*") -> int:
|
||||
if not path.exists():
|
||||
return 0
|
||||
return sum(1 for f in path.glob(pattern) if f.is_file())
|
||||
|
||||
activities_mb = _mb(dd / "activities")
|
||||
originals_mb = _mb(dd / "originals")
|
||||
strava_mb = _mb(dd / "originals" / "strava")
|
||||
images_mb = _mb(dd / "edits" / "images")
|
||||
total_mb = _mb(dd)
|
||||
|
||||
return JSONResponse({
|
||||
"total_mb": total_mb,
|
||||
"activities_mb": activities_mb,
|
||||
"activities_count": _count(dd / "activities", "*.json"),
|
||||
"originals_mb": originals_mb,
|
||||
"strava_originals_mb": strava_mb,
|
||||
"strava_originals_count": _count(dd / "originals" / "strava", "*.json"),
|
||||
"images_mb": images_mb,
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/api/me/originals")
|
||||
async def me_delete_originals(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Delete the user's originals/ directory (frees space after re-extraction)."""
|
||||
user = deps._require_user(bincio_session)
|
||||
originals = deps._get_data_dir() / user.handle / "originals"
|
||||
if not originals.exists():
|
||||
return JSONResponse({"ok": True, "freed_mb": 0.0})
|
||||
|
||||
freed = round(
|
||||
sum(f.stat().st_size for f in originals.rglob("*") if f.is_file()) / 1_048_576, 2
|
||||
)
|
||||
shutil.rmtree(originals)
|
||||
return JSONResponse({"ok": True, "freed_mb": freed})
|
||||
|
||||
|
||||
@router.delete("/api/me/activities")
|
||||
async def me_delete_activities(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Wipe all extracted activity data (activities/, edits/, _merged/, index/athlete JSON).
|
||||
|
||||
Requires the user's current password in the request body for confirmation.
|
||||
"""
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
password = body.get("password", "")
|
||||
if not authenticate(deps._get_db(), user.handle, password):
|
||||
raise HTTPException(401, "Wrong password")
|
||||
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
deleted = _wipe_user_activities(user_dir)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse({"ok": True, "deleted": deleted})
|
||||
|
||||
|
||||
@router.delete("/api/me")
|
||||
async def me_delete_account(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Delete the account and all data permanently.
|
||||
|
||||
Requires the user's current password. Deletes the DB row, all sessions,
|
||||
and the entire user data directory. The root shard manifest is updated.
|
||||
"""
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
password = body.get("password", "")
|
||||
if not authenticate(deps._get_db(), user.handle, password):
|
||||
raise HTTPException(401, "Wrong password")
|
||||
|
||||
# Wipe data directory
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
if user_dir.is_dir():
|
||||
shutil.rmtree(user_dir)
|
||||
|
||||
# Remove from DB (cascades to sessions, invites, reset_codes)
|
||||
from bincio.serve.db import delete_user as _delete_user
|
||||
_delete_user(deps._get_db(), user.handle)
|
||||
|
||||
# Update root manifest so the shard disappears
|
||||
from bincio.render.cli import _write_root_manifest
|
||||
try:
|
||||
_write_root_manifest(deps._get_data_dir())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.delete_cookie(deps._SESSION_COOKIE)
|
||||
return resp
|
||||
|
||||
|
||||
@router.put("/api/me/display-name")
|
||||
async def me_update_display_name(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Update the logged-in user's display name."""
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
display_name = str(body.get("display_name", "")).strip()
|
||||
if len(display_name) > 60:
|
||||
raise HTTPException(400, "Display name too long (max 60 characters)")
|
||||
db = deps._get_db()
|
||||
db.execute("UPDATE users SET display_name = ? WHERE handle = ?", (display_name, user.handle))
|
||||
db.commit()
|
||||
return JSONResponse({"ok": True, "display_name": display_name})
|
||||
|
||||
|
||||
@router.get("/api/me/prefs")
|
||||
async def me_get_prefs(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Return all user preferences as a key→value dict."""
|
||||
user = deps._require_user(bincio_session)
|
||||
return JSONResponse(get_user_prefs(deps._get_db(), user.handle))
|
||||
|
||||
|
||||
@router.put("/api/me/prefs")
|
||||
async def me_set_prefs(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Upsert one or more user preferences. Body: {key: value, ...} (all strings)."""
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Body must be a JSON object")
|
||||
# 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})
|
||||
|
||||
|
||||
@router.get("/api/me/strava-credentials")
|
||||
async def me_get_strava_credentials(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Return whether per-user Strava credentials are configured (never returns the secret)."""
|
||||
user = deps._require_user(bincio_session)
|
||||
creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE
|
||||
has_user_creds = False
|
||||
client_id_hint = ""
|
||||
if creds_path.exists():
|
||||
try:
|
||||
d = json.loads(creds_path.read_text(encoding="utf-8"))
|
||||
cid = str(d.get("client_id", "")).strip()
|
||||
csec = str(d.get("client_secret", "")).strip()
|
||||
if cid and csec:
|
||||
has_user_creds = True
|
||||
client_id_hint = cid
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return JSONResponse({
|
||||
"has_user_creds": has_user_creds,
|
||||
"client_id": client_id_hint,
|
||||
"instance_configured": bool(deps.strava_client_id),
|
||||
})
|
||||
|
||||
|
||||
@router.put("/api/me/strava-credentials")
|
||||
async def me_set_strava_credentials(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Save per-user Strava credentials. Body: {client_id, client_secret}."""
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
cid = str(body.get("client_id", "")).strip()
|
||||
csec = str(body.get("client_secret", "")).strip()
|
||||
if not cid:
|
||||
raise HTTPException(400, "client_id is required")
|
||||
creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE
|
||||
# If client_secret is omitted, preserve existing secret (if any)
|
||||
if not csec:
|
||||
if creds_path.exists():
|
||||
try:
|
||||
existing = json.loads(creds_path.read_text(encoding="utf-8"))
|
||||
csec = str(existing.get("client_secret", "")).strip()
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
if not csec:
|
||||
raise HTTPException(400, "client_secret is required (no existing secret to preserve)")
|
||||
|
||||
# If the client_id changed, the existing token belongs to a different OAuth
|
||||
# app and will fail on refresh — delete it so the user must re-authenticate.
|
||||
token_path = deps._get_data_dir() / user.handle / "strava_token.json"
|
||||
if creds_path.exists() and token_path.exists():
|
||||
try:
|
||||
old_cid = str(json.loads(creds_path.read_text(encoding="utf-8")).get("client_id", "")).strip()
|
||||
if old_cid and old_cid != cid:
|
||||
token_path.unlink(missing_ok=True)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
creds_path.write_text(
|
||||
json.dumps({"client_id": cid, "client_secret": csec}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.delete("/api/me/strava-credentials")
|
||||
async def me_delete_strava_credentials(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Remove per-user Strava credentials (falls back to instance credentials)."""
|
||||
user = deps._require_user(bincio_session)
|
||||
creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE
|
||||
creds_path.unlink(missing_ok=True)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.put("/api/me/password")
|
||||
async def me_change_password(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Change the logged-in user's password. Requires current password."""
|
||||
from bincio.serve.db import change_password as _change_password
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
current = body.get("current_password", "")
|
||||
new_pw = body.get("new_password", "")
|
||||
if not authenticate(deps._get_db(), user.handle, current):
|
||||
raise HTTPException(401, "Current password is wrong")
|
||||
if len(new_pw) < 8:
|
||||
raise HTTPException(400, "New password must be at least 8 characters")
|
||||
_change_password(deps._get_db(), user.handle, new_pw)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/me/sync-status")
|
||||
async def get_sync_status(
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Return the last sync status for Strava and Garmin for the logged-in user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
|
||||
def _read_status(filename: str) -> dict | None:
|
||||
p = user_dir / filename
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
strava = _read_status("_strava_sync_status.json")
|
||||
garmin = _read_status("_garmin_sync_status.json")
|
||||
return JSONResponse({
|
||||
"strava": strava,
|
||||
"garmin": garmin,
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
"""OG preview endpoints.
|
||||
|
||||
GET /activity/{activity_id}
|
||||
Returns a minimal HTML page with Open Graph meta tags for social link
|
||||
previews (Telegram, WhatsApp, Slack, …). nginx proxies only bot
|
||||
User-Agents here; regular browsers still get the static SPA shell.
|
||||
|
||||
GET /api/og-image/{user_handle}/{activity_id}.png
|
||||
Returns the pre-generated 400×400 track PNG. Falls back to generating
|
||||
on the fly if the static file doesn't exist yet (e.g. a brand-new import
|
||||
before the next deploy-time generation run).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from bincio.serve import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Sport → emoji map (extend as needed)
|
||||
_SPORT_EMOJI: dict[str, str] = {
|
||||
"cycling": "🚴",
|
||||
"running": "🏃",
|
||||
"swimming": "🏊",
|
||||
"hiking": "🥾",
|
||||
"walking": "🚶",
|
||||
"skiing": "⛷️",
|
||||
"rowing": "🚣",
|
||||
"triathlon": "🏊",
|
||||
"e_cycling": "🚴",
|
||||
"gravel": "🚴",
|
||||
}
|
||||
|
||||
|
||||
def _find_user(data_dir: Path, activity_id: str) -> str | None:
|
||||
"""Return the user handle that owns *activity_id*, or None."""
|
||||
for user_dir in sorted(data_dir.iterdir()):
|
||||
if not user_dir.is_dir() or user_dir.name.startswith("_") or user_dir.name == "segments":
|
||||
continue
|
||||
if (user_dir / "activities" / f"{activity_id}.json").exists():
|
||||
return user_dir.name
|
||||
return None
|
||||
|
||||
|
||||
def _fmt_description(detail: dict, handle: str) -> str:
|
||||
parts: list[str] = []
|
||||
sport = (detail.get("sport") or "").lower()
|
||||
emoji = _SPORT_EMOJI.get(sport, "🏅")
|
||||
parts.append(emoji)
|
||||
|
||||
dist_m = detail.get("distance_m")
|
||||
if dist_m:
|
||||
parts.append(f"{dist_m / 1000:.1f} km")
|
||||
|
||||
gain = detail.get("elevation_gain_m")
|
||||
if gain:
|
||||
parts.append(f"{gain:.0f} m ↑")
|
||||
|
||||
dur = detail.get("moving_time_s") or detail.get("duration_s")
|
||||
if dur:
|
||||
h, rem = divmod(int(dur), 3600)
|
||||
m = rem // 60
|
||||
parts.append(f"{h}h {m:02d}m" if h else f"{m}m")
|
||||
|
||||
started = detail.get("started_at")
|
||||
if started:
|
||||
try:
|
||||
dt = datetime.fromisoformat(started).astimezone(timezone.utc)
|
||||
parts.append(dt.strftime("%-d %b %Y"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
parts.append(f"@{handle}")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
@router.get("/activity/{activity_id}", response_class=HTMLResponse, include_in_schema=False)
|
||||
@router.get("/activity/{activity_id}/", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def og_preview(activity_id: str, request: Request) -> HTMLResponse:
|
||||
data_dir = deps._get_data_dir()
|
||||
handle = _find_user(data_dir, activity_id)
|
||||
if handle is None:
|
||||
raise HTTPException(404)
|
||||
|
||||
json_path = data_dir / handle / "activities" / f"{activity_id}.json"
|
||||
detail = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
|
||||
title = detail.get("title") or activity_id
|
||||
desc = _fmt_description(detail, handle)
|
||||
base = str(request.base_url).rstrip("/")
|
||||
img_url = f"{base}/og-image/{handle}/{activity_id}.png"
|
||||
act_url = f"{base}/activity/{activity_id}/"
|
||||
|
||||
h_title = html.escape(title)
|
||||
h_desc = html.escape(desc)
|
||||
h_img = html.escape(img_url)
|
||||
h_url = html.escape(act_url)
|
||||
|
||||
content = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{h_title} – BincioActivity</title>
|
||||
<meta property="og:title" content="{h_title}" />
|
||||
<meta property="og:description" content="{h_desc}" />
|
||||
<meta property="og:image" content="{h_img}" />
|
||||
<meta property="og:image:width" content="400" />
|
||||
<meta property="og:image:height" content="400" />
|
||||
<meta property="og:url" content="{h_url}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="BincioActivity" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="{h_title}" />
|
||||
<meta name="twitter:description" content="{h_desc}" />
|
||||
<meta name="twitter:image" content="{h_img}" />
|
||||
<script>window.location.replace("{act_url}");</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=content)
|
||||
|
||||
|
||||
@router.get("/og-image/{user_handle}/{activity_id}.png", include_in_schema=False)
|
||||
async def og_image(user_handle: str, activity_id: str) -> Response:
|
||||
data_dir = deps._get_data_dir()
|
||||
www_root = Path("/var/www/activity")
|
||||
img_path = www_root / "og-image" / user_handle / f"{activity_id}.png"
|
||||
|
||||
if img_path.exists():
|
||||
return Response(
|
||||
content=img_path.read_bytes(),
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "public, max-age=86400"},
|
||||
)
|
||||
|
||||
# Fallback: generate on the fly (e.g. new activity before next deploy run)
|
||||
ts_path = data_dir / user_handle / "activities" / f"{activity_id}.timeseries.json"
|
||||
if not ts_path.exists():
|
||||
raise HTTPException(404)
|
||||
|
||||
try:
|
||||
from bincio.render.ogimage import generate_for_activity
|
||||
png = generate_for_activity(ts_path)
|
||||
img_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img_path.write_bytes(png)
|
||||
except Exception:
|
||||
raise HTTPException(500, "Image generation failed")
|
||||
|
||||
return Response(
|
||||
content=png,
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "public, max-age=86400"},
|
||||
)
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Segments endpoints (/api/segments/*)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Cookie, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from bincio.serve import deps
|
||||
from bincio.serve.models import CreateSegmentRequest
|
||||
from bincio.segments import models as _seg_models
|
||||
from bincio.segments import store as _seg_store
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _scan_segment_for_user(dd: Path, handle: str, segment_id: str) -> int:
|
||||
"""Scan all of a user's activities against one segment. Returns effort count."""
|
||||
from datetime import datetime as _datetime
|
||||
from bincio.segments.detect import track_from_timeseries_json, detect_one
|
||||
|
||||
seg = _seg_store.load_segment(dd, segment_id)
|
||||
if seg is None:
|
||||
return 0
|
||||
user_dir = dd / handle
|
||||
acts_dir = user_dir / "activities"
|
||||
total = 0
|
||||
for detail_path in sorted(acts_dir.glob("*.json")):
|
||||
if ".timeseries." in detail_path.name:
|
||||
continue
|
||||
try:
|
||||
detail = json.loads(detail_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
ts_url = detail.get("timeseries_url")
|
||||
if not ts_url:
|
||||
continue
|
||||
ts_path = user_dir / ts_url
|
||||
if not ts_path.exists():
|
||||
continue
|
||||
try:
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
started_raw = detail.get("started_at")
|
||||
if not started_raw:
|
||||
continue
|
||||
try:
|
||||
started_at = _datetime.fromisoformat(started_raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
continue
|
||||
track = track_from_timeseries_json(ts, detail.get("id", detail_path.stem),
|
||||
detail.get("sport", "other"), started_at)
|
||||
if track is None:
|
||||
continue
|
||||
efforts = detect_one(track, seg)
|
||||
for effort in efforts:
|
||||
_seg_store.add_effort(dd, handle, segment_id, effort)
|
||||
total += len(efforts)
|
||||
return total
|
||||
|
||||
|
||||
@router.get("/api/segments")
|
||||
async def get_segments(
|
||||
bbox: Optional[str] = None,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""List segments, optionally filtered to a map viewport bbox (lon_min,lat_min,lon_max,lat_max)."""
|
||||
deps._require_user(bincio_session)
|
||||
parsed_bbox: Optional[list[float]] = None
|
||||
if bbox:
|
||||
try:
|
||||
parts = [float(x) for x in bbox.split(",")]
|
||||
if len(parts) == 4:
|
||||
parsed_bbox = parts
|
||||
except ValueError:
|
||||
raise HTTPException(400, "bbox must be four comma-separated floats")
|
||||
dd = deps._get_data_dir()
|
||||
segs = _seg_store.list_segments(dd, parsed_bbox)
|
||||
return JSONResponse([{
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"sport": s.sport,
|
||||
"distance_m": s.distance_m,
|
||||
"bbox": s.bbox,
|
||||
"polyline": s.polyline,
|
||||
"created_by": s.created_by,
|
||||
"created_at": _seg_store._iso(s.created_at),
|
||||
} for s in segs])
|
||||
|
||||
|
||||
@router.get("/api/segments/{segment_id}")
|
||||
async def get_segment(segment_id: str) -> JSONResponse:
|
||||
"""Return metadata for a single segment."""
|
||||
dd = deps._get_data_dir()
|
||||
seg = _seg_store.load_segment(dd, segment_id)
|
||||
if seg is None:
|
||||
raise HTTPException(404, "Segment not found")
|
||||
return JSONResponse({
|
||||
"id": seg.id,
|
||||
"name": seg.name,
|
||||
"sport": seg.sport,
|
||||
"polyline": seg.polyline,
|
||||
"distance_m": seg.distance_m,
|
||||
"bbox": seg.bbox,
|
||||
"created_by": seg.created_by,
|
||||
"created_at": _seg_store._iso(seg.created_at),
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/segments")
|
||||
async def create_segment(
|
||||
body: CreateSegmentRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
if len(body.polyline) < 2:
|
||||
raise HTTPException(400, "polyline must have at least 2 points")
|
||||
if body.distance_m < 500:
|
||||
raise HTTPException(400, "segment must be at least 500 m long")
|
||||
|
||||
lats = [p[0] for p in body.polyline]
|
||||
lons = [p[1] for p in body.polyline]
|
||||
bbox = [min(lons), min(lats), max(lons), max(lats)]
|
||||
|
||||
seg_id = _seg_store.make_segment_id(body.name)
|
||||
from datetime import datetime, timezone as _tz
|
||||
seg = _seg_models.Segment(
|
||||
id=seg_id,
|
||||
name=body.name,
|
||||
sport=body.sport or None,
|
||||
polyline=body.polyline,
|
||||
distance_m=body.distance_m,
|
||||
bbox=bbox,
|
||||
created_by=user.handle,
|
||||
created_at=datetime.now(_tz.utc),
|
||||
)
|
||||
dd = deps._get_data_dir()
|
||||
_seg_store.save_segment(dd, seg)
|
||||
background_tasks.add_task(_scan_segment_for_user, dd, user.handle, seg_id)
|
||||
return JSONResponse({"id": seg_id}, status_code=201)
|
||||
|
||||
|
||||
@router.delete("/api/segments/{segment_id}")
|
||||
async def delete_segment(
|
||||
segment_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
seg = _seg_store.load_segment(dd, segment_id)
|
||||
if seg is None:
|
||||
raise HTTPException(404, "Segment not found")
|
||||
if seg.created_by != user.handle and not user.is_admin:
|
||||
raise HTTPException(403, "Not allowed")
|
||||
_seg_store.delete_segment(dd, segment_id)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/segments/{segment_id}/efforts")
|
||||
async def get_segment_efforts(
|
||||
segment_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Return all efforts on a segment for the logged-in user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
seg = _seg_store.load_segment(dd, segment_id)
|
||||
if seg is None:
|
||||
raise HTTPException(404, "Segment not found")
|
||||
efforts = _seg_store.load_efforts(dd, user.handle, segment_id)
|
||||
return JSONResponse([
|
||||
{
|
||||
"activity_id": e.activity_id,
|
||||
"started_at": _seg_store._iso(e.started_at),
|
||||
"elapsed_s": e.elapsed_s,
|
||||
"avg_speed_kmh": e.avg_speed_kmh,
|
||||
"avg_hr_bpm": e.avg_hr_bpm,
|
||||
"avg_power_w": e.avg_power_w,
|
||||
"np_power_w": e.np_power_w,
|
||||
}
|
||||
for e in efforts
|
||||
])
|
||||
|
||||
|
||||
@router.post("/api/segments/{segment_id}/detect")
|
||||
async def trigger_detect(
|
||||
segment_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Retroactively detect efforts on a segment for the logged-in user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
if _seg_store.load_segment(dd, segment_id) is None:
|
||||
raise HTTPException(404, "Segment not found")
|
||||
_seg_store.save_efforts(dd, user.handle, segment_id, [])
|
||||
total = _scan_segment_for_user(dd, user.handle, segment_id)
|
||||
return JSONResponse({"ok": True, "efforts_found": total})
|
||||
|
||||
|
||||
@router.post("/api/me/segment-rescan")
|
||||
async def me_segment_rescan(
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Retroactively detect efforts for ALL segments across ALL activities for the logged-in user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
user_dir = dd / user.handle
|
||||
acts_dir = user_dir / "activities"
|
||||
|
||||
from datetime import datetime as _datetime
|
||||
from bincio.segments.detect import track_from_timeseries_json, detect_one
|
||||
import json as _json
|
||||
|
||||
segments = _seg_store.list_segments(dd)
|
||||
if not segments:
|
||||
return JSONResponse({"ok": True, "efforts_found": 0})
|
||||
|
||||
for seg in segments:
|
||||
_seg_store.save_efforts(dd, user.handle, seg.id, [])
|
||||
|
||||
total = 0
|
||||
for detail_path in sorted(acts_dir.glob("*.json")):
|
||||
if ".timeseries." in detail_path.name:
|
||||
continue
|
||||
try:
|
||||
detail = _json.loads(detail_path.read_text(encoding="utf-8"))
|
||||
except (OSError, _json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
ts_url = detail.get("timeseries_url")
|
||||
if not ts_url:
|
||||
continue
|
||||
ts_path = user_dir / ts_url
|
||||
if not ts_path.exists():
|
||||
continue
|
||||
try:
|
||||
ts = _json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
except (OSError, _json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
started_raw = detail.get("started_at")
|
||||
if not started_raw:
|
||||
continue
|
||||
try:
|
||||
started_at = _datetime.fromisoformat(started_raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
continue
|
||||
track = track_from_timeseries_json(
|
||||
ts, detail.get("id", detail_path.stem),
|
||||
detail.get("sport", "other"), started_at,
|
||||
)
|
||||
if track is None:
|
||||
continue
|
||||
for seg in segments:
|
||||
efforts = detect_one(track, seg)
|
||||
for effort in efforts:
|
||||
_seg_store.add_effort(dd, user.handle, seg.id, effort)
|
||||
total += len(efforts)
|
||||
|
||||
return JSONResponse({"ok": True, "efforts_found": total})
|
||||
|
||||
|
||||
@router.get("/api/users/{handle}/segment_summary")
|
||||
async def user_segment_summary(handle: str) -> JSONResponse:
|
||||
"""Public endpoint: segments where this user has efforts, with best time and count."""
|
||||
dd = deps._get_data_dir()
|
||||
efforts_dir = dd / handle / "segment_efforts"
|
||||
result = []
|
||||
if efforts_dir.exists():
|
||||
for ef_file in sorted(efforts_dir.glob("*.json")):
|
||||
seg_id = ef_file.stem
|
||||
efforts = _seg_store.load_efforts(dd, handle, seg_id)
|
||||
if not efforts:
|
||||
continue
|
||||
seg = _seg_store.load_segment(dd, seg_id)
|
||||
if not seg:
|
||||
continue
|
||||
best = min(efforts, key=lambda e: e.elapsed_s)
|
||||
result.append({
|
||||
"segment": {
|
||||
"id": seg.id,
|
||||
"name": seg.name,
|
||||
"sport": seg.sport,
|
||||
"distance_m": seg.distance_m,
|
||||
},
|
||||
"best_elapsed_s": best.elapsed_s,
|
||||
"best_activity_id": best.activity_id,
|
||||
"effort_count": len(efforts),
|
||||
})
|
||||
result.sort(key=lambda x: x["segment"]["name"].lower())
|
||||
return JSONResponse(result)
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Strava integration endpoints (/api/strava/*)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
from bincio.serve.db import get_setting
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_strava_oauth_states: set[str] = set()
|
||||
|
||||
|
||||
@router.get("/api/strava/status")
|
||||
async def strava_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
cid, _ = deps._strava_creds(user.handle)
|
||||
if not cid:
|
||||
return JSONResponse({"configured": False, "connected": False, "last_sync": None})
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
from bincio.extract.strava_api import load_token
|
||||
token = load_token(dd)
|
||||
return JSONResponse({
|
||||
"configured": True,
|
||||
"connected": token is not None,
|
||||
"last_sync": token.get("last_sync_at") if token else None,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/strava/disconnect")
|
||||
async def strava_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
"""Remove the stored Strava token, forcing a fresh OAuth on next connect."""
|
||||
user = deps._require_user(bincio_session)
|
||||
token_path = deps._get_data_dir() / user.handle / "strava_token.json"
|
||||
token_path.unlink(missing_ok=True)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/api/strava/reset")
|
||||
async def strava_reset(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
"""Reset last_sync_at so the next sync re-fetches from a chosen point.
|
||||
|
||||
mode=soft — set to the started_at of the most recent activity on disk
|
||||
(next sync only fetches activities newer than the last known one)
|
||||
mode=hard — clear last_sync_at entirely
|
||||
(next sync re-downloads full Strava history, skipping existing files)
|
||||
"""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
from bincio.extract.strava_api import load_token, save_token
|
||||
token = load_token(dd)
|
||||
if token is None:
|
||||
raise HTTPException(400, "Not connected to Strava")
|
||||
|
||||
body = await request.json()
|
||||
mode = body.get("mode", "soft")
|
||||
|
||||
if mode == "hard":
|
||||
token.pop("last_sync_at", None)
|
||||
save_token(dd, token)
|
||||
return JSONResponse({"ok": True, "mode": "hard", "last_sync_at": None})
|
||||
|
||||
# soft: find the most recent started_at across the user's merged index
|
||||
from datetime import datetime, timezone
|
||||
last_ts: int | None = None
|
||||
for index_path in [dd / "_merged" / "index.json", dd / "index.json"]:
|
||||
if not index_path.exists():
|
||||
continue
|
||||
try:
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
started_ats = [
|
||||
a.get("started_at") for a in index_data.get("activities", [])
|
||||
if a.get("started_at")
|
||||
]
|
||||
if started_ats:
|
||||
latest = max(started_ats)
|
||||
dt = datetime.fromisoformat(latest.replace("Z", "+00:00"))
|
||||
last_ts = int(dt.astimezone(timezone.utc).timestamp())
|
||||
break
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
|
||||
if last_ts is None:
|
||||
token.pop("last_sync_at", None)
|
||||
else:
|
||||
token["last_sync_at"] = last_ts
|
||||
save_token(dd, token)
|
||||
return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts})
|
||||
|
||||
|
||||
@router.get("/api/strava/auth-url")
|
||||
async def strava_auth_url(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
cid, _ = deps._strava_creds(user.handle)
|
||||
if not cid:
|
||||
raise HTTPException(400, "Strava client ID not configured on this server")
|
||||
state = secrets.token_urlsafe(16)
|
||||
_strava_oauth_states.add(state)
|
||||
if deps.public_url:
|
||||
redirect_uri = deps.public_url.rstrip("/") + "/api/strava/callback"
|
||||
else:
|
||||
redirect_uri = str(request.url_for("strava_callback"))
|
||||
from bincio.extract.strava_api import auth_url
|
||||
return JSONResponse({"url": auth_url(cid, redirect_uri, state=state)})
|
||||
|
||||
|
||||
@router.get("/api/strava/callback", name="strava_callback")
|
||||
async def strava_callback(
|
||||
request: Request,
|
||||
code: str = "",
|
||||
error: str = "",
|
||||
state: str = "",
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> RedirectResponse:
|
||||
site_origin = deps.public_url.rstrip("/") if deps.public_url else str(request.base_url).rstrip("/")
|
||||
if error or not code:
|
||||
return RedirectResponse(f"{site_origin}/?strava=error")
|
||||
if state not in _strava_oauth_states:
|
||||
return RedirectResponse(f"{site_origin}/?strava=error")
|
||||
_strava_oauth_states.discard(state)
|
||||
user = deps._current_user(bincio_session)
|
||||
if not user:
|
||||
return RedirectResponse(f"{site_origin}/?strava=error")
|
||||
cid, csec = deps._strava_creds(user.handle)
|
||||
if not cid or not csec:
|
||||
return RedirectResponse(f"{site_origin}/?strava=error")
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
from bincio.extract.strava_api import StravaError, exchange_code, save_token
|
||||
try:
|
||||
token = exchange_code(cid, csec, code)
|
||||
except StravaError:
|
||||
return RedirectResponse(f"{site_origin}/?strava=error")
|
||||
save_token(dd, token)
|
||||
return RedirectResponse(f"{site_origin}/?strava=connected")
|
||||
|
||||
|
||||
@router.get("/api/strava/sync/stream")
|
||||
async def serve_strava_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse:
|
||||
"""SSE endpoint — streams per-activity progress then a final summary event."""
|
||||
user = deps._require_user(bincio_session)
|
||||
cid, csec = deps._strava_creds(user.handle)
|
||||
if not cid or not csec:
|
||||
raise HTTPException(400, "Strava not configured on this server")
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
store_orig_setting = get_setting(deps._get_db(), "store_originals")
|
||||
store_orig = store_orig_setting == "true"
|
||||
originals_dir = (dd / "originals" / "strava") if store_orig else None
|
||||
if originals_dir:
|
||||
originals_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from bincio.extract.ingest import strava_sync_iter
|
||||
|
||||
def event_stream():
|
||||
try:
|
||||
for event in strava_sync_iter(dd, cid, csec, originals_dir):
|
||||
if event["type"] == "done":
|
||||
tasks._trigger_rebuild(user.handle) # start before client closes connection
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
except Exception as exc:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/strava/import-gear")
|
||||
async def serve_strava_import_gear(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
"""One-time backfill: scan stored Strava originals for gear_ids, fetch names, populate gear registry."""
|
||||
user = deps._require_user(bincio_session)
|
||||
cid, csec = deps._strava_creds(user.handle)
|
||||
if not cid or not csec:
|
||||
raise HTTPException(400, "Strava not configured on this server")
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
originals_dir = dd / "originals" / "strava"
|
||||
if not originals_dir.exists():
|
||||
return JSONResponse({"ok": True, "gear_added": 0, "activities_updated": 0, "message": "No stored originals found"})
|
||||
|
||||
import contextlib
|
||||
import uuid
|
||||
|
||||
from bincio.extract.strava_api import StravaError, ensure_fresh, fetch_gear
|
||||
from bincio.render.merge import merge_one
|
||||
from bincio.serve.routers.gear import _load as _gear_load, _save as _gear_save
|
||||
|
||||
try:
|
||||
token = ensure_fresh(dd, cid, csec)
|
||||
except StravaError as e:
|
||||
raise HTTPException(502, str(e))
|
||||
|
||||
registry = _gear_load(dd)
|
||||
known_strava_ids = {g.get("strava_id") for g in registry if g.get("strava_id")}
|
||||
|
||||
# Collect all unique gear_ids from originals
|
||||
gear_id_to_activities: dict[str, list[str]] = {}
|
||||
for orig_path in originals_dir.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(orig_path.read_text(encoding="utf-8"))
|
||||
gear_id = (data.get("meta") or {}).get("gear_id") or ""
|
||||
if gear_id:
|
||||
gear_id_to_activities.setdefault(gear_id, []).append(orig_path.stem)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
gear_added = 0
|
||||
activities_updated = 0
|
||||
|
||||
for gear_id, activity_ids in gear_id_to_activities.items():
|
||||
if gear_id in known_strava_ids:
|
||||
gear_name = next(g["name"] for g in registry if g.get("strava_id") == gear_id)
|
||||
else:
|
||||
details = fetch_gear(token["access_token"], gear_id)
|
||||
gear_name = details.get("name") or ""
|
||||
if not gear_name:
|
||||
continue
|
||||
gear_type = "shoes" if gear_id.startswith("g") else "bike"
|
||||
new_item: dict = {"id": str(uuid.uuid4()), "name": gear_name, "type": gear_type, "retired": False, "strava_id": gear_id}
|
||||
registry.append(new_item)
|
||||
known_strava_ids.add(gear_id)
|
||||
gear_added += 1
|
||||
|
||||
# Backfill: write sidecar for each activity that has no gear set yet
|
||||
import yaml as _yaml
|
||||
edits_dir = dd / "edits"
|
||||
edits_dir.mkdir(exist_ok=True)
|
||||
for activity_id in activity_ids:
|
||||
activity_json = dd / "activities" / f"{activity_id}.json"
|
||||
if not activity_json.exists():
|
||||
continue
|
||||
try:
|
||||
act = json.loads(activity_json.read_text(encoding="utf-8"))
|
||||
if act.get("gear"):
|
||||
continue # already has gear
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
sidecar = edits_dir / f"{activity_id}.md"
|
||||
fm: dict = {}
|
||||
body = ""
|
||||
if sidecar.exists():
|
||||
try:
|
||||
text = sidecar.read_text(encoding="utf-8")
|
||||
import re as _re
|
||||
parts = _re.split(r"^---[ \t]*$", text, maxsplit=2, flags=_re.MULTILINE)
|
||||
if len(parts) >= 3:
|
||||
fm = _yaml.safe_load(parts[1]) or {}
|
||||
body = parts[2].strip()
|
||||
except Exception:
|
||||
pass
|
||||
if fm.get("gear"):
|
||||
continue # sidecar already sets gear
|
||||
fm["gear"] = gear_name
|
||||
fm_text = _yaml.safe_dump(fm, default_flow_style=False, allow_unicode=True).strip()
|
||||
content = f"---\n{fm_text}\n---\n"
|
||||
if body:
|
||||
content += f"\n{body}\n"
|
||||
sidecar.write_text(content, encoding="utf-8")
|
||||
with contextlib.suppress(Exception):
|
||||
merge_one(dd, activity_id)
|
||||
activities_updated += 1
|
||||
|
||||
_gear_save(dd, registry)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse({"ok": True, "gear_added": gear_added, "activities_updated": activities_updated})
|
||||
|
||||
|
||||
@router.post("/api/strava/sync")
|
||||
async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
cid, csec = deps._strava_creds(user.handle)
|
||||
if not cid or not csec:
|
||||
raise HTTPException(400, "Strava not configured on this server")
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
store_orig_setting = get_setting(deps._get_db(), "store_originals")
|
||||
store_orig = store_orig_setting == "true"
|
||||
originals_dir = (dd / "originals" / "strava") if store_orig else None
|
||||
if originals_dir:
|
||||
originals_dir.mkdir(parents=True, exist_ok=True)
|
||||
from bincio.edit.ops import run_strava_sync
|
||||
try:
|
||||
result = run_strava_sync(dd, cid, csec, originals_dir=originals_dir)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(502, str(e))
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse(result)
|
||||
@@ -0,0 +1,506 @@
|
||||
"""File upload endpoints (/api/upload/*)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Cookie, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
|
||||
log = logging.getLogger("bincio.serve")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"}
|
||||
|
||||
|
||||
def _file_suffix(name: str) -> str:
|
||||
"""Return the effective suffix, including .gz double-extension."""
|
||||
p = Path(name.lower())
|
||||
if p.suffix == ".gz":
|
||||
return p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz"
|
||||
return p.suffix
|
||||
|
||||
|
||||
def _upsert_index_summary(user_dir: Path, activity_id: str, activity: dict, geojson: Optional[dict] = None) -> None:
|
||||
"""Add or update an activity summary in user_dir/index.json.
|
||||
|
||||
Called after writing BAS activity files so that merge_all can include the
|
||||
activity in year shards. Without this, uploaded activities exist on disk
|
||||
but never appear in the browser feed.
|
||||
"""
|
||||
# Build preview coords from geojson if available ([lat, lng] order)
|
||||
preview: Optional[list] = None
|
||||
if geojson:
|
||||
try:
|
||||
coords = geojson.get("geometry", {}).get("coordinates", [])
|
||||
if coords:
|
||||
step = max(1, len(coords) // 9)
|
||||
preview = [[c[1], c[0]] for c in coords[::step]][:9]
|
||||
except (TypeError, IndexError, AttributeError):
|
||||
pass
|
||||
|
||||
has_track = (user_dir / "activities" / f"{activity_id}.geojson").exists()
|
||||
summary = {
|
||||
"id": activity_id,
|
||||
"title": activity.get("title", activity_id),
|
||||
"sport": activity.get("sport"),
|
||||
"sub_sport": activity.get("sub_sport"),
|
||||
"started_at": activity.get("started_at"),
|
||||
"distance_m": activity.get("distance_m"),
|
||||
"duration_s": activity.get("duration_s"),
|
||||
"moving_time_s": activity.get("moving_time_s"),
|
||||
"elevation_gain_m": activity.get("elevation_gain_m"),
|
||||
"avg_speed_kmh": activity.get("avg_speed_kmh"),
|
||||
"max_speed_kmh": activity.get("max_speed_kmh"),
|
||||
"avg_hr_bpm": activity.get("avg_hr_bpm"),
|
||||
"max_hr_bpm": activity.get("max_hr_bpm"),
|
||||
"avg_cadence_rpm": activity.get("avg_cadence_rpm"),
|
||||
"avg_power_w": activity.get("avg_power_w"),
|
||||
"mmp": activity.get("mmp"),
|
||||
"best_efforts": activity.get("best_efforts"),
|
||||
"best_climb_m": activity.get("best_climb_m"),
|
||||
"source": activity.get("source"),
|
||||
"privacy": activity.get("privacy", "public"),
|
||||
"detail_url": f"activities/{activity_id}.json",
|
||||
"track_url": f"activities/{activity_id}.geojson" if has_track else None,
|
||||
"preview_coords": preview,
|
||||
}
|
||||
|
||||
index_path = user_dir / "index.json"
|
||||
if index_path.exists():
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
index_data = {
|
||||
"bas_version": "1.0",
|
||||
"owner": {"handle": user_dir.name},
|
||||
"generated_at": None,
|
||||
"activities": [],
|
||||
}
|
||||
existing = {a["id"]: a for a in index_data.get("activities", [])}
|
||||
existing[activity_id] = summary
|
||||
index_data["activities"] = sorted(existing.values(), key=lambda a: a.get("started_at", ""), reverse=True)
|
||||
index_path.write_text(json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
@router.post("/api/upload/bas")
|
||||
async def upload_bas_activity(
|
||||
request: Request,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Accept a pre-extracted BAS activity JSON from the mobile app.
|
||||
|
||||
Body (JSON):
|
||||
activity – full BAS activity dict (required, must have 'id')
|
||||
timeseries – timeseries dict (optional)
|
||||
geojson – GeoJSON dict (optional)
|
||||
|
||||
Returns:
|
||||
{"ok": true, "id": "...", "status": "imported" | "duplicate"}
|
||||
"""
|
||||
user = deps._require_auth(request, bincio_session)
|
||||
body = await request.json()
|
||||
|
||||
activity = body.get("activity")
|
||||
if not activity or not activity.get("id"):
|
||||
raise HTTPException(400, "Missing activity.id")
|
||||
|
||||
activity_id = str(activity["id"])
|
||||
deps._check_id(activity_id)
|
||||
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
acts_dir = user_dir / "activities"
|
||||
acts_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
out = acts_dir / f"{activity_id}.json"
|
||||
if out.exists():
|
||||
return JSONResponse({"ok": True, "id": activity_id, "status": "duplicate"})
|
||||
|
||||
out.write_text(json.dumps(activity, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
if body.get("timeseries"):
|
||||
ts_path = acts_dir / f"{activity_id}.timeseries.json"
|
||||
if not ts_path.exists():
|
||||
ts_path.write_text(json.dumps(body["timeseries"], ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
geojson_body: Optional[dict] = body.get("geojson") or None
|
||||
if geojson_body:
|
||||
gj_path = acts_dir / f"{activity_id}.geojson"
|
||||
if not gj_path.exists():
|
||||
gj_path.write_text(json.dumps(geojson_body, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
_upsert_index_summary(user_dir, activity_id, activity, geojson_body)
|
||||
|
||||
try:
|
||||
from bincio.render.merge import merge_one, write_combined_feed
|
||||
merge_one(user_dir, activity_id)
|
||||
write_combined_feed(deps._get_data_dir())
|
||||
except Exception as exc:
|
||||
log.warning("upload/bas[%s]: merge/feed failed (non-fatal): %s", user.handle, exc)
|
||||
|
||||
log.info("upload/bas[%s]: imported %s", user.handle, activity_id)
|
||||
return JSONResponse({"ok": True, "id": activity_id, "status": "imported"})
|
||||
|
||||
|
||||
@router.post("/api/upload/raw")
|
||||
async def upload_raw_activity(
|
||||
request: Request,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Accept a raw FIT/GPX file (base64-encoded) from the mobile app, extract it
|
||||
server-side, store it in the user's activity library, and return the full
|
||||
extracted data so the mobile can cache it locally.
|
||||
|
||||
Used when the device WebView is too old to run Pyodide (e.g. Karoo / Chrome <69).
|
||||
|
||||
Body (JSON):
|
||||
filename – original filename (used only to determine file extension)
|
||||
base64 – base64-encoded raw file bytes
|
||||
|
||||
Auth: Authorization: Bearer <token>
|
||||
|
||||
Returns:
|
||||
{"ok": true, "id": "...", "detail": {...}, "timeseries": {...}|null,
|
||||
"geojson": {...}|null, "source_hash": "<sha256-hex>"}
|
||||
"""
|
||||
import base64 as _b64
|
||||
import hashlib
|
||||
import shutil
|
||||
|
||||
user = deps._require_auth(request, bincio_session)
|
||||
|
||||
body = await request.json()
|
||||
filename_hint: str = body.get("filename") or "activity.fit"
|
||||
b64: str = body.get("base64") or ""
|
||||
user_title: Optional[str] = body.get("user_title") or None
|
||||
if not b64:
|
||||
raise HTTPException(400, "Missing base64 field")
|
||||
|
||||
try:
|
||||
raw = _b64.b64decode(b64)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid base64 encoding")
|
||||
|
||||
source_hash = hashlib.sha256(raw).hexdigest()
|
||||
|
||||
suffix = Path(filename_hint).suffix or ".fit"
|
||||
tmp_in = Path(f"/tmp/bincio_raw_{uuid.uuid4()}{suffix}")
|
||||
tmp_out = Path(f"/tmp/bincio_out_{uuid.uuid4()}")
|
||||
try:
|
||||
tmp_in.write_bytes(raw)
|
||||
tmp_out.mkdir()
|
||||
|
||||
from bincio.extract.parsers.factory import parse_file
|
||||
from bincio.extract.metrics import compute
|
||||
from bincio.extract.writer import make_activity_id, write_activity
|
||||
from bincio.extract.timeseries import build_timeseries
|
||||
|
||||
activity = parse_file(tmp_in)
|
||||
metrics = compute(activity)
|
||||
write_activity(activity, metrics, tmp_out, privacy="public", rdp_epsilon=0.0001)
|
||||
act_id = make_activity_id(activity)
|
||||
|
||||
acts_tmp = tmp_out / "activities"
|
||||
detail_path = acts_tmp / f"{act_id}.json"
|
||||
ts_path = acts_tmp / f"{act_id}.timeseries.json"
|
||||
geojson_path = acts_tmp / f"{act_id}.geojson"
|
||||
|
||||
if not ts_path.exists():
|
||||
ts_data = build_timeseries(activity.points, activity.started_at, "public")
|
||||
if ts_data.get("t"):
|
||||
ts_path.write_text(json.dumps(ts_data))
|
||||
|
||||
detail = json.loads(detail_path.read_text())
|
||||
timeseries = json.loads(ts_path.read_text()) if ts_path.exists() else None
|
||||
geojson = json.loads(geojson_path.read_text()) if geojson_path.exists() else None
|
||||
|
||||
# Also store on the server so the activity appears in the user's feed.
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
acts_dir = user_dir / "activities"
|
||||
acts_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = acts_dir / f"{act_id}.json"
|
||||
if not out.exists():
|
||||
out.write_text(json.dumps(detail, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
if timeseries and not (acts_dir / f"{act_id}.timeseries.json").exists():
|
||||
(acts_dir / f"{act_id}.timeseries.json").write_text(json.dumps(timeseries), encoding="utf-8")
|
||||
if geojson and not (acts_dir / f"{act_id}.geojson").exists():
|
||||
(acts_dir / f"{act_id}.geojson").write_text(json.dumps(geojson), encoding="utf-8")
|
||||
|
||||
_upsert_index_summary(user_dir, act_id, detail, geojson)
|
||||
|
||||
if user_title:
|
||||
import yaml as _yaml
|
||||
edits_dir = user_dir / "edits"
|
||||
edits_dir.mkdir(parents=True, exist_ok=True)
|
||||
(edits_dir / f"{act_id}.md").write_text(
|
||||
f"---\n{_yaml.dump({'title': user_title}, allow_unicode=True)}---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
log.warning("upload/raw[%s]: extraction failed: %s", user.handle, exc)
|
||||
raise HTTPException(422, f"Could not extract activity: {exc}") from exc
|
||||
finally:
|
||||
tmp_in.unlink(missing_ok=True)
|
||||
shutil.rmtree(tmp_out, ignore_errors=True)
|
||||
|
||||
# Merge and update feed — best effort; a race or transient FS error here must
|
||||
# not turn a successful extraction into a 422 (the file is on disk; the mobile
|
||||
# would retry indefinitely and the activity would never be marked synced).
|
||||
try:
|
||||
from bincio.render.merge import merge_one, write_combined_feed
|
||||
merge_one(user_dir, act_id)
|
||||
write_combined_feed(deps._get_data_dir())
|
||||
except Exception as exc:
|
||||
log.warning("upload/raw[%s]: merge/feed failed (non-fatal): %s", user.handle, exc)
|
||||
|
||||
log.info("upload/raw[%s]: imported %s", user.handle, act_id)
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"id": act_id,
|
||||
"detail": detail,
|
||||
"timeseries": timeseries,
|
||||
"geojson": geojson,
|
||||
"source_hash": source_hash,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/upload")
|
||||
async def upload_activity(
|
||||
files: list[UploadFile] = File(...),
|
||||
store_original: bool = Form(False),
|
||||
overwrite: bool = Form(False),
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> StreamingResponse:
|
||||
"""Accept FIT/GPX/TCX files and/or activities.csv; stream SSE progress while processing.
|
||||
|
||||
activities.csv (Strava export format) can be included in the batch to:
|
||||
- Enrich activity files in the same batch (matched by filename)
|
||||
- Retroactively update sidecars for existing activities (matched by strava_id)
|
||||
|
||||
SSE events:
|
||||
{"type": "progress", "n": N, "total": T, "name": "...", "status": "imported"|"overwritten"|"duplicate"|"error"}
|
||||
{"type": "csv", "updates": N} -- only when CSV was included
|
||||
{"type": "done", "added": N, "csv_updates": N, "duplicates": N, "overwritten": N, "errors": N}
|
||||
"""
|
||||
from bincio.extract.ingest import ingest_parsed
|
||||
from bincio.extract.parsers.factory import parse_file
|
||||
from bincio.extract.writer import make_activity_id
|
||||
from bincio.render.merge import merge_all
|
||||
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
staging = dd / "_uploads"
|
||||
staging.mkdir(exist_ok=True)
|
||||
|
||||
# Read all files into memory now (async), then process synchronously in the generator
|
||||
csv_bytes_list: list[bytes] = []
|
||||
activity_items: list[tuple[str, bytes]] = [] # (original_filename, bytes)
|
||||
|
||||
for f in files:
|
||||
fname = Path(f.filename or "").name
|
||||
raw = await f.read()
|
||||
if fname.lower().endswith(".csv"):
|
||||
csv_bytes_list.append(raw)
|
||||
else:
|
||||
activity_items.append((fname, raw))
|
||||
|
||||
# Build metadata from the first CSV
|
||||
metadata = None
|
||||
if csv_bytes_list:
|
||||
from bincio.extract.strava_csv import StravaMetadata
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp:
|
||||
tmp.write(csv_bytes_list[0])
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
metadata = StravaMetadata(tmp_path)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
total_files = len(activity_items)
|
||||
job_id = tasks._job_start(user.handle, total_files) if total_files > 0 else None
|
||||
|
||||
def event_stream():
|
||||
added = 0
|
||||
overwritten = 0
|
||||
duplicates = 0
|
||||
errors = 0
|
||||
any_added = False
|
||||
|
||||
for n, (name, contents) in enumerate(activity_items, 1):
|
||||
if job_id:
|
||||
tasks._job_update(job_id, n - 1, name)
|
||||
|
||||
suffix = _file_suffix(name)
|
||||
if suffix not in _SUPPORTED_SUFFIXES:
|
||||
errors += 1
|
||||
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'unsupported type'})}\n\n"
|
||||
continue
|
||||
|
||||
if len(contents) > 50 * 1024 * 1024:
|
||||
errors += 1
|
||||
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'file too large'})}\n\n"
|
||||
continue
|
||||
|
||||
staged = staging / name
|
||||
staged.write_bytes(contents)
|
||||
kept = False
|
||||
try:
|
||||
activity = parse_file(staged)
|
||||
if metadata is not None:
|
||||
metadata.enrich(name, activity)
|
||||
activity_id = make_activity_id(activity)
|
||||
was_overwrite = False
|
||||
if (dd / "activities" / f"{activity_id}.json").exists():
|
||||
if not overwrite:
|
||||
duplicates += 1
|
||||
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'duplicate'})}\n\n"
|
||||
continue
|
||||
# Overwrite: delete existing files before re-ingesting.
|
||||
for ext in (".json", ".geojson", ".timeseries.json"):
|
||||
(dd / "activities" / f"{activity_id}{ext}").unlink(missing_ok=True)
|
||||
# Remove stale summary from index so ingest_parsed writes a clean one
|
||||
index_path = dd / "index.json"
|
||||
if index_path.exists():
|
||||
idx = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
idx["activities"] = [a for a in idx.get("activities", []) if a.get("id") != activity_id]
|
||||
index_path.write_text(json.dumps(idx, indent=2, ensure_ascii=False))
|
||||
# Remove from dedup hash cache so the new file isn't blocked
|
||||
cache_path = dd / ".bincio_cache.json"
|
||||
if cache_path.exists():
|
||||
try:
|
||||
cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
cache.pop(activity_id, None)
|
||||
cache_path.write_text(json.dumps(cache, ensure_ascii=False))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
# Remove merged copies (merge_all will regenerate them after ingest)
|
||||
merged_acts = dd / "_merged" / "activities"
|
||||
if merged_acts.exists():
|
||||
for ext in (".json", ".geojson", ".timeseries.json"):
|
||||
p = merged_acts / f"{activity_id}{ext}"
|
||||
if p.exists() or p.is_symlink():
|
||||
p.unlink(missing_ok=True)
|
||||
was_overwrite = True
|
||||
ingest_parsed(activity, dd, privacy="public")
|
||||
if store_original:
|
||||
originals_dir = dd / "originals"
|
||||
originals_dir.mkdir(exist_ok=True)
|
||||
staged.rename(originals_dir / name)
|
||||
kept = True
|
||||
if was_overwrite:
|
||||
overwritten += 1
|
||||
else:
|
||||
added += 1
|
||||
any_added = True
|
||||
status = 'overwritten' if was_overwrite else 'imported'
|
||||
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': status})}\n\n"
|
||||
except Exception as exc:
|
||||
errors += 1
|
||||
log.error("upload[%s]: failed to process %s: %s", user.handle, name, exc, exc_info=True)
|
||||
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': str(exc)})}\n\n"
|
||||
finally:
|
||||
if not kept:
|
||||
staged.unlink(missing_ok=True)
|
||||
|
||||
# Retroactively apply CSV metadata to existing activities
|
||||
csv_updates = 0
|
||||
if metadata is not None:
|
||||
from bincio.extract.strava_csv import apply_csv_to_data_dir
|
||||
csv_updates = apply_csv_to_data_dir(dd, metadata)
|
||||
if csv_updates:
|
||||
yield f"data: {json.dumps({'type': 'csv', 'updates': csv_updates})}\n\n"
|
||||
|
||||
if any_added or csv_updates:
|
||||
merge_all(dd)
|
||||
if any_added:
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
|
||||
yield f"data: {json.dumps({'type': 'done', 'added': added, 'overwritten': overwritten, 'csv_updates': csv_updates, 'duplicates': duplicates, 'errors': errors})}\n\n"
|
||||
|
||||
if job_id:
|
||||
tasks._job_finish(job_id)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/upload/strava-zip")
|
||||
async def upload_strava_zip(
|
||||
file: UploadFile = File(...),
|
||||
private: str = Form(default="false"),
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> StreamingResponse:
|
||||
"""Accept a Strava bulk export ZIP and stream SSE progress while processing.
|
||||
|
||||
The ZIP is written to a temp file, processed activity-by-activity, then deleted.
|
||||
Originals are never kept — the UI informs the user of this upfront.
|
||||
"""
|
||||
user = deps._require_user(bincio_session)
|
||||
if not file.filename or not file.filename.lower().endswith(".zip"):
|
||||
raise HTTPException(400, "Please upload a .zip file")
|
||||
|
||||
privacy = "unlisted" if private.lower() in ("true", "1", "yes") else "public"
|
||||
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
import tempfile
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
|
||||
zip_path = Path(tmp.name)
|
||||
try:
|
||||
while chunk := await file.read(1024 * 1024): # 1 MB chunks
|
||||
tmp.write(chunk)
|
||||
finally:
|
||||
tmp.close()
|
||||
|
||||
from bincio.extract.strava_zip import strava_zip_iter
|
||||
from bincio.render.merge import merge_all
|
||||
|
||||
log.info("strava-zip[%s]: received %s, privacy=%s", user.handle, file.filename, privacy)
|
||||
|
||||
def event_stream():
|
||||
any_imported = False
|
||||
imported_count = 0
|
||||
error_count = 0
|
||||
try:
|
||||
for event in strava_zip_iter(zip_path, dd, privacy=privacy):
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
if event.get("type") == "progress":
|
||||
status = event.get("status")
|
||||
if status == "imported":
|
||||
any_imported = True
|
||||
imported_count += 1
|
||||
elif status == "error":
|
||||
error_count += 1
|
||||
log.warning("strava-zip[%s]: error on %s: %s",
|
||||
user.handle, event.get("name"), event.get("detail", ""))
|
||||
if event.get("type") == "done":
|
||||
log.info("strava-zip[%s]: done — imported=%d errors=%d",
|
||||
user.handle, imported_count, error_count)
|
||||
if any_imported:
|
||||
merge_all(dd)
|
||||
try:
|
||||
from bincio.explore import bake_tracks
|
||||
bake_tracks(user.handle, deps._get_data_dir())
|
||||
except Exception as exc:
|
||||
log.warning("strava-zip[%s]: bake_tracks failed (non-fatal): %s", user.handle, exc)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
except Exception as exc:
|
||||
log.error("strava-zip[%s]: fatal error: %s", user.handle, exc, exc_info=True)
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
|
||||
finally:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
+38
-2480
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
"""Background workers and job tracker for bincio.serve."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from bincio.serve import deps
|
||||
|
||||
log = logging.getLogger("bincio.serve")
|
||||
|
||||
# ── Job tracker ───────────────────────────────────────────────────────────────
|
||||
|
||||
_jobs_lock = threading.Lock()
|
||||
_active_jobs: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _job_start(user_handle: str, total_files: int) -> str:
|
||||
job_id = uuid.uuid4().hex[:8]
|
||||
with _jobs_lock:
|
||||
_active_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user": user_handle,
|
||||
"started_at": int(time.time()),
|
||||
"total": total_files,
|
||||
"done": 0,
|
||||
"current": "",
|
||||
}
|
||||
return job_id
|
||||
|
||||
|
||||
def _job_update(job_id: str, done: int, current: str) -> None:
|
||||
with _jobs_lock:
|
||||
if job_id in _active_jobs:
|
||||
_active_jobs[job_id]["done"] = done
|
||||
_active_jobs[job_id]["current"] = current
|
||||
|
||||
|
||||
def _job_finish(job_id: str) -> None:
|
||||
with _jobs_lock:
|
||||
_active_jobs.pop(job_id, None)
|
||||
|
||||
|
||||
# ── Post-write rebuild ────────────────────────────────────────────────────────
|
||||
|
||||
_rebuild_lock = threading.Lock()
|
||||
_site_rebuild_event = threading.Event()
|
||||
|
||||
_low_priority = {"preexec_fn": lambda: os.nice(19)}
|
||||
|
||||
|
||||
def _site_rebuild_worker() -> None:
|
||||
"""Single background thread: debounced Astro build + rsync after uploads.
|
||||
|
||||
Waits for _site_rebuild_event, sleeps 60 s to let upload bursts settle,
|
||||
then runs one full build. Uploads that arrive during the build set the
|
||||
event again, so a follow-up build starts after the current one finishes.
|
||||
"""
|
||||
_webroot = str(deps.webroot)
|
||||
_data_dir = str(deps.data_dir)
|
||||
_site_dir = str(deps.site_dir)
|
||||
uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
|
||||
while True:
|
||||
_site_rebuild_event.wait()
|
||||
_site_rebuild_event.clear()
|
||||
time.sleep(60)
|
||||
_site_rebuild_event.clear()
|
||||
log.info("site-rebuild: starting full build + rsync to %s", _webroot)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[uv, "run", "bincio", "render",
|
||||
"--data-dir", _data_dir,
|
||||
"--site-dir", _site_dir],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
**_low_priority,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log.error("site-rebuild: build failed (rc=%d):\n%s\n%s",
|
||||
result.returncode, result.stdout, result.stderr)
|
||||
continue
|
||||
dist_data = Path(_site_dir) / "dist" / "data"
|
||||
if dist_data.exists():
|
||||
shutil.rmtree(dist_data)
|
||||
rsync = subprocess.run(
|
||||
["rsync", "-a", "--delete", "--exclude=data/",
|
||||
f"{_site_dir}/dist/", _webroot + "/"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
**_low_priority,
|
||||
)
|
||||
if rsync.returncode != 0:
|
||||
log.error("site-rebuild: rsync failed (rc=%d):\n%s\n%s",
|
||||
rsync.returncode, rsync.stdout, rsync.stderr)
|
||||
else:
|
||||
log.info("site-rebuild: rsync done, generating OG images")
|
||||
og_script = Path(_site_dir).parent / "scripts" / "generate_og_images.py"
|
||||
if og_script.exists() and deps.webroot is not None:
|
||||
og = subprocess.run(
|
||||
[uv, "run", "python3", str(og_script),
|
||||
"--data-dir", _data_dir,
|
||||
"--www-root", _webroot],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
preexec_fn=lambda: os.nice(19),
|
||||
)
|
||||
if og.returncode != 0:
|
||||
log.error("site-rebuild: og-images failed (rc=%d):\n%s\n%s",
|
||||
og.returncode, og.stdout, og.stderr)
|
||||
else:
|
||||
log.info("site-rebuild: done")
|
||||
except Exception:
|
||||
log.exception("site-rebuild: unexpected error")
|
||||
|
||||
|
||||
def _trigger_rebuild(handle: str) -> None:
|
||||
"""Merge sidecars for handle asynchronously; signal the site-rebuild worker."""
|
||||
if deps.site_dir is None:
|
||||
return
|
||||
if not deps._VALID_HANDLE.match(handle):
|
||||
return
|
||||
|
||||
uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
|
||||
_data_dir = str(deps.data_dir)
|
||||
_site_dir = str(deps.site_dir)
|
||||
_handle = handle
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
log.info("rebuild[%s]: merge-only", _handle)
|
||||
with _rebuild_lock:
|
||||
result = subprocess.run(
|
||||
[uv, "run", "bincio", "render",
|
||||
"--data-dir", _data_dir,
|
||||
"--site-dir", _site_dir,
|
||||
"--handle", _handle,
|
||||
"--no-build"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
**_low_priority,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log.error("rebuild[%s]: merge failed (rc=%d):\n%s\n%s",
|
||||
_handle, result.returncode, result.stdout, result.stderr)
|
||||
else:
|
||||
log.info("rebuild[%s]: merge done", _handle)
|
||||
if deps.webroot is not None:
|
||||
_site_rebuild_event.set()
|
||||
except Exception:
|
||||
log.exception("rebuild[%s]: unexpected error", _handle)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Shared image upload utilities used by both the edit and serve servers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ALLOWED_IMAGE_TYPES: frozenset[str] = frozenset({
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
})
|
||||
MAX_IMAGE_BYTES: int = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
def unique_image_name(directory: Path, filename: str) -> str:
|
||||
"""Return a filename that does not collide with existing files in directory."""
|
||||
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||
candidate = filename
|
||||
counter = 1
|
||||
while (directory / candidate).exists():
|
||||
candidate = f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Headless multi-user Garmin sync — designed to run as a systemd timer.
|
||||
|
||||
For each user directory that contains garmin_creds.json, tries to refresh
|
||||
the cached garth OAuth2 session (fast, no full login), falls back to a full
|
||||
email/password re-login only when the session has expired, then fetches and
|
||||
ingests new activities via garmin_sync_iter.
|
||||
|
||||
After all users are synced, optionally POSTs to a server endpoint to trigger
|
||||
an Astro rebuild + rsync.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
_STATUS_FILE = "_garmin_sync_status.json"
|
||||
|
||||
log = logging.getLogger("bincio.sync_garmin")
|
||||
|
||||
|
||||
def _write_status(
|
||||
user_dir: Path,
|
||||
status: str,
|
||||
imported: int,
|
||||
errors: int,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
payload: dict = {
|
||||
"status": status,
|
||||
"imported": imported,
|
||||
"errors": errors,
|
||||
"last_run": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if error_message is not None:
|
||||
payload["error_message"] = error_message
|
||||
try:
|
||||
(user_dir / _STATUS_FILE).write_text(
|
||||
json.dumps(payload, indent=2), encoding="utf-8"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _post_rebuild(url: str, secret: str | None) -> None:
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
if secret:
|
||||
headers["X-Sync-Secret"] = secret
|
||||
req = urllib.request.Request(url, data=b"{}", headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
log.info("rebuild triggered: HTTP %d", resp.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
log.error("rebuild trigger failed: HTTP %d %s", exc.code, exc.read().decode()[:100])
|
||||
except Exception as exc:
|
||||
log.error("rebuild trigger failed: %s", exc)
|
||||
|
||||
|
||||
def sync_user(data_dir: Path, user_dir: Path) -> tuple[int, int]:
|
||||
"""Sync one user's Garmin activities.
|
||||
|
||||
Returns (imported_count, error_count). Skips silently if no credentials.
|
||||
"""
|
||||
from bincio.extract.garmin_api import GarminError, has_credentials, get_client
|
||||
from bincio.extract.garmin_sync import run_garmin_sync
|
||||
|
||||
handle = user_dir.name
|
||||
|
||||
if not has_credentials(user_dir):
|
||||
log.debug("sync[%s]: no garmin_creds.json — skipped", handle)
|
||||
_write_status(user_dir, "no_credentials", 0, 0)
|
||||
return 0, 0
|
||||
|
||||
# Explicit auth step so we can distinguish auth failures from API failures.
|
||||
try:
|
||||
get_client(data_dir, user_dir)
|
||||
except GarminError as exc:
|
||||
log.error("sync[%s]: auth failed: %s", handle, exc)
|
||||
_write_status(user_dir, "auth_error", 0, 1, str(exc))
|
||||
return 0, 1
|
||||
|
||||
try:
|
||||
result = run_garmin_sync(data_dir, user_dir)
|
||||
except RuntimeError as exc:
|
||||
log.error("sync[%s]: sync failed: %s", handle, exc)
|
||||
_write_status(user_dir, "api_error", 0, 1, str(exc))
|
||||
return 0, 1
|
||||
|
||||
imported = result.get("imported", 0)
|
||||
error_count = result.get("error_count", 0)
|
||||
|
||||
log.info("sync[%s]: done — %d imported, %d errors", handle, imported, error_count)
|
||||
_write_status(user_dir, "ok", imported, error_count)
|
||||
return imported, error_count
|
||||
|
||||
|
||||
def sync_all(root_data_dir: Path) -> dict[str, tuple[int, int]]:
|
||||
"""Sync all users that have garmin_creds.json. Returns {handle: (imported, errors)}."""
|
||||
results: dict[str, tuple[int, int]] = {}
|
||||
cred_files = sorted(root_data_dir.glob("*/garmin_creds.json"))
|
||||
if not cred_files:
|
||||
log.info("sync_all: no users with garmin_creds.json found in %s", root_data_dir)
|
||||
return results
|
||||
log.info("sync_all: %d user(s) with Garmin credentials", len(cred_files))
|
||||
for cf in cred_files:
|
||||
user_dir = cf.parent
|
||||
handle = user_dir.name
|
||||
try:
|
||||
results[handle] = sync_user(root_data_dir, user_dir)
|
||||
except Exception as exc:
|
||||
log.exception("sync_all[%s]: unexpected error: %s", handle, exc)
|
||||
results[handle] = (0, -1)
|
||||
return results
|
||||
|
||||
|
||||
@click.command("sync-garmin")
|
||||
@click.option("--data-dir", "data_dir_str", required=True,
|
||||
help="Root data dir (parent of all user dirs, e.g. /var/bincio/data).")
|
||||
@click.option("--user", "only_user", default=None,
|
||||
help="Sync only this handle instead of all users.")
|
||||
@click.option("--rebuild-url", default=None, envvar="BINCIO_REBUILD_URL",
|
||||
help="POST here after a successful sync to trigger a site rebuild.")
|
||||
@click.option("--rebuild-secret", default=None, envvar="BINCIO_SYNC_SECRET",
|
||||
help="Value sent as X-Sync-Secret header to the rebuild endpoint.")
|
||||
def sync_garmin_cmd(
|
||||
data_dir_str: str,
|
||||
only_user: str | None,
|
||||
rebuild_url: str | None,
|
||||
rebuild_secret: str | None,
|
||||
) -> None:
|
||||
"""Headless Garmin sync for all users (designed for systemd timer).
|
||||
|
||||
Discovers every user directory that has garmin_creds.json, tries to
|
||||
resume the cached garth session (no full re-login if the token is still
|
||||
valid), fetches new activities, and optionally triggers a site rebuild.
|
||||
"""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s — %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||
)
|
||||
|
||||
root = Path(data_dir_str).expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
raise click.ClickException(f"Data dir not found: {root}")
|
||||
|
||||
if only_user:
|
||||
user_dir = root / only_user
|
||||
if not user_dir.is_dir():
|
||||
raise click.ClickException(f"User dir not found: {user_dir}")
|
||||
new_count, err_count = sync_user(root, user_dir)
|
||||
click.echo(f"{only_user}: {new_count} imported, {err_count} errors")
|
||||
total_new = new_count
|
||||
else:
|
||||
results = sync_all(root)
|
||||
total_new = sum(n for n, _ in results.values())
|
||||
total_err = sum(e for _, e in results.values())
|
||||
click.echo(
|
||||
f"Sync complete: {len(results)} users, "
|
||||
f"{total_new} new activities, {total_err} errors"
|
||||
)
|
||||
|
||||
if total_new > 0 and rebuild_url:
|
||||
_post_rebuild(rebuild_url, rebuild_secret)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Headless multi-user Strava sync — designed to run as a systemd timer.
|
||||
|
||||
For each user directory that contains both strava_token.json and
|
||||
strava_credentials.json, refreshes the token, fetches new activities,
|
||||
writes them to the user's data dir, merges sidecars, and updates the
|
||||
_strava_sync.json checkpoint.
|
||||
|
||||
After all users are synced, optionally POSTs to a server endpoint
|
||||
to trigger an Astro rebuild + rsync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
_TOKEN_FILE = "strava_token.json"
|
||||
_CREDS_FILE = "strava_credentials.json"
|
||||
_SYNC_FILE = "_strava_sync.json"
|
||||
_STATUS_FILE = "_strava_sync_status.json"
|
||||
|
||||
log = logging.getLogger("bincio.sync_strava")
|
||||
|
||||
|
||||
def _write_status(
|
||||
user_dir: Path,
|
||||
status: str,
|
||||
imported: int,
|
||||
errors: int,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
payload: dict = {
|
||||
"status": status,
|
||||
"imported": imported,
|
||||
"errors": errors,
|
||||
"last_run": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if error_message is not None:
|
||||
payload["error_message"] = error_message
|
||||
try:
|
||||
(user_dir / _STATUS_FILE).write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _load_creds(user_dir: Path) -> tuple[str, str] | None:
|
||||
"""Return (client_id, client_secret) from strava_credentials.json, or None."""
|
||||
p = user_dir / _CREDS_FILE
|
||||
if not p.exists():
|
||||
return None
|
||||
try:
|
||||
d = json.loads(p.read_text(encoding="utf-8"))
|
||||
cid = str(d.get("client_id", "")).strip()
|
||||
csec = str(d.get("client_secret", "")).strip()
|
||||
if cid and csec:
|
||||
return cid, csec
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def sync_user(user_dir: Path) -> tuple[int, int]:
|
||||
"""Sync one user's Strava activities.
|
||||
|
||||
Returns (new_count, error_count). Skips silently if no credentials.
|
||||
"""
|
||||
from bincio.extract.strava_api import ensure_fresh, fetch_activities, fetch_streams, StravaError
|
||||
from bincio.extract.metrics import compute
|
||||
from bincio.extract.writer import build_summary, make_activity_id, write_activity, write_index
|
||||
from bincio.import_.strava import _strava_to_parsed, _patch_from_summary
|
||||
from bincio.render.merge import merge_all
|
||||
|
||||
handle = user_dir.name
|
||||
|
||||
creds = _load_creds(user_dir)
|
||||
if creds is None:
|
||||
log.debug("sync[%s]: no strava_credentials.json — skipped", handle)
|
||||
_write_status(user_dir, "no_credentials", 0, 0)
|
||||
return 0, 0
|
||||
|
||||
client_id, client_secret = creds
|
||||
|
||||
try:
|
||||
token = ensure_fresh(user_dir, client_id, client_secret)
|
||||
except StravaError as exc:
|
||||
log.error("sync[%s]: token refresh failed: %s", handle, exc)
|
||||
_write_status(user_dir, "token_error", 0, 1, str(exc))
|
||||
return 0, 1
|
||||
|
||||
access_token = token["access_token"]
|
||||
|
||||
# Load incremental sync state
|
||||
sync_path = user_dir / _SYNC_FILE
|
||||
sync_state: dict = (
|
||||
json.loads(sync_path.read_text(encoding="utf-8"))
|
||||
if sync_path.exists() else {}
|
||||
)
|
||||
imported_ids: set[str] = set(sync_state.get("imported_ids", []))
|
||||
|
||||
after_ts: int | None = None
|
||||
if sync_state.get("last_sync"):
|
||||
last = datetime.fromisoformat(sync_state["last_sync"])
|
||||
# 1-hour overlap to catch activities saved late to Strava
|
||||
after_ts = int((last - timedelta(hours=1)).timestamp())
|
||||
|
||||
try:
|
||||
all_acts = fetch_activities(access_token, after=after_ts)
|
||||
except StravaError as exc:
|
||||
log.error("sync[%s]: fetch_activities failed: %s", handle, exc)
|
||||
_write_status(user_dir, "api_error", 0, 1, str(exc))
|
||||
return 0, 1
|
||||
|
||||
new_acts = [a for a in all_acts if str(a["id"]) not in imported_ids]
|
||||
log.info(
|
||||
"sync[%s]: %d new, %d already imported",
|
||||
handle, len(new_acts), len(all_acts) - len(new_acts),
|
||||
)
|
||||
if not new_acts:
|
||||
_write_status(user_dir, "ok", 0, 0)
|
||||
return 0, 0
|
||||
|
||||
# Load existing index so we can update it in place
|
||||
index_path = user_dir / "index.json"
|
||||
if index_path.exists():
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
index_data = {"owner": {"handle": handle}, "activities": []}
|
||||
owner = index_data.get("owner", {})
|
||||
summaries: dict[str, dict] = {s["id"]: s for s in index_data.get("activities", [])}
|
||||
|
||||
imported = 0
|
||||
errors = 0
|
||||
|
||||
for act in new_acts:
|
||||
strava_id = str(act["id"])
|
||||
try:
|
||||
try:
|
||||
streams = fetch_streams(access_token, int(strava_id))
|
||||
except StravaError as exc:
|
||||
if "404" in str(exc):
|
||||
# Activity exists in list but has no accessible streams (old/deleted GPS).
|
||||
# Still import it using summary-only stats via _patch_from_summary.
|
||||
streams = {}
|
||||
else:
|
||||
raise
|
||||
|
||||
# strava_api.fetch_streams returns {type: {"data": [...], ...}};
|
||||
# _strava_to_parsed (from import_/strava.py) expects {type: [...]}
|
||||
flat_streams = {
|
||||
k: v["data"] for k, v in streams.items()
|
||||
if isinstance(v, dict) and "data" in v
|
||||
}
|
||||
|
||||
parsed = _strava_to_parsed(act, flat_streams)
|
||||
metrics = compute(parsed)
|
||||
metrics = _patch_from_summary(metrics, act)
|
||||
act_id = make_activity_id(parsed)
|
||||
|
||||
# Respect Strava visibility: only_me → unlisted
|
||||
visibility = act.get("visibility") or ""
|
||||
privacy = "unlisted" if (act.get("private") or visibility == "only_me") else "public"
|
||||
|
||||
write_activity(parsed, metrics, user_dir, privacy=privacy)
|
||||
summaries[act_id] = build_summary(parsed, metrics, act_id, privacy)
|
||||
imported_ids.add(strava_id)
|
||||
imported += 1
|
||||
except Exception as exc:
|
||||
log.error("sync[%s]: activity %s failed: %s", handle, strava_id, exc)
|
||||
errors += 1
|
||||
|
||||
# Persist index and sync checkpoint
|
||||
write_index(list(summaries.values()), user_dir, owner)
|
||||
sync_state["imported_ids"] = sorted(imported_ids)
|
||||
sync_state["last_sync"] = datetime.now(timezone.utc).isoformat()
|
||||
sync_path.write_text(json.dumps(sync_state, indent=2), encoding="utf-8")
|
||||
|
||||
# Merge sidecars so _merged/ reflects any edits
|
||||
merge_all(user_dir)
|
||||
|
||||
log.info("sync[%s]: done — %d imported, %d errors", handle, imported, errors)
|
||||
_write_status(user_dir, "ok", imported, errors)
|
||||
return imported, errors
|
||||
|
||||
|
||||
def sync_all(root_data_dir: Path) -> dict[str, tuple[int, int]]:
|
||||
"""Sync all users that have a strava_token.json. Returns {handle: (new, errors)}."""
|
||||
results: dict[str, tuple[int, int]] = {}
|
||||
token_files = sorted(root_data_dir.glob("*/strava_token.json"))
|
||||
if not token_files:
|
||||
log.info("sync_all: no users with strava_token.json found in %s", root_data_dir)
|
||||
return results
|
||||
log.info("sync_all: %d user(s) with Strava token", len(token_files))
|
||||
for tf in token_files:
|
||||
user_dir = tf.parent
|
||||
handle = user_dir.name
|
||||
try:
|
||||
results[handle] = sync_user(user_dir)
|
||||
except Exception as exc:
|
||||
log.exception("sync_all[%s]: unexpected error: %s", handle, exc)
|
||||
results[handle] = (0, -1)
|
||||
return results
|
||||
|
||||
|
||||
def _post_rebuild(url: str, secret: str | None) -> None:
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
if secret:
|
||||
headers["X-Sync-Secret"] = secret
|
||||
req = urllib.request.Request(url, data=b"{}", headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
log.info("rebuild triggered: HTTP %d", resp.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
log.error("rebuild trigger failed: HTTP %d %s", exc.code, exc.read().decode()[:100])
|
||||
except Exception as exc:
|
||||
log.error("rebuild trigger failed: %s", exc)
|
||||
|
||||
|
||||
@click.command("sync-strava")
|
||||
@click.option("--data-dir", "data_dir_str", required=True,
|
||||
help="Root data dir (parent of all user dirs, e.g. /var/bincio/data).")
|
||||
@click.option("--user", "only_user", default=None,
|
||||
help="Sync only this handle instead of all users.")
|
||||
@click.option("--rebuild-url", default=None, envvar="BINCIO_REBUILD_URL",
|
||||
help="POST here after a successful sync to trigger a site rebuild.")
|
||||
@click.option("--rebuild-secret", default=None, envvar="BINCIO_SYNC_SECRET",
|
||||
help="Value sent as X-Sync-Secret header to the rebuild endpoint.")
|
||||
def sync_strava_cmd(
|
||||
data_dir_str: str,
|
||||
only_user: str | None,
|
||||
rebuild_url: str | None,
|
||||
rebuild_secret: str | None,
|
||||
) -> None:
|
||||
"""Headless Strava sync for all users (designed for systemd timer).
|
||||
|
||||
Discovers every user directory that has both strava_token.json and
|
||||
strava_credentials.json, syncs new activities, and optionally triggers
|
||||
a site rebuild via an HTTP POST.
|
||||
"""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s — %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||
)
|
||||
|
||||
root = Path(data_dir_str).expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
raise click.ClickException(f"Data dir not found: {root}")
|
||||
|
||||
if only_user:
|
||||
user_dir = root / only_user
|
||||
if not user_dir.is_dir():
|
||||
raise click.ClickException(f"User dir not found: {user_dir}")
|
||||
new_count, err_count = sync_user(user_dir)
|
||||
click.echo(f"{only_user}: {new_count} imported, {err_count} errors")
|
||||
total_new = new_count
|
||||
else:
|
||||
results = sync_all(root)
|
||||
total_new = sum(n for n, _ in results.values())
|
||||
total_err = sum(e for _, e in results.values())
|
||||
click.echo(
|
||||
f"Sync complete: {len(results)} users, "
|
||||
f"{total_new} new activities, {total_err} errors"
|
||||
)
|
||||
|
||||
if total_new > 0 and rebuild_url:
|
||||
_post_rebuild(rebuild_url, rebuild_secret)
|
||||
@@ -0,0 +1,84 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
|
||||
---
|
||||
|
||||
## Project notes
|
||||
|
||||
### Activity page power curve — comparison lines (future)
|
||||
|
||||
The activity page shows a single-activity power curve from `detail.mmp` (pre-computed at
|
||||
extract time, zero extra requests). Adding "last 365 d / last 90 d" comparison overlays
|
||||
requires the pre-computed `power_curve.last_365d` / `power_curve.last_90d` arrays, which
|
||||
currently live only in `athlete.json`. Loading `athlete.json` at activity-page time is
|
||||
wasteful (it's a large file with all activity summaries).
|
||||
|
||||
**Clean solution when the time comes:** at `render` time (inside `_merge_edits` or a
|
||||
dedicated step in `bincio/render/cli.py`), bake the comparison curves into each activity's
|
||||
detail JSON — e.g. add a `power_curve_context` key with `all_time`, `last_365d`, `last_90d`.
|
||||
The activity page then gets them for free with the detail JSON it already fetches.
|
||||
Requires a one-time `bincio render` (no code changes to the extractor).
|
||||
Component to update: `site/src/components/ActivityPowerCurve.svelte`.
|
||||
@@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=BincioActivity Garmin sync
|
||||
Documentation=https://github.com/bincio/bincio-activity
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
WorkingDirectory=/opt/bincio
|
||||
|
||||
# Secrets: BINCIO_SYNC_SECRET (must match --sync-secret passed to bincio serve)
|
||||
# Copy /opt/bincio/deploy/systemd/sync.env.example → /etc/bincio/sync.env and fill it in.
|
||||
EnvironmentFile=/etc/bincio/sync.env
|
||||
|
||||
ExecStart=/root/.local/bin/uv run --project /opt/bincio bincio sync-garmin \
|
||||
--data-dir /var/bincio/data \
|
||||
--rebuild-url http://localhost:4041/api/internal/rebuild
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=bincio-sync-garmin
|
||||
|
||||
# Don't restart on failure — the timer will retry in 3 hours.
|
||||
Restart=no
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=BincioActivity Garmin sync — every 3 hours
|
||||
Documentation=https://github.com/bincio/bincio-activity
|
||||
|
||||
[Timer]
|
||||
# Fire at 01:30, 04:30, 07:30, 10:30, 13:30, 16:30, 19:30, 22:30 UTC
|
||||
# Offset by 1h30m from the Strava timer to avoid simultaneous rebuilds.
|
||||
OnCalendar=*-*-* 01,04,07,10,13,16,19,22:30:00
|
||||
# Catch up if the VPS was offline during a scheduled run
|
||||
Persistent=true
|
||||
# Spread load within a 2-minute window
|
||||
RandomizedDelaySec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=BincioActivity Strava sync
|
||||
Documentation=https://github.com/bincio/bincio-activity
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
WorkingDirectory=/opt/bincio
|
||||
|
||||
# Secrets: BINCIO_SYNC_SECRET (must match --sync-secret passed to bincio serve)
|
||||
# Copy /opt/bincio/deploy/systemd/sync.env.example → /etc/bincio/sync.env and fill it in.
|
||||
EnvironmentFile=/etc/bincio/sync.env
|
||||
|
||||
ExecStart=/root/.local/bin/uv run --project /opt/bincio bincio sync-strava \
|
||||
--data-dir /var/bincio/data \
|
||||
--rebuild-url http://localhost:4041/api/internal/rebuild
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=bincio-sync
|
||||
|
||||
# Don't restart on failure — the timer will retry in 3 hours.
|
||||
Restart=no
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=BincioActivity Strava sync — every 3 hours
|
||||
Documentation=https://github.com/bincio/bincio-activity
|
||||
|
||||
[Timer]
|
||||
# Fire at 00:00, 03:00, 06:00, 09:00, 12:00, 15:00, 18:00, 21:00 UTC
|
||||
OnCalendar=*-*-* 00,03,06,09,12,15,18,21:00:00
|
||||
# Catch up if the VPS was offline during a scheduled run
|
||||
Persistent=true
|
||||
# Spread load within a 2-minute window to avoid exact midnight spikes
|
||||
RandomizedDelaySec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,7 @@
|
||||
# /etc/bincio/sync.env — secrets for bincio-sync.service
|
||||
# Copy this file to /etc/bincio/sync.env and fill in the values.
|
||||
# chmod 600 /etc/bincio/sync.env
|
||||
|
||||
# Must match the --sync-secret / BINCIO_SYNC_SECRET value passed to `bincio serve`.
|
||||
# Generate with: openssl rand -hex 32
|
||||
BINCIO_SYNC_SECRET=your-secret-here
|
||||
@@ -26,6 +26,13 @@ Welcome to BincioActivity — a federated, self-hosted activity stats platform.
|
||||
|
||||
**[CLI Reference](reference/cli.md)** — All bincio commands and options.
|
||||
|
||||
## Mobile App
|
||||
|
||||
The mobile app (Expo/React Native) source code is now in the separate `bincio_autarchive` repository. See:
|
||||
|
||||
- **[Mobile App Design](mobile-app.md)** — Architecture, Pyodide extraction, Karoo integration, sync protocol (reference documentation)
|
||||
- **`bincio_autarchive/DEVELOPMENT.md`** — Build & development instructions
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [GitHub repo](https://github.com/brutsalvadi/bincio-activity)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Bincio Mobile App — Design Document
|
||||
|
||||
> **Note:** The mobile app source code is now in the separate `bincio_autarchive` repository. This document remains here as the authoritative design reference. For build & development instructions, see the `bincio_autarchive/DEVELOPMENT.md` file.
|
||||
|
||||
## Vision
|
||||
|
||||
The long-term goal is full independence from Garmin Connect, Strava, Hammerhead,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Gear Feature Plan
|
||||
|
||||
## Why the gap exists
|
||||
|
||||
Neither sync path populates gear today. The Strava API returns `gear_id` per activity
|
||||
(brut's originals show `b3437566`, `g10422777` etc.) but `strava_to_parsed()` ignores it.
|
||||
The ZIP path also ignores the gear column in activities.csv.
|
||||
Diego_p's "Rose Backroad" was set manually via the EditDrawer free-text field.
|
||||
|
||||
---
|
||||
|
||||
## Data model — `{user_dir}/gear.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid-abc123",
|
||||
"name": "Rose Backroad",
|
||||
"type": "bike",
|
||||
"retired": false,
|
||||
"strava_id": "b3437566"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `type` enum: `bike | shoes | skis | other`
|
||||
- Per-activity gear stays as a plain string (the gear **name**) — backward compatible with existing sidecars
|
||||
- `strava_id` is optional, used for deduplication during Strava sync
|
||||
|
||||
---
|
||||
|
||||
## Build order
|
||||
|
||||
### [x] Step 1 — `gear.json` CRUD API ✓
|
||||
File: `bincio/serve/routers/gear.py`
|
||||
- `GET /api/gear` → list items (auth required)
|
||||
- `POST /api/gear` → add item (auto-generate UUID id)
|
||||
- `PATCH /api/gear/{id}` → update (name, type, retired)
|
||||
- `DELETE /api/gear/{id}` → delete
|
||||
File lives at `{user_dir}/gear.json`, same pattern as `athlete.json`.
|
||||
Add gear router to `server.py`.
|
||||
|
||||
### [x] Step 2 — Gear tab in AthleteView (ownerOnly) ✓
|
||||
- Added `'gear'` to `Tab` type and `ALL_TABS` in `AthleteView.svelte`
|
||||
- Inline gear management: list, add, edit, retire — no separate component
|
||||
|
||||
### [x] Step 3 — EditDrawer gear selector ✓
|
||||
- At drawer open, fetches `/api/gear`
|
||||
- Shows `<select>` from registry (if items exist), with "Other…" revealing text input
|
||||
- Falls back to plain text input if no gear items registered
|
||||
- Value still stored as gear name string — backward compatible
|
||||
|
||||
### [x] Step 4 — Strava sync gear extraction ✓
|
||||
- `strava_api.py`: added `fetch_gear()` + `gear` field on `strava_to_parsed()` via `_gear_name` meta key
|
||||
- `ingest.py`: during sync, resolves gear_id → name, adds new items to registry
|
||||
- New endpoint `POST /api/strava/import-gear`: one-time backfill from stored originals
|
||||
|
||||
### [x] Step 5 — ZIP import gear column ✓
|
||||
- `strava_zip.py`: reads `Gear` column from activities.csv and sets `parsed.gear`
|
||||
|
||||
### [x] Step 6 — One-time backfill endpoint ✓
|
||||
`POST /api/strava/import-gear` implemented in `strava.py`.
|
||||
+16
-9
@@ -4,6 +4,16 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Bincio</title>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(regs) {
|
||||
regs.forEach(function(r) { r.unregister(); });
|
||||
});
|
||||
caches.keys().then(function(keys) {
|
||||
keys.forEach(function(k) { caches.delete(k); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
var palettes = {
|
||||
@@ -52,7 +62,6 @@
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
body { visibility: hidden; }
|
||||
|
||||
.wrap {
|
||||
max-width: 384px;
|
||||
@@ -222,12 +231,13 @@
|
||||
|
||||
function showApps(user) {
|
||||
loginDiv.style.display = 'none';
|
||||
appsDiv.style.display = '';
|
||||
appsDiv.style.display = 'block';
|
||||
greeting.textContent = 'Ciao ' + (user.display_name || user.handle);
|
||||
cardsDiv.innerHTML = '';
|
||||
if (user.activity_access)
|
||||
// activity_access/wiki_access not in CurrentUserResponse yet — default to true
|
||||
if (user.activity_access !== false)
|
||||
cardsDiv.appendChild(appCard('BincioActivity', 'Tracks, strade e numeri', ACTIVITY_URL));
|
||||
if (user.wiki_access)
|
||||
if (user.wiki_access !== false)
|
||||
cardsDiv.appendChild(appCard('BincioWiki', 'La memoria collettiva del gruppo', WIKI_URL));
|
||||
}
|
||||
|
||||
@@ -237,11 +247,8 @@
|
||||
}
|
||||
|
||||
fetch('/api/me', { credentials: 'include' })
|
||||
.then(async r => {
|
||||
document.body.style.visibility = '';
|
||||
if (r.ok) showApps(await r.json());
|
||||
})
|
||||
.catch(() => { document.body.style.visibility = ''; });
|
||||
.then(async r => { if (r.ok) showApps(await r.json()); })
|
||||
.catch(() => {});
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', e => {
|
||||
e.waitUntil((async () => {
|
||||
await self.clients.claim();
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map(k => caches.delete(k)));
|
||||
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
for (const c of all) c.navigate(c.url);
|
||||
await self.registration.unregister();
|
||||
})());
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
|
||||
# Generated native projects (managed workflow — produced by EAS, not committed)
|
||||
android/
|
||||
ios/
|
||||
|
||||
# Local env overrides
|
||||
.env.local
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { getSetting, setSetting } from '@/db/queries';
|
||||
import { autoKey, PALETTES, type PaletteKey, type Theme } from '@/theme';
|
||||
|
||||
type ThemeCtx = {
|
||||
theme: Theme;
|
||||
paletteKey: PaletteKey;
|
||||
setPaletteOverride: (key: PaletteKey) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeCtx>({
|
||||
theme: PALETTES.default,
|
||||
paletteKey: 'auto',
|
||||
setPaletteOverride: () => {},
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const db = useSQLiteContext();
|
||||
const [paletteKey, setPaletteKey] = useState<PaletteKey>('auto');
|
||||
|
||||
useEffect(() => {
|
||||
getSetting(db, 'palette_override').then(val => {
|
||||
if (val) setPaletteKey(val as PaletteKey);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function setPaletteOverride(key: PaletteKey) {
|
||||
setPaletteKey(key);
|
||||
setSetting(db, 'palette_override', key);
|
||||
}
|
||||
|
||||
const resolved = paletteKey === 'auto' ? autoKey() : paletteKey;
|
||||
const theme = PALETTES[resolved as keyof typeof PALETTES] ?? PALETTES.default;
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, paletteKey, setPaletteOverride }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): Theme {
|
||||
return useContext(ThemeContext).theme;
|
||||
}
|
||||
|
||||
export function usePaletteControl(): Pick<ThemeCtx, 'paletteKey' | 'setPaletteOverride'> {
|
||||
const { paletteKey, setPaletteOverride } = useContext(ThemeContext);
|
||||
return { paletteKey, setPaletteOverride };
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
apply plugin: "com.android.application"
|
||||
apply plugin: "org.jetbrains.kotlin.android"
|
||||
apply plugin: "com.facebook.react"
|
||||
|
||||
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||
|
||||
/**
|
||||
* This is the configuration block to customize your React Native Android app.
|
||||
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||
*/
|
||||
react {
|
||||
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
|
||||
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
|
||||
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
|
||||
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
|
||||
// Use Expo CLI to bundle the app, this ensures the Metro config
|
||||
// works correctly with Expo projects.
|
||||
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
|
||||
bundleCommand = "export:embed"
|
||||
|
||||
// Embed the JS bundle in debug builds so the APK runs without a Metro server
|
||||
// (needed for deployment to Karoo and other standalone devices).
|
||||
// debuggableVariants lists variants that skip bundling; empty = bundle all variants.
|
||||
debuggableVariants = []
|
||||
|
||||
/* Folders */
|
||||
// The root of your project, i.e. where "package.json" lives. Default is '../..'
|
||||
// root = file("../../")
|
||||
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
|
||||
// reactNativeDir = file("../../node_modules/react-native")
|
||||
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
|
||||
// codegenDir = file("../../node_modules/@react-native/codegen")
|
||||
|
||||
/* Variants */
|
||||
// The list of variants to that are debuggable. For those we're going to
|
||||
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||
|
||||
/* Bundling */
|
||||
// A list containing the node command and its flags. Default is just 'node'.
|
||||
// nodeExecutableAndArgs = ["node"]
|
||||
|
||||
//
|
||||
// The path to the CLI configuration file. Default is empty.
|
||||
// bundleConfig = file(../rn-cli.config.js)
|
||||
//
|
||||
// The name of the generated asset file containing your JS bundle
|
||||
// bundleAssetName = "MyApplication.android.bundle"
|
||||
//
|
||||
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||
// entryFile = file("../js/MyApplication.android.js")
|
||||
//
|
||||
// A list of extra flags to pass to the 'bundle' commands.
|
||||
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||
// extraPackagerArgs = []
|
||||
|
||||
/* Hermes Commands */
|
||||
// The hermes compiler command to run. By default it is 'hermesc'
|
||||
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||
//
|
||||
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||
// hermesFlags = ["-O", "-output-source-map"]
|
||||
|
||||
/* Autolinking */
|
||||
autolinkLibrariesWithApp()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
|
||||
*/
|
||||
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
|
||||
|
||||
/**
|
||||
* The preferred build flavor of JavaScriptCore (JSC)
|
||||
*
|
||||
* For example, to use the international variant, you can use:
|
||||
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||
*
|
||||
* The international variant includes ICU i18n library and necessary data
|
||||
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||
* give correct results when using with locales other than en-US. Note that
|
||||
* this variant is about 6MiB larger per architecture than default.
|
||||
*/
|
||||
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
||||
|
||||
android {
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
namespace 'org.bincio.app'
|
||||
defaultConfig {
|
||||
applicationId 'org.bincio.app'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
storePassword 'android'
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig signingConfigs.debug
|
||||
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||
shrinkResources enableShrinkResources.toBoolean()
|
||||
minifyEnabled enableMinifyInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
||||
crunchPngs enablePngCrunchInRelease.toBoolean()
|
||||
}
|
||||
}
|
||||
packagingOptions {
|
||||
jniLibs {
|
||||
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
|
||||
useLegacyPackaging enableLegacyPackaging.toBoolean()
|
||||
}
|
||||
}
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
include "arm64-v8a", "armeabi-v7a"
|
||||
universalApk true
|
||||
}
|
||||
}
|
||||
androidResources {
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
|
||||
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||
// Accepts values in comma delimited lists, example:
|
||||
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
|
||||
// Split option: 'foo,bar' -> ['foo', 'bar']
|
||||
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
|
||||
// Trim all elements in place.
|
||||
for (i in 0..<options.size()) options[i] = options[i].trim();
|
||||
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
||||
options -= ""
|
||||
|
||||
if (options.length > 0) {
|
||||
println "android.packagingOptions.$prop += $options ($options.length)"
|
||||
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
|
||||
options.each {
|
||||
android.packagingOptions[prop] += it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
|
||||
if (isGifEnabled) {
|
||||
// For animated gif support
|
||||
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
|
||||
}
|
||||
|
||||
if (isWebpEnabled) {
|
||||
// For webp support
|
||||
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
|
||||
if (isWebpAnimatedEnabled) {
|
||||
// Animated webp support
|
||||
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
|
||||
}
|
||||
}
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
} else {
|
||||
implementation jscFlavor
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Bincio",
|
||||
"slug": "bincio",
|
||||
"version": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"scheme": "bincio",
|
||||
"userInterfaceStyle": "dark",
|
||||
"newArchEnabled": true,
|
||||
"platforms": ["ios", "android"],
|
||||
"icon": "./assets/icon.png",
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#09090b"
|
||||
},
|
||||
"android": {
|
||||
"package": "org.bincio.app",
|
||||
"usesCleartextTraffic": true,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#09090b"
|
||||
},
|
||||
"permissions": [
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.READ_MEDIA_VIDEO",
|
||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
||||
"android.permission.VIBRATE",
|
||||
"android.permission.POST_NOTIFICATIONS"
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"bundleIdentifier": "org.bincio.app",
|
||||
"supportsTablet": true
|
||||
},
|
||||
"plugins": [
|
||||
"expo-system-ui",
|
||||
"expo-router",
|
||||
"expo-sqlite",
|
||||
[
|
||||
"expo-document-picker",
|
||||
{ "iCloudContainerEnvironment": "Production" }
|
||||
],
|
||||
"expo-background-fetch",
|
||||
"expo-task-manager",
|
||||
"@maplibre/maplibre-react-native"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Platform } from 'react-native';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
|
||||
const isKaroo = Platform.OS === 'android' && (Platform.Version as number) < 29;
|
||||
|
||||
export default function TabLayout() {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: { backgroundColor: '#18181b', borderTopColor: '#27272a' },
|
||||
tabBarActiveTintColor: theme.accent,
|
||||
tabBarInactiveTintColor: '#71717a',
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{ title: 'Feed', tabBarIcon: ({ color }) => <TabIcon label="⬡" color={color} /> }}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="import"
|
||||
options={{ title: 'Import', tabBarIcon: ({ color }) => <TabIcon label="↑" color={color} /> }}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="search"
|
||||
options={{
|
||||
title: 'Search',
|
||||
tabBarIcon: ({ color }) => <TabIcon label="⌕" color={color} />,
|
||||
href: isKaroo ? null : '/search',
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{ title: 'Settings', tabBarIcon: ({ color }) => <TabIcon label="⚙" color={color} /> }}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function TabIcon({ label, color }: { label: string; color: string }) {
|
||||
const { Text } = require('react-native');
|
||||
return <Text style={{ color, fontSize: 18 }}>{label}</Text>;
|
||||
}
|
||||
@@ -1,604 +0,0 @@
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AppState, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { insertActivity, isSourcePathImported, getSetting } from '@/db/queries';
|
||||
import { PyodideWebView } from '@/extraction/PyodideWebView';
|
||||
import { extractFile, waitForEngine, onEngineProgress, isEngineAvailable } from '@/extraction/extractActivity';
|
||||
import { extractFileViaServer, checkServerAuth } from '@/extraction/extractServer';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
|
||||
async function sha256hex(text: string): Promise<string> {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
||||
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
const FIT_EXTENSIONS = ['.fit', '.fit.gz'];
|
||||
const OTHER_EXTENSIONS = ['.gpx', '.tcx', '.gpx.gz', '.tcx.gz'];
|
||||
const ALL_NATIVE_EXTENSIONS = [...FIT_EXTENSIONS, ...OTHER_EXTENSIONS];
|
||||
|
||||
type ImportState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading'; msg: string; current: number; total: number }
|
||||
| { status: 'done'; count: number; errors: Array<{ name: string; message: string }> }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
export default function ImportScreen() {
|
||||
const db = useSQLiteContext();
|
||||
const theme = useTheme();
|
||||
const [state, setState] = useState<ImportState>({ status: 'idle' });
|
||||
const [watchPath, setWatchPath] = useState('');
|
||||
const [engineAvailable, setEngineAvailable] = useState<boolean | null>(null);
|
||||
const isImporting = useRef(false);
|
||||
|
||||
// Track engine availability so we can show the server-extraction notice.
|
||||
useEffect(() => {
|
||||
waitForEngine(30_000)
|
||||
.then(() => setEngineAvailable(true))
|
||||
.catch((e: unknown) => {
|
||||
if (e instanceof Error && e.message === 'engine_unavailable') setEngineAvailable(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Reload watch path every time the Import tab comes into focus so changes
|
||||
// saved in Settings are picked up without remounting the tab.
|
||||
useFocusEffect(useCallback(() => {
|
||||
if (Platform.OS !== 'android') return;
|
||||
const row = db.getFirstSync<{ value: string }>(
|
||||
'SELECT value FROM settings WHERE key = ?',
|
||||
['auto_import_path'],
|
||||
);
|
||||
setWatchPath(row?.value ?? '');
|
||||
}, [db]));
|
||||
|
||||
// Auto-scan watch folder on mount and when app comes to foreground.
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'android') return;
|
||||
runAutoScan();
|
||||
|
||||
const sub = AppState.addEventListener('change', (next) => {
|
||||
if (next === 'active') runAutoScan();
|
||||
});
|
||||
return () => sub.remove();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function runAutoScan() {
|
||||
if (isImporting.current) return;
|
||||
const path = await getSetting(db, 'auto_import_path');
|
||||
if (!path) return;
|
||||
const instanceUrl = await getSetting(db, 'instance_url');
|
||||
if (!instanceUrl) return;
|
||||
|
||||
// Wait for engine — skip auto-scan on init failure, but continue if device is
|
||||
// too old for local extraction (importNativeFile will use the server instead).
|
||||
try { await waitForEngine(120_000); } catch (e: unknown) {
|
||||
if (!(e instanceof Error) || e.message !== 'engine_unavailable') return;
|
||||
}
|
||||
|
||||
// Server-mode requires a valid token — verify before touching any files.
|
||||
if (isEngineAvailable() === false) {
|
||||
const token = await getSetting(db, 'api_token');
|
||||
if (!token) return;
|
||||
try { await checkServerAuth(instanceUrl, token); } catch { return; }
|
||||
}
|
||||
|
||||
const newFiles = await discoverNewFiles(db, path);
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
isImporting.current = true;
|
||||
try {
|
||||
await processBatch(newFiles.map(f => ({ uri: `file://${f}`, name: f.split('/').pop() ?? f, sourcePath: f })));
|
||||
} finally {
|
||||
isImporting.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function manualScan() {
|
||||
if (isImporting.current) return;
|
||||
const path = await getSetting(db, 'auto_import_path');
|
||||
if (!path) return;
|
||||
const instanceUrl = await getSetting(db, 'instance_url');
|
||||
if (!instanceUrl) {
|
||||
setState({ status: 'error', message: 'No Bincio instance configured. Go to Settings and enter an instance URL first — it\'s needed to download the extraction engine.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const serverMode = isEngineAvailable() === false;
|
||||
if (!serverMode) {
|
||||
setState({ status: 'loading', msg: 'Preparing extraction engine…', current: 0, total: 0 });
|
||||
const unsubScan = onEngineProgress((msg) =>
|
||||
setState({ status: 'loading', msg, current: 0, total: 0 }),
|
||||
);
|
||||
try {
|
||||
await waitForEngine();
|
||||
} catch (e: unknown) {
|
||||
if (!(e instanceof Error) || e.message !== 'engine_unavailable') {
|
||||
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
|
||||
return;
|
||||
}
|
||||
// engine_unavailable — fall through to server mode
|
||||
} finally {
|
||||
unsubScan();
|
||||
}
|
||||
} else {
|
||||
const token = await getSetting(db, 'api_token');
|
||||
if (!token) {
|
||||
setState({ status: 'error', message: 'Server extraction requires a Bincio account. Connect in Settings.' });
|
||||
return;
|
||||
}
|
||||
// Verify the token is valid before processing any files.
|
||||
setState({ status: 'loading', msg: 'Checking connection…', current: 0, total: 0 });
|
||||
try {
|
||||
await checkServerAuth(instanceUrl, token);
|
||||
} catch (e: unknown) {
|
||||
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState({ status: 'loading', msg: 'Scanning…', current: 0, total: 0 });
|
||||
const newFiles = await discoverNewFiles(db, path);
|
||||
if (newFiles.length === 0) {
|
||||
setState({ status: 'done', count: 0, errors: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
isImporting.current = true;
|
||||
try {
|
||||
await processBatch(newFiles.map(f => ({ uri: `file://${f}`, name: f.split('/').pop() ?? f, sourcePath: f })));
|
||||
} finally {
|
||||
isImporting.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFiles() {
|
||||
if (isImporting.current) return;
|
||||
setState({ status: 'loading', msg: 'Picking files…', current: 0, total: 0 });
|
||||
try {
|
||||
let result: DocumentPicker.DocumentPickerResult;
|
||||
try {
|
||||
result = await DocumentPicker.getDocumentAsync({
|
||||
type: ['*/*'],
|
||||
copyToCacheDirectory: true,
|
||||
multiple: true,
|
||||
});
|
||||
} catch (pickerErr: unknown) {
|
||||
// Some Android devices (e.g. Karoo) have no system file picker app.
|
||||
const raw = pickerErr instanceof Error ? pickerErr.message : String(pickerErr);
|
||||
const noApp = raw.includes('ActivityNotFoundException') || raw.includes('No Activity found');
|
||||
setState({
|
||||
status: 'error',
|
||||
message: noApp
|
||||
? 'No file picker available on this device. Set a Watch directory in Settings to import from a folder.'
|
||||
: raw,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.canceled || !result.assets?.length) {
|
||||
setState({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
isImporting.current = true;
|
||||
const unsubPick = onEngineProgress((msg) =>
|
||||
setState({ status: 'loading', msg, current: 0, total: 0 }),
|
||||
);
|
||||
try {
|
||||
await processBatch(result.assets.map(a => ({ uri: a.uri, name: a.name ?? '', sourcePath: null })));
|
||||
} finally {
|
||||
unsubPick();
|
||||
isImporting.current = false;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setState({ status: 'error', message: msg });
|
||||
isImporting.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function processBatch(files: Array<{ uri: string; name: string; sourcePath: string | null }>) {
|
||||
const total = files.length;
|
||||
const errors: Array<{ name: string; message: string }> = [];
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const { uri, name, sourcePath } = files[i];
|
||||
const lower = name.toLowerCase();
|
||||
|
||||
setState({ status: 'loading', msg: `Processing ${name}…`, current: i + 1, total });
|
||||
|
||||
try {
|
||||
if (lower.endsWith('.json')) {
|
||||
await importBasJson(uri, name, sourcePath, (msg) =>
|
||||
setState({ status: 'loading', msg, current: i + 1, total }),
|
||||
);
|
||||
} else if (ALL_NATIVE_EXTENSIONS.some(ext => lower.endsWith(ext))) {
|
||||
await importNativeFile(uri, name, sourcePath, (msg) =>
|
||||
setState({ status: 'loading', msg, current: i + 1, total }),
|
||||
);
|
||||
} else {
|
||||
errors.push({ name, message: 'Unsupported file type' });
|
||||
continue;
|
||||
}
|
||||
count++;
|
||||
} catch (e: unknown) {
|
||||
errors.push({ name, message: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
setState({ status: 'done', count, errors });
|
||||
}
|
||||
|
||||
// ── BAS JSON import (no extraction needed) ──────────────────────────────────
|
||||
|
||||
async function importBasJson(
|
||||
uri: string,
|
||||
_name: string,
|
||||
sourcePath: string | null,
|
||||
onStatus: (msg: string) => void,
|
||||
) {
|
||||
onStatus('Importing…');
|
||||
const text = await FileSystem.readAsStringAsync(uri);
|
||||
const detail = JSON.parse(text);
|
||||
|
||||
if (!detail.id || !detail.started_at) {
|
||||
throw new Error('Not a valid BAS activity JSON (missing id or started_at)');
|
||||
}
|
||||
|
||||
const hash = detail.source_hash ?? await sha256hex(text);
|
||||
const origDir = `${FileSystem.documentDirectory}originals/`;
|
||||
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
|
||||
const dest = `${origDir}${detail.id}.json`;
|
||||
await FileSystem.copyAsync({ from: uri, to: dest });
|
||||
|
||||
await insertActivity(db, {
|
||||
id: detail.id,
|
||||
source_hash: hash,
|
||||
detail_json: text,
|
||||
timeseries_json: null,
|
||||
geojson: null,
|
||||
original_path: dest,
|
||||
source_path: sourcePath,
|
||||
origin: 'local',
|
||||
});
|
||||
}
|
||||
|
||||
// ── FIT / GPX / TCX import via Pyodide (local) or server fallback ───────────
|
||||
|
||||
async function importNativeFile(
|
||||
uri: string,
|
||||
name: string,
|
||||
sourcePath: string | null,
|
||||
onStatus: (msg: string) => void,
|
||||
) {
|
||||
onStatus('Reading file…');
|
||||
|
||||
// Read the original file as base64 so we can (a) pass it to the extractor
|
||||
// and (b) copy it to permanent storage without a second read.
|
||||
const base64 = await FileSystem.readAsStringAsync(uri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
|
||||
let result;
|
||||
|
||||
if (isEngineAvailable() === false) {
|
||||
// Device WebView is too old for WebAssembly.Global (Chrome <69).
|
||||
// Send the raw file to the Bincio instance for server-side extraction.
|
||||
const instanceUrl = await getInstanceUrl(db);
|
||||
const token = db.getFirstSync<{ value: string }>(
|
||||
'SELECT value FROM settings WHERE key = ?', ['api_token'],
|
||||
)?.value ?? '';
|
||||
if (!token) throw new Error('Server extraction requires a Bincio account — connect in Settings.');
|
||||
result = await extractFileViaServer(name, base64, instanceUrl, token, onStatus);
|
||||
} else {
|
||||
// Fetch the bincio wheel here (React Native networking), not inside the
|
||||
// WebView. WKWebView blocks HTTP requests via ATS; RN native networking
|
||||
// allows local-network HTTP (NSAllowsLocalNetworking=true in Info.plist).
|
||||
const instanceUrl = await getInstanceUrl(db);
|
||||
onStatus('Fetching Bincio engine…');
|
||||
const { base64: wheelBase64, filename: wheelFilename } = await fetchWheelBase64(instanceUrl);
|
||||
result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus);
|
||||
}
|
||||
|
||||
onStatus('Saving…');
|
||||
|
||||
// Copy original file to permanent storage (keeps original bytes for future re-extraction)
|
||||
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
|
||||
const origDir = `${FileSystem.documentDirectory}originals/`;
|
||||
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
|
||||
const dest = `${origDir}${result.id}${ext}`;
|
||||
await FileSystem.copyAsync({ from: uri, to: dest });
|
||||
|
||||
await insertActivity(db, {
|
||||
id: result.id,
|
||||
source_hash: result.sourceHash,
|
||||
detail_json: JSON.stringify(result.detail),
|
||||
timeseries_json: result.timeseries ? JSON.stringify(result.timeseries) : null,
|
||||
geojson: result.geojson ? JSON.stringify(result.geojson) : null,
|
||||
original_path: dest,
|
||||
source_path: sourcePath,
|
||||
origin: 'local',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
{/* Hidden WebView for Pyodide — only mounted on devices that can run it.
|
||||
Android <29 has a system WebView (Chrome <69) that lacks WebAssembly.Global
|
||||
AND causes GPU SurfaceView crashes on old drivers. Skip it entirely there. */}
|
||||
{(Platform.OS !== 'android' || (Platform.Version as number) >= 29) && (
|
||||
<View style={styles.hiddenEngine}>
|
||||
<PyodideWebView />
|
||||
</View>
|
||||
)}
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.header}>Import</Text>
|
||||
|
||||
<Text style={styles.body}>
|
||||
Import FIT, GPX, or TCX files — extracted on your device, nothing uploaded.
|
||||
You can also import pre-extracted BAS <Text style={[styles.code, { color: theme.accent }]}>.json</Text> files.
|
||||
</Text>
|
||||
|
||||
{engineAvailable === false && (
|
||||
<View style={styles.serverNotice}>
|
||||
<Text style={styles.serverNoticeText}>
|
||||
This device's Android WebView is too old to run local extraction (requires Chrome 69+).
|
||||
Activities are processed by your Bincio instance instead — a connected account is required.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{watchPath ? (
|
||||
<View style={styles.watchBox}>
|
||||
<Text style={styles.watchLabel}>Watch folder</Text>
|
||||
<Text style={styles.watchPath} numberOfLines={2}>{watchPath}</Text>
|
||||
<Pressable
|
||||
style={[styles.scanButton, state.status === 'loading' && styles.buttonDisabled]}
|
||||
onPress={state.status !== 'loading' ? manualScan : undefined}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{state.status === 'loading' ? 'Working…' : '↺ Scan for new rides'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
style={[styles.button, state.status === 'loading' && styles.buttonDisabled]}
|
||||
onPress={state.status !== 'loading' ? pickFiles : undefined}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{state.status === 'loading' ? 'Working…' : '+ Pick files'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{state.status === 'loading' && (
|
||||
<View style={styles.statusBox}>
|
||||
{state.total > 1 && (
|
||||
<Text style={styles.statusCounter}>
|
||||
File {state.current} of {state.total}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[styles.statusMsg, { color: theme.accent }]}>{state.msg}</Text>
|
||||
{engineAvailable !== false && (
|
||||
<Text style={styles.statusHint}>
|
||||
First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{state.status === 'done' && (
|
||||
<View style={[styles.success, state.count === 0 && state.errors.length === 0 && styles.successEmpty]}>
|
||||
<Text style={styles.successText}>
|
||||
{state.count === 0 && state.errors.length === 0
|
||||
? 'No new rides found'
|
||||
: `✓ Imported ${state.count} ${state.count === 1 ? 'activity' : 'activities'}`}
|
||||
</Text>
|
||||
{state.errors.map((e, i) => (
|
||||
<Text key={i} style={styles.batchError}>✗ {e.name}: {e.message}</Text>
|
||||
))}
|
||||
<Pressable onPress={() => setState({ status: 'idle' })}>
|
||||
<Text style={styles.errorRetry}>Dismiss</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{state.status === 'error' && (
|
||||
<View style={styles.error}>
|
||||
<Text style={styles.errorText}>{state.message}</Text>
|
||||
<Pressable onPress={() => setState({ status: 'idle' })}>
|
||||
<Text style={styles.errorRetry}>Try again</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Text style={styles.sectionTitle}>Supported formats</Text>
|
||||
{([
|
||||
['FIT', 'Garmin, Wahoo, Karoo native format'],
|
||||
['GPX', 'Most GPS devices and apps'],
|
||||
['TCX', 'Garmin Training Center'],
|
||||
['BAS JSON', 'Pre-extracted Bincio format (instant)'],
|
||||
] as [string, string][]).map(([fmt, desc]) => (
|
||||
<View key={fmt} style={styles.formatRow}>
|
||||
<Text style={styles.formatName}>{fmt}</Text>
|
||||
<Text style={styles.formatDesc}>{desc}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={styles.notice}>
|
||||
<Text style={styles.noticeText}>
|
||||
{engineAvailable === false
|
||||
? 'Activities are sent to your Bincio instance for extraction and stored there + locally. A connected account is required.'
|
||||
: `FIT/GPX/TCX extraction runs entirely on your device.\nA Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).`}
|
||||
{'\n\n'}
|
||||
On Karoo: set Watch directory to <Text style={styles.noticeCode}>/sdcard/FitFiles</Text> in Settings to auto-import rides.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Watch-folder helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function requestStoragePermission(): Promise<boolean> {
|
||||
if (Platform.OS !== 'android') return true;
|
||||
try {
|
||||
const granted = await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
|
||||
);
|
||||
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverNewFiles(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
watchPath: string,
|
||||
): Promise<string[]> {
|
||||
const ok = await requestStoragePermission();
|
||||
if (!ok) return [];
|
||||
|
||||
// Normalize: strip trailing slash, then use file:// URI for expo-fs
|
||||
const dir = watchPath.replace(/\/+$/, '');
|
||||
const uri = dir.startsWith('file://') ? dir : `file://${dir}`;
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await FileSystem.readDirectoryAsync(uri);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const newFiles: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const lower = entry.toLowerCase();
|
||||
if (!lower.endsWith('.fit')) continue;
|
||||
const fullPath = `${dir}/${entry}`;
|
||||
if (!isSourcePathImported(db, fullPath)) {
|
||||
newFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
return newFiles;
|
||||
}
|
||||
|
||||
// ── Module-level helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function getInstanceUrl(db: ReturnType<typeof useSQLiteContext>): Promise<string> {
|
||||
const row = db.getFirstSync<{ value: string }>(
|
||||
'SELECT value FROM settings WHERE key = ?',
|
||||
['instance_url'],
|
||||
);
|
||||
return (row?.value ?? '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
// In-memory cache so repeated imports in one session don't re-download the wheel.
|
||||
let _cachedWheel: { base64: string; filename: string } | null = null;
|
||||
|
||||
async function fetchWheelBase64(instanceUrl: string): Promise<{ base64: string; filename: string }> {
|
||||
if (_cachedWheel) return _cachedWheel;
|
||||
|
||||
const base = instanceUrl || 'https://bincio.org';
|
||||
|
||||
// Ask the instance for the canonical wheel URL (handles both dev and prod layouts).
|
||||
let wheelUrl = `${base}/api/wheel/download`;
|
||||
let wheelFilename = 'bincio-0.1.0-py3-none-any.whl';
|
||||
try {
|
||||
const vr = await fetch(`${base}/api/wheel/version`, { signal: AbortSignal.timeout(5000) });
|
||||
if (vr.ok) {
|
||||
const d = await vr.json() as { api_url?: string; url?: string };
|
||||
const path = d.api_url ?? d.url ?? '/api/wheel/download';
|
||||
wheelUrl = path.startsWith('http') ? path : `${base}${path}`;
|
||||
// Extract the filename from the URL path (last segment after final /)
|
||||
const urlBasename = wheelUrl.split('/').pop() ?? '';
|
||||
if (urlBasename.endsWith('.whl')) wheelFilename = urlBasename;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fetch via React Native networking (supports local HTTP; WKWebView would block it).
|
||||
const resp = await fetch(wheelUrl);
|
||||
if (!resp.ok) throw new Error(`Could not download Bincio engine (${resp.status}). Is the instance running?`);
|
||||
const buf = await resp.arrayBuffer();
|
||||
_cachedWheel = { base64: arrayBufferToBase64(buf), filename: wheelFilename };
|
||||
return _cachedWheel;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buf: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = '';
|
||||
// Process in chunks to avoid spread-operator stack overflow on large arrays.
|
||||
const CHUNK = 8192;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...(bytes.subarray(i, i + CHUNK) as unknown as number[]));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
// ── Styles ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: '#09090b' },
|
||||
hiddenEngine: { position: 'absolute', width: 1, height: 1, overflow: 'hidden' },
|
||||
container: { flex: 1 },
|
||||
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
|
||||
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
|
||||
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
|
||||
code: { color: '#60a5fa', fontFamily: 'monospace' },
|
||||
serverNotice: {
|
||||
backgroundColor: '#1c1400', borderRadius: 8, borderWidth: 1,
|
||||
borderColor: '#854d0e', padding: 12, marginBottom: 16,
|
||||
},
|
||||
serverNoticeText: { color: '#fbbf24', fontSize: 13, lineHeight: 18 },
|
||||
watchBox: {
|
||||
backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1,
|
||||
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 10,
|
||||
},
|
||||
watchLabel: { color: '#71717a', fontSize: 11, fontWeight: '600', letterSpacing: 0.5 },
|
||||
watchPath: { color: '#a1a1aa', fontSize: 13, fontFamily: 'monospace' },
|
||||
scanButton: {
|
||||
backgroundColor: '#16a34a', borderRadius: 10,
|
||||
paddingVertical: 14, alignItems: 'center',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#2563eb', borderRadius: 10,
|
||||
paddingVertical: 14, alignItems: 'center', marginBottom: 16,
|
||||
},
|
||||
buttonDisabled: { opacity: 0.5 },
|
||||
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||
statusBox: {
|
||||
backgroundColor: '#18181b', borderRadius: 8, borderWidth: 1,
|
||||
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 6,
|
||||
},
|
||||
statusCounter: { color: '#71717a', fontSize: 12, textAlign: 'center' },
|
||||
statusMsg: { color: '#60a5fa', fontSize: 14, textAlign: 'center' },
|
||||
statusHint: { color: '#52525b', fontSize: 12, textAlign: 'center', lineHeight: 16 },
|
||||
success: {
|
||||
backgroundColor: '#14532d', borderRadius: 8, padding: 12, marginBottom: 16, gap: 6,
|
||||
},
|
||||
successEmpty: { backgroundColor: '#1c1c1e' },
|
||||
successText: { color: '#86efac', fontSize: 14 },
|
||||
batchError: { color: '#fca5a5', fontSize: 12 },
|
||||
error: {
|
||||
backgroundColor: '#450a0a', borderRadius: 8, padding: 12, marginBottom: 16, gap: 8,
|
||||
},
|
||||
errorText: { color: '#fca5a5', fontSize: 14 },
|
||||
errorRetry: { color: '#71717a', fontSize: 13, textDecorationLine: 'underline', marginTop: 4 },
|
||||
divider: { height: 1, backgroundColor: '#27272a', marginVertical: 24 },
|
||||
sectionTitle: { color: '#a1a1aa', fontSize: 12, fontWeight: '600', marginBottom: 12, letterSpacing: 0.5 },
|
||||
formatRow: { flexDirection: 'row', gap: 12, marginBottom: 10 },
|
||||
formatName: { color: '#f4f4f5', fontSize: 13, fontWeight: '600', width: 72 },
|
||||
formatDesc: { color: '#71717a', fontSize: 13, flex: 1 },
|
||||
notice: {
|
||||
marginTop: 8, backgroundColor: '#18181b',
|
||||
borderRadius: 8, padding: 12, borderWidth: 1, borderColor: '#27272a',
|
||||
},
|
||||
noticeText: { color: '#71717a', fontSize: 12, lineHeight: 18 },
|
||||
noticeCode: { fontFamily: 'monospace', color: '#a1a1aa' },
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { deleteActivities, useActivities, useActivityCount, PAGE_SIZE } from '@/db/queries';
|
||||
import { downloadFeed, uploadFeed } from '@/db/sync';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
import { ActivityCard } from '@/components/ActivityCard';
|
||||
|
||||
export default function FeedScreen() {
|
||||
const db = useSQLiteContext();
|
||||
const theme = useTheme();
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [limit, setLimit] = useState(PAGE_SIZE);
|
||||
const activities = useActivities(searchQuery, limit);
|
||||
const totalCount = useActivityCount(searchQuery);
|
||||
const hasMore = activities.length < totalCount;
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [statusMsg, setStatusMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const selecting = selected.size > 0;
|
||||
|
||||
// Auto-refresh the local list whenever the tab comes into focus.
|
||||
// SQLite getAllSync is sub-millisecond — no network, no lag.
|
||||
useFocusEffect(useCallback(() => {
|
||||
setRefreshKey(k => k + 1);
|
||||
}, []));
|
||||
|
||||
function showMsg(ok: boolean, text: string) {
|
||||
setStatusMsg({ ok, text });
|
||||
setTimeout(() => setStatusMsg(null), 3500);
|
||||
}
|
||||
|
||||
const doDownload = useCallback(async () => {
|
||||
setDownloading(true);
|
||||
setStatusMsg(null);
|
||||
const result = await downloadFeed(db);
|
||||
setDownloading(false);
|
||||
setRefreshKey(k => k + 1);
|
||||
if (result.error) {
|
||||
showMsg(false, result.error);
|
||||
} else if (result.total === 0) {
|
||||
showMsg(true, 'No activities on instance');
|
||||
} else if (result.synced === 0 && !result.fetched) {
|
||||
showMsg(true, `Up to date (${result.total} activities)`);
|
||||
} else {
|
||||
const parts = [];
|
||||
if (result.synced > 0) parts.push(`${result.synced} new`);
|
||||
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
|
||||
showMsg(true, `Downloaded: ${parts.join(', ')} (${result.total} total)`);
|
||||
}
|
||||
}, [db]);
|
||||
|
||||
const doUpload = useCallback(async () => {
|
||||
setUploading(true);
|
||||
setStatusMsg(null);
|
||||
const result = await uploadFeed(db, (n, total) => {
|
||||
setStatusMsg({ ok: true, text: `Uploading ${n} / ${total}…` });
|
||||
});
|
||||
setUploading(false);
|
||||
if (result.error) {
|
||||
showMsg(false, result.error);
|
||||
} else if (!result.uploaded && !result.failed) {
|
||||
showMsg(true, 'Nothing to upload');
|
||||
} else {
|
||||
const parts: string[] = [];
|
||||
if (result.uploaded) parts.push(`${result.uploaded} uploaded`);
|
||||
if (result.failed) parts.push(`${result.failed} failed`);
|
||||
showMsg(result.failed ? false : true, parts.join(', '));
|
||||
}
|
||||
}, [db]);
|
||||
|
||||
function doRefresh() {
|
||||
setRefreshKey(k => k + 1);
|
||||
}
|
||||
|
||||
function handleSearch(q: string) {
|
||||
setSearchQuery(q);
|
||||
setLimit(PAGE_SIZE); // reset pagination when search changes
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (hasMore) setLimit(l => l + PAGE_SIZE);
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function cancelSelect() { setSelected(new Set()); }
|
||||
|
||||
function confirmDeleteSelected() {
|
||||
const count = selected.size;
|
||||
Alert.alert(
|
||||
`Delete ${count} activit${count === 1 ? 'y' : 'ies'}`,
|
||||
'These activities will be permanently removed from your device.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
const ids = Array.from(selected);
|
||||
const paths = await deleteActivities(db, ids);
|
||||
setSelected(new Set());
|
||||
for (const p of paths) {
|
||||
if (p) try { await FileSystem.deleteAsync(p, { idempotent: true }); } catch {}
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const busy = downloading || uploading;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.headerRow}>
|
||||
{selecting ? (
|
||||
<>
|
||||
<Text style={styles.header}>{selected.size} selected</Text>
|
||||
<Pressable style={styles.cancelButton} onPress={cancelSelect}>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.header}>Feed</Text>
|
||||
<View style={styles.actionButtons}>
|
||||
<ActionButton
|
||||
icon="↑"
|
||||
label="Upload"
|
||||
loading={uploading}
|
||||
disabled={busy}
|
||||
accent={theme.accent}
|
||||
dim={theme.dim}
|
||||
onPress={doUpload}
|
||||
/>
|
||||
<ActionButton
|
||||
icon="↓"
|
||||
label="Download"
|
||||
loading={downloading}
|
||||
disabled={busy}
|
||||
accent={theme.accent}
|
||||
dim={theme.dim}
|
||||
onPress={doDownload}
|
||||
/>
|
||||
<ActionButton
|
||||
icon="↺"
|
||||
label="Refresh"
|
||||
loading={false}
|
||||
disabled={busy}
|
||||
accent={theme.accent}
|
||||
dim={theme.dim}
|
||||
onPress={doRefresh}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{statusMsg && (
|
||||
<Text style={statusMsg.ok ? styles.msgOk : styles.msgErr}>{statusMsg.text}</Text>
|
||||
)}
|
||||
|
||||
{!selecting && (
|
||||
<View style={styles.searchRow}>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
placeholder="Search activities…"
|
||||
placeholderTextColor="#52525b"
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activities.length === 0 && !busy ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyIcon}>🚴</Text>
|
||||
<Text style={styles.emptyTitle}>No activities yet</Text>
|
||||
<Text style={styles.emptyBody}>
|
||||
Import a file or tap ↓ to pull from your instance.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={activities}
|
||||
keyExtractor={(a) => a.id}
|
||||
extraData={refreshKey}
|
||||
renderItem={({ item }) => (
|
||||
<ActivityCard
|
||||
activity={item}
|
||||
selecting={selecting}
|
||||
checked={selected.has(item.id)}
|
||||
onToggleSelect={() => toggleSelect(item.id)}
|
||||
onLongPress={() => toggleSelect(item.id)}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={styles.list}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={false}
|
||||
onRefresh={doRefresh}
|
||||
tintColor="#60a5fa"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selecting && (
|
||||
<View style={styles.actionBar}>
|
||||
<Pressable style={styles.deleteBarButton} onPress={confirmDeleteSelected}>
|
||||
<Text style={styles.deleteBarText}>Delete {selected.size}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon, label, loading, disabled, accent, dim, onPress,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
accent: string;
|
||||
dim: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.actionBtn, { backgroundColor: dim }, disabled && styles.actionBtnDisabled]}
|
||||
onPress={disabled ? undefined : onPress}
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<Text style={[styles.actionBtnIcon, { color: loading ? '#52525b' : accent }]}>
|
||||
{loading ? '…' : icon}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#09090b' },
|
||||
headerRow: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
|
||||
},
|
||||
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
|
||||
actionButtons: { flexDirection: 'row', gap: 8 },
|
||||
actionBtn: {
|
||||
width: 36, height: 36, borderRadius: 8,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
actionBtnDisabled: { opacity: 0.4 },
|
||||
actionBtnIcon: { fontSize: 18, fontWeight: '700', lineHeight: 22 },
|
||||
cancelButton: {
|
||||
backgroundColor: '#27272a', borderRadius: 8,
|
||||
paddingHorizontal: 14, paddingVertical: 7,
|
||||
},
|
||||
cancelText: { color: '#a1a1aa', fontSize: 13, fontWeight: '600' },
|
||||
msgOk: { color: '#86efac', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
|
||||
msgErr: { color: '#fca5a5', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
|
||||
searchRow: { paddingHorizontal: 16, paddingBottom: 10 },
|
||||
searchInput: {
|
||||
backgroundColor: '#18181b', borderWidth: 1, borderColor: '#27272a',
|
||||
borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8,
|
||||
color: '#f4f4f5', fontSize: 14,
|
||||
},
|
||||
list: { padding: 16, gap: 12, paddingBottom: 80 },
|
||||
empty: {
|
||||
flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32,
|
||||
},
|
||||
emptyIcon: { fontSize: 48, marginBottom: 16 },
|
||||
emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 },
|
||||
emptyBody: { color: '#71717a', fontSize: 14, textAlign: 'center', lineHeight: 20 },
|
||||
actionBar: {
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||
backgroundColor: '#18181b', borderTopWidth: 1, borderTopColor: '#27272a',
|
||||
paddingHorizontal: 16, paddingVertical: 12, paddingBottom: 28,
|
||||
},
|
||||
deleteBarButton: {
|
||||
backgroundColor: '#7f1d1d', borderRadius: 10,
|
||||
paddingVertical: 14, alignItems: 'center',
|
||||
},
|
||||
deleteBarText: { color: '#fca5a5', fontSize: 15, fontWeight: '700' },
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { PAGE_SIZE, useActivityYears, useFilteredActivities, useFilteredCount, type ActivityFilter } from '@/db/queries';
|
||||
import { ActivityCard } from '@/components/ActivityCard';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
|
||||
type SortKey = 'date' | 'distance' | 'elevation';
|
||||
|
||||
const SPORTS = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'cycling', label: '🚴 Cycling' },
|
||||
{ value: 'running', label: '🏃 Running' },
|
||||
{ value: 'hiking', label: '🥾 Hiking' },
|
||||
{ value: 'swimming', label: '🏊 Swimming' },
|
||||
{ value: 'walking', label: '🚶 Walking' },
|
||||
];
|
||||
|
||||
const DATE_PRESETS = [
|
||||
{ value: 'all', label: 'All time' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '30d', label: '30 days' },
|
||||
{ value: '6mo', label: '6 months' },
|
||||
];
|
||||
|
||||
const SORTS: { value: SortKey; label: string }[] = [
|
||||
{ value: 'date', label: 'Newest' },
|
||||
{ value: 'distance', label: 'Distance' },
|
||||
{ value: 'elevation', label: 'Elevation' },
|
||||
];
|
||||
|
||||
function computeDateRange(preset: string): { dateFrom: string; dateTo: string } {
|
||||
if (preset === 'all') return { dateFrom: '', dateTo: '' };
|
||||
if (/^\d{4}$/.test(preset)) {
|
||||
const y = parseInt(preset, 10);
|
||||
return { dateFrom: `${y}-01-01T000000Z`, dateTo: `${y + 1}-01-01T000000Z` };
|
||||
}
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const now = new Date();
|
||||
let d: Date;
|
||||
if (preset === '7d') d = new Date(now.getTime() - 7 * 86_400_000);
|
||||
else if (preset === '30d') d = new Date(now.getTime() - 30 * 86_400_000);
|
||||
else { d = new Date(now); d.setMonth(d.getMonth() - 6); }
|
||||
return { dateFrom: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T000000Z`, dateTo: '' };
|
||||
}
|
||||
|
||||
export default function SearchScreen() {
|
||||
const theme = useTheme();
|
||||
const [sport, setSport] = useState('');
|
||||
const [datePre, setDatePre] = useState('all');
|
||||
const [sort, setSort] = useState<SortKey>('date');
|
||||
const [limit, setLimit] = useState(PAGE_SIZE);
|
||||
|
||||
const years = useActivityYears();
|
||||
const dateOptions = [...DATE_PRESETS, ...years.map(y => ({ value: y, label: y }))];
|
||||
|
||||
const { dateFrom, dateTo } = computeDateRange(datePre);
|
||||
const filter: ActivityFilter = { sport, dateFrom, dateTo, sort };
|
||||
const activities = useFilteredActivities(filter, limit);
|
||||
const total = useFilteredCount(filter);
|
||||
const hasMore = activities.length < total;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={styles.header}>Filter</Text>
|
||||
{total > 0 && <Text style={styles.count}>{total} activities</Text>}
|
||||
</View>
|
||||
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}
|
||||
style={styles.pillScroll} contentContainerStyle={styles.pillRow}>
|
||||
{SPORTS.map(s => (
|
||||
<Pill key={s.value} label={s.label} active={sport === s.value} accent={theme.accent}
|
||||
onPress={() => { setSport(s.value); setLimit(PAGE_SIZE); }} />
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}
|
||||
style={styles.pillScroll} contentContainerStyle={styles.pillRow}>
|
||||
{dateOptions.map(d => (
|
||||
<Pill key={d.value} label={d.label} active={datePre === d.value} accent={theme.accent}
|
||||
onPress={() => { setDatePre(d.value); setLimit(PAGE_SIZE); }} />
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.sortRow}>
|
||||
{SORTS.map(s => (
|
||||
<Pressable key={s.value}
|
||||
style={[styles.sortBtn, sort === s.value && { borderBottomColor: theme.accent, borderBottomWidth: 2 }]}
|
||||
onPress={() => { setSort(s.value); setLimit(PAGE_SIZE); }}>
|
||||
<Text style={[styles.sortText, sort === s.value && { color: theme.accent }]}>{s.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyText}>No activities match</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
style={{ flex: 1 }}
|
||||
data={activities}
|
||||
keyExtractor={a => a.id}
|
||||
renderItem={({ item }) => (
|
||||
<ActivityCard activity={item} selecting={false} checked={false}
|
||||
onToggleSelect={() => {}} onLongPress={() => {}} />
|
||||
)}
|
||||
contentContainerStyle={styles.list}
|
||||
onEndReached={() => { if (hasMore) setLimit(l => l + PAGE_SIZE); }}
|
||||
onEndReachedThreshold={0.3}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({ label, active, accent, onPress }: {
|
||||
label: string; active: boolean; accent: string; onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.pill, active && { backgroundColor: accent + '33', borderColor: accent }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text style={[styles.pillText, active && { color: accent }]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#09090b' },
|
||||
headerRow: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
|
||||
},
|
||||
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
|
||||
count: { color: '#71717a', fontSize: 13 },
|
||||
pillScroll: { flexGrow: 0, flexShrink: 0 },
|
||||
pillRow: { flexDirection: 'row', gap: 8, paddingHorizontal: 16, paddingBottom: 10 },
|
||||
pill: {
|
||||
borderRadius: 20, borderWidth: 1, borderColor: '#3f3f46',
|
||||
paddingHorizontal: 14, paddingVertical: 7,
|
||||
},
|
||||
pillText: { color: '#a1a1aa', fontSize: 13, fontWeight: '500' },
|
||||
sortRow: { flexDirection: 'row', paddingHorizontal: 16, marginBottom: 4 },
|
||||
sortBtn: { marginRight: 24, paddingBottom: 8, borderBottomWidth: 2, borderBottomColor: 'transparent' },
|
||||
sortText: { color: '#71717a', fontSize: 13, fontWeight: '600' },
|
||||
list: { padding: 16, gap: 12, paddingBottom: 80 },
|
||||
empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
emptyText: { color: '#52525b', fontSize: 15 },
|
||||
});
|
||||
@@ -1,388 +0,0 @@
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet,
|
||||
Text, TextInput, View,
|
||||
} from 'react-native';
|
||||
import { deleteRemoteActivities, getSetting, setSetting, useSetting } from '@/db/queries';
|
||||
import { PALETTES, type PaletteKey } from '@/theme';
|
||||
import { useTheme, usePaletteControl } from '@/ThemeContext';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const db = useSQLiteContext();
|
||||
|
||||
const storedUrl = useSetting('instance_url') ?? '';
|
||||
const storedHandle = useSetting('handle') ?? '';
|
||||
const storedPath = useSetting('auto_import_path') ?? '';
|
||||
const storedToken = useSetting('api_token');
|
||||
const storedSyncMode = (useSetting('sync_mode') ?? 'summaries') as 'summaries' | 'full';
|
||||
const storedSyncUpload = useSetting('sync_upload') === 'true';
|
||||
const storedUploadFormat = (useSetting('upload_format') ?? 'raw') as 'raw' | 'bas';
|
||||
|
||||
const [instanceUrl, setInstanceUrl] = useState(storedUrl);
|
||||
const [handle, setHandle] = useState(storedHandle);
|
||||
const [autoPath, setAutoPath] = useState(storedPath);
|
||||
const [syncMode, setSyncMode] = useState(storedSyncMode);
|
||||
const [syncUpload, setSyncUpload] = useState(storedSyncUpload);
|
||||
const [uploadFormat, setUploadFormat] = useState(storedUploadFormat);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const theme = useTheme();
|
||||
const { paletteKey: palette, setPaletteOverride } = usePaletteControl();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [connectMsg, setConnectMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
const [resetArmed, setResetArmed] = useState(false);
|
||||
const [resetMsg, setResetMsg] = useState<string | null>(null);
|
||||
|
||||
async function save() {
|
||||
await setSetting(db, 'instance_url', instanceUrl.trim());
|
||||
await setSetting(db, 'handle', handle.trim());
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const url = instanceUrl.trim().replace(/\/$/, '');
|
||||
const h = handle.trim();
|
||||
if (!url || !h || !password) {
|
||||
setConnectMsg({ ok: false, text: 'Fill in URL, handle, and password first.' });
|
||||
return;
|
||||
}
|
||||
setConnecting(true);
|
||||
setConnectMsg(null);
|
||||
try {
|
||||
const resp = await fetch(`${url}/api/auth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ handle: h, password }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
setConnectMsg({ ok: false, text: err.detail ?? `Error ${resp.status}` });
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
await setSetting(db, 'instance_url', url);
|
||||
await setSetting(db, 'handle', h);
|
||||
await setSetting(db, 'api_token', data.token);
|
||||
setPassword('');
|
||||
setConnectMsg({ ok: true, text: `Connected as ${data.display_name || h}` });
|
||||
} catch {
|
||||
setConnectMsg({ ok: false, text: 'Could not reach instance — check the URL.' });
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
await setSetting(db, 'api_token', '');
|
||||
setConnectMsg(null);
|
||||
}
|
||||
|
||||
async function resetSyncedData() {
|
||||
if (!resetArmed) {
|
||||
setResetArmed(true);
|
||||
return;
|
||||
}
|
||||
const n = await deleteRemoteActivities(db);
|
||||
setResetArmed(false);
|
||||
setResetMsg(`Removed ${n} synced ${n === 1 ? 'activity' : 'activities'}`);
|
||||
setTimeout(() => setResetMsg(null), 3000);
|
||||
}
|
||||
|
||||
const isConnected = !!storedToken;
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.header}>Settings</Text>
|
||||
|
||||
<Section title="Instance">
|
||||
<Field
|
||||
label="Instance URL"
|
||||
placeholder="https://bincio.org"
|
||||
value={instanceUrl}
|
||||
onChangeText={setInstanceUrl}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
<Field
|
||||
label="Handle"
|
||||
placeholder="yourhandle"
|
||||
value={handle}
|
||||
onChangeText={setHandle}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
Connect to a Bincio instance to sync your activities. Leave blank to use
|
||||
the app offline only.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Pressable style={styles.saveButton} onPress={save}>
|
||||
<Text style={styles.saveButtonText}>
|
||||
{saved ? '✓ Saved' : 'Save'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Section title="Connection">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Row label="Status" value={`Connected as ${storedHandle || '—'}`} />
|
||||
<Pressable style={styles.disconnectButton} onPress={disconnect}>
|
||||
<Text style={styles.disconnectText}>Disconnect</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field
|
||||
label="Password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
autoCapitalize="none"
|
||||
secureTextEntry
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.connectButton, connecting && styles.buttonDisabled]}
|
||||
onPress={connecting ? undefined : connect}
|
||||
>
|
||||
{connecting
|
||||
? <ActivityIndicator color="#fff" size="small" />
|
||||
: <Text style={styles.connectText}>Connect</Text>}
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
{connectMsg && (
|
||||
<Text style={connectMsg.ok ? styles.msgOk : styles.msgErr}>
|
||||
{connectMsg.text}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.hint}>
|
||||
Your password is used once to obtain a session token, then forgotten.
|
||||
The token is stored locally and sent with each sync request.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{Platform.OS === 'android' && (
|
||||
<Section title="Auto-import (Android)">
|
||||
{!storedUrl ? (
|
||||
<Text style={[styles.hint, styles.hintWarn]}>
|
||||
Configure and save a Bincio instance URL above first — it's needed to download the extraction engine.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Field
|
||||
label="Watch directory"
|
||||
placeholder="/sdcard/FitFiles"
|
||||
value={autoPath}
|
||||
onChangeText={setAutoPath}
|
||||
onBlur={() => setSetting(db, 'auto_import_path', autoPath.trim())}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
New FIT files in this folder are imported automatically when you
|
||||
open the app. Leave blank to disable. Requires storage permission.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Sync">
|
||||
<Text style={styles.subLabel}>Download</Text>
|
||||
<View style={styles.modeRow}>
|
||||
<ModeButton label="Summaries only" active={syncMode === 'summaries'} accent={theme.accent} dim={theme.dim}
|
||||
onPress={() => { setSyncMode('summaries'); setSetting(db, 'sync_mode', 'summaries'); }} />
|
||||
<ModeButton label="Full data" active={syncMode === 'full'} accent={theme.accent} dim={theme.dim}
|
||||
onPress={() => { setSyncMode('full'); setSetting(db, 'sync_mode', 'full'); }} />
|
||||
</View>
|
||||
<Text style={styles.hint}>
|
||||
{syncMode === 'full'
|
||||
? 'Downloads map route and elevation chart for every activity during sync. Uses more storage and takes longer.'
|
||||
: 'Syncs activity summaries only. Map and chart are fetched on demand when you open an activity.'}
|
||||
</Text>
|
||||
<Text style={[styles.subLabel, { borderTopWidth: 1, borderTopColor: '#27272a', paddingTop: 12 }]}>Upload</Text>
|
||||
<View style={styles.modeRow}>
|
||||
<ModeButton label="Off" active={!syncUpload} accent={theme.accent} dim={theme.dim}
|
||||
onPress={() => { setSyncUpload(false); setSetting(db, 'sync_upload', 'false'); }} />
|
||||
<ModeButton label="Upload local activities" active={syncUpload} accent={theme.accent} dim={theme.dim}
|
||||
onPress={() => { setSyncUpload(true); setSetting(db, 'sync_upload', 'true'); }} />
|
||||
</View>
|
||||
<Text style={styles.hint}>
|
||||
{syncUpload
|
||||
? 'Local activities are uploaded to the instance during sync.'
|
||||
: 'Local activities stay on device only.'}
|
||||
</Text>
|
||||
<Text style={[styles.subLabel, { borderTopWidth: 1, borderTopColor: '#27272a', paddingTop: 12 }]}>Upload format</Text>
|
||||
<View style={styles.modeRow}>
|
||||
<ModeButton label="Original file" active={uploadFormat === 'raw'} accent={theme.accent} dim={theme.dim}
|
||||
onPress={() => { setUploadFormat('raw'); setSetting(db, 'upload_format', 'raw'); }} />
|
||||
<ModeButton label="Extracted JSON" active={uploadFormat === 'bas'} accent={theme.accent} dim={theme.dim}
|
||||
onPress={() => { setUploadFormat('bas'); setSetting(db, 'upload_format', 'bas'); }} />
|
||||
</View>
|
||||
<Text style={styles.hint}>
|
||||
{uploadFormat === 'raw'
|
||||
? 'Uploads the original FIT/GPX/TCX file. The server re-extracts it with DEM elevation correction and updates your local copy.'
|
||||
: 'Uploads the pre-extracted JSON. Faster, but no DEM elevation correction.'}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section title="Palette">
|
||||
<Text style={[styles.hint, { paddingBottom: 0 }]}>
|
||||
Auto-switches to race colours during Giro, Tour, and Vuelta. Override here for testing.
|
||||
</Text>
|
||||
<View style={styles.modeRow}>
|
||||
{(['auto', 'default', 'giro', 'tour', 'vuelta'] as PaletteKey[]).map(key => {
|
||||
const label = key === 'auto' ? 'Auto' : PALETTES[key as keyof typeof PALETTES].label;
|
||||
const keyAccent = key === 'auto' ? theme.accent : PALETTES[key as keyof typeof PALETTES].accent;
|
||||
const keyDim = key === 'auto' ? theme.dim : PALETTES[key as keyof typeof PALETTES].dim;
|
||||
return (
|
||||
<ModeButton
|
||||
key={key}
|
||||
label={label}
|
||||
active={palette === key}
|
||||
accent={keyAccent}
|
||||
dim={keyDim}
|
||||
onPress={() => setPaletteOverride(key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Section>
|
||||
|
||||
<Section title="Data">
|
||||
<Pressable
|
||||
style={[styles.resetButton, resetArmed && styles.resetButtonArmed]}
|
||||
onPress={resetSyncedData}
|
||||
onBlur={() => setResetArmed(false)}
|
||||
>
|
||||
<Text style={[styles.resetText, resetArmed && styles.resetTextArmed]}>
|
||||
{resetArmed ? 'Tap again to confirm' : 'Reset synced data'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{resetMsg && <Text style={styles.msgOk}>{resetMsg}</Text>}
|
||||
<Text style={styles.hint}>
|
||||
Removes all activities synced from the instance. Locally imported files are kept.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section title="About">
|
||||
<Row label="Version" value="0.1.0 (Phase 0.5)" />
|
||||
<Row label="Schema" value="BAS 1.0" />
|
||||
</Section>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
<View style={styles.sectionBody}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label, placeholder, value, onChangeText, ...rest
|
||||
}: {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChangeText: (v: string) => void;
|
||||
[key: string]: unknown;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#52525b"
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
{...rest}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeButton({ label, active, accent, dim, onPress }: {
|
||||
label: string; active: boolean; accent: string; dim: string; onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.modeButton, active && { backgroundColor: dim, borderColor: accent }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text style={[styles.modeButtonText, active && { color: accent }]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{label}</Text>
|
||||
<Text style={styles.rowValue}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#09090b' },
|
||||
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
|
||||
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 24 },
|
||||
section: { marginBottom: 28 },
|
||||
sectionTitle: {
|
||||
color: '#a1a1aa', fontSize: 11, fontWeight: '600',
|
||||
letterSpacing: 0.8, marginBottom: 8,
|
||||
},
|
||||
sectionBody: {
|
||||
backgroundColor: '#18181b', borderRadius: 10,
|
||||
borderWidth: 1, borderColor: '#27272a', overflow: 'hidden',
|
||||
},
|
||||
field: { padding: 14, borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
||||
fieldLabel: { color: '#71717a', fontSize: 11, marginBottom: 4 },
|
||||
input: { color: '#f4f4f5', fontSize: 15 },
|
||||
hint: { color: '#52525b', fontSize: 12, lineHeight: 16, padding: 12 },
|
||||
hintWarn: { color: '#a16207' },
|
||||
row: {
|
||||
flexDirection: 'row', justifyContent: 'space-between',
|
||||
paddingHorizontal: 14, paddingVertical: 12,
|
||||
borderBottomWidth: 1, borderBottomColor: '#27272a',
|
||||
},
|
||||
rowLabel: { color: '#a1a1aa', fontSize: 14 },
|
||||
rowValue: { color: '#71717a', fontSize: 14 },
|
||||
saveButton: {
|
||||
backgroundColor: '#2563eb', borderRadius: 10,
|
||||
paddingVertical: 14, alignItems: 'center', marginBottom: 28,
|
||||
},
|
||||
saveButtonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||
connectButton: {
|
||||
backgroundColor: '#059669', borderRadius: 8, margin: 12,
|
||||
paddingVertical: 12, alignItems: 'center',
|
||||
},
|
||||
connectText: { color: '#fff', fontWeight: '600', fontSize: 15 },
|
||||
buttonDisabled: { opacity: 0.5 },
|
||||
disconnectButton: {
|
||||
margin: 12, paddingVertical: 10, alignItems: 'center',
|
||||
borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46',
|
||||
},
|
||||
disconnectText: { color: '#71717a', fontSize: 14 },
|
||||
msgOk: { color: '#86efac', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
|
||||
msgErr: { color: '#fca5a5', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
|
||||
subLabel: { color: '#52525b', fontSize: 11, fontWeight: '600', letterSpacing: 0.6, paddingHorizontal: 12, paddingTop: 12, paddingBottom: 4 },
|
||||
modeRow: { flexDirection: 'row', gap: 8, padding: 12 },
|
||||
modeButton: { flex: 1, paddingVertical: 9, borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46', alignItems: 'center' },
|
||||
modeButtonText: { color: '#71717a', fontSize: 13, fontWeight: '500' },
|
||||
resetButton: {
|
||||
margin: 12, paddingVertical: 10, alignItems: 'center',
|
||||
borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46',
|
||||
},
|
||||
resetButtonArmed: { borderColor: '#ef4444', backgroundColor: '#1c0a0a' },
|
||||
resetText: { color: '#71717a', fontSize: 14 },
|
||||
resetTextArmed: { color: '#ef4444', fontWeight: '600' },
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { SQLiteProvider } from 'expo-sqlite';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { migrateDb } from '@/db';
|
||||
import { ThemeProvider } from '@/ThemeContext';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<SQLiteProvider databaseName="bincio.db" onInit={migrateDb}>
|
||||
<ThemeProvider>
|
||||
<StatusBar style="light" />
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
</ThemeProvider>
|
||||
</SQLiteProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,531 +0,0 @@
|
||||
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { deleteActivity, setActivityTitle, useActivity, useSetting } from '@/db/queries';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
|
||||
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Timeseries = {
|
||||
t: number[];
|
||||
elevation_m: (number | null)[];
|
||||
speed_kmh?: (number | null)[] | null;
|
||||
hr_bpm?: (number | null)[] | null;
|
||||
cadence_rpm?: (number | null)[] | null;
|
||||
power_w?: (number | null)[] | null;
|
||||
lat?: (number | null)[] | null;
|
||||
lon?: (number | null)[] | null;
|
||||
};
|
||||
|
||||
// ── Screen ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ActivityScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const db = useSQLiteContext();
|
||||
const theme = useTheme();
|
||||
const row = useActivity(id);
|
||||
const instanceUrl = useSetting('instance_url')?.replace(/\/$/, '') ?? '';
|
||||
const token = useSetting('api_token') ?? '';
|
||||
|
||||
const [geojson, setGeojson] = useState<object | null>(null);
|
||||
const [timeseries, setTimeseries] = useState<Timeseries | null>(null);
|
||||
const [loadingMap, setLoadingMap] = useState(false);
|
||||
const [loadingChart, setLoadingChart] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState('');
|
||||
|
||||
async function confirmDelete() {
|
||||
Alert.alert(
|
||||
'Delete activity',
|
||||
'This will permanently remove this activity from your device.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
const originalPath = await deleteActivity(db, id);
|
||||
if (originalPath) {
|
||||
try { await FileSystem.deleteAsync(originalPath, { idempotent: true }); } catch {}
|
||||
}
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// instanceUrl and token are in the dep array to avoid a stale-closure bug in
|
||||
// release builds: Hermes executes effects sooner and captures empty strings if
|
||||
// the deps are omitted. Guards on geojson/timeseries prevent double-fetching.
|
||||
useEffect(() => {
|
||||
if (!row) return;
|
||||
|
||||
if (row.geojson) {
|
||||
setGeojson(JSON.parse(row.geojson));
|
||||
} else if (row.origin === 'remote' && instanceUrl && token) {
|
||||
setLoadingMap(true);
|
||||
fetch(`${instanceUrl}/api/activity/${row.id}/geojson`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data) setGeojson(data); })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingMap(false));
|
||||
}
|
||||
|
||||
if (row.timeseries_json) {
|
||||
setTimeseries(JSON.parse(row.timeseries_json));
|
||||
} else if (row.origin === 'remote' && instanceUrl && token) {
|
||||
setLoadingChart(true);
|
||||
fetch(`${instanceUrl}/api/activity/${row.id}/timeseries`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data) setTimeseries(data); })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingChart(false));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [row?.id, instanceUrl, token]);
|
||||
|
||||
if (!row) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.notFound}>Activity not found</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const detail = JSON.parse(row.detail_json);
|
||||
const edits = row.edits_json ? JSON.parse(row.edits_json) : {};
|
||||
const displayTitle = edits.title ?? detail.title;
|
||||
const canEdit = row.origin === 'local';
|
||||
const km = detail.distance_m != null ? (detail.distance_m / 1000).toFixed(2) : null;
|
||||
const elev = detail.elevation_gain_m != null ? Math.round(detail.elevation_gain_m) : null;
|
||||
const elevLoss = detail.elevation_loss_m != null ? Math.round(Math.abs(detail.elevation_loss_m)) : null;
|
||||
const movingTime = detail.moving_time_s != null ? formatDuration(detail.moving_time_s) : null;
|
||||
const speed = detail.avg_speed_kmh != null ? detail.avg_speed_kmh.toFixed(1) : null;
|
||||
const hr = detail.avg_hr_bpm != null ? Math.round(detail.avg_hr_bpm) : null;
|
||||
const power = detail.avg_power_w != null ? Math.round(detail.avg_power_w) : null;
|
||||
const date = new Date(detail.started_at).toLocaleDateString(undefined, {
|
||||
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<View style={styles.topBar}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<Text style={[styles.backText, { color: theme.accent }]}>← Back</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.deleteButton} onPress={confirmDelete}>
|
||||
<Text style={styles.deleteText}>Delete</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sport}>{detail.sport ?? 'Activity'}</Text>
|
||||
{editingTitle ? (
|
||||
<TextInput
|
||||
style={styles.titleInput}
|
||||
value={titleDraft}
|
||||
onChangeText={setTitleDraft}
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onEndEditing={(e) => {
|
||||
const trimmed = e.nativeEvent.text.trim();
|
||||
if (trimmed && trimmed !== displayTitle) {
|
||||
setActivityTitle(db, id, trimmed);
|
||||
}
|
||||
setEditingTitle(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={canEdit ? () => { setTitleDraft(displayTitle); setEditingTitle(true); } : undefined}
|
||||
style={styles.titleRow}
|
||||
>
|
||||
<Text style={styles.title}>{displayTitle}</Text>
|
||||
{canEdit && <Text style={styles.editHint}>✎</Text>}
|
||||
</Pressable>
|
||||
)}
|
||||
<Text style={styles.date}>{date}</Text>
|
||||
|
||||
{/* Map */}
|
||||
<RouteMap geojson={geojson} loading={loadingMap} accent={theme.accent} />
|
||||
|
||||
{/* Stats grid */}
|
||||
<View style={styles.grid}>
|
||||
{km && <StatCell label="Distance" value={km} unit="km" />}
|
||||
{movingTime && <StatCell label="Moving time" value={movingTime} unit="" />}
|
||||
{elev != null && <StatCell label="Elev gain" value={String(elev)} unit="m" />}
|
||||
{elevLoss != null && <StatCell label="Elev loss" value={String(elevLoss)} unit="m" />}
|
||||
{speed && <StatCell label="Avg speed" value={speed} unit="km/h"/>}
|
||||
{hr && <StatCell label="Avg HR" value={String(hr)} unit="bpm" />}
|
||||
{power && <StatCell label="Avg power" value={String(power)} unit="W" />}
|
||||
</View>
|
||||
|
||||
{/* Metric charts */}
|
||||
<MetricCharts timeseries={timeseries} loading={loadingChart} accent={theme.accent} />
|
||||
|
||||
{/* Meta */}
|
||||
<View style={styles.meta}>
|
||||
<MetaRow label="Source" value={detail.source ?? '—'} />
|
||||
<MetaRow label="Device" value={detail.device ?? '—'} />
|
||||
<MetaRow label="Origin" value={row.origin} />
|
||||
<MetaRow label="Synced" value={row.synced_at ? new Date(row.synced_at * 1000).toLocaleDateString() : 'No'} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Map ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function RouteMap({ geojson, loading, accent }: { geojson: object | null; loading: boolean; accent: string }) {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [currentZoom, setCurrentZoom] = useState(12);
|
||||
const cameraRef = useRef<any>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.mapPlaceholder}>
|
||||
<Text style={{ color: accent, fontSize: 13 }}>Loading map…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!geojson) return null;
|
||||
|
||||
// MapLibre uses OpenGL/SurfaceView which crashes the Karoo's Qualcomm GPU
|
||||
// driver (Android <29) even without any interaction. Render a pure SVG route
|
||||
// trace instead — no native GL surface, no crash.
|
||||
if (Platform.OS === 'android' && (Platform.Version as number) < 29) {
|
||||
return <SvgRouteView geojson={geojson} accent={accent} />;
|
||||
}
|
||||
|
||||
const bounds = geoJsonBounds(geojson);
|
||||
const routeSource = (
|
||||
<GeoJSONSource id="route" data={geojson as GeoJSON.FeatureCollection}>
|
||||
<Layer
|
||||
type="line"
|
||||
id="route-line"
|
||||
paint={{ 'line-color': accent, 'line-width': 3 }}
|
||||
layout={{ 'line-cap': 'round', 'line-join': 'round' }}
|
||||
/>
|
||||
</GeoJSONSource>
|
||||
);
|
||||
const cameraBounds = bounds
|
||||
? { bounds, padding: { top: 24, bottom: 24, left: 24, right: 24 } }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Thumbnail — tap to expand */}
|
||||
<Pressable style={styles.mapContainer} onPress={() => setFullscreen(true)}>
|
||||
<Map style={styles.map} mapStyle={MAP_STYLE} dragPan={false} touchZoom={false} touchPitch={false} touchRotate={false}>
|
||||
{cameraBounds && <Camera initialViewState={cameraBounds} />}
|
||||
{routeSource}
|
||||
</Map>
|
||||
<View style={styles.mapExpandHint}>
|
||||
<Text style={styles.mapExpandText}>⤢ tap to explore</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Full-screen map with +/- zoom buttons */}
|
||||
<Modal visible={fullscreen} animationType="slide" onRequestClose={() => setFullscreen(false)}>
|
||||
<View style={styles.fullscreenMap}>
|
||||
<Map
|
||||
style={styles.map}
|
||||
mapStyle={MAP_STYLE}
|
||||
onRegionDidChange={(e: any) => {
|
||||
const z = e?.properties?.zoomLevel;
|
||||
if (typeof z === 'number') setCurrentZoom(z);
|
||||
}}
|
||||
>
|
||||
{cameraBounds && <Camera ref={cameraRef} initialViewState={cameraBounds} />}
|
||||
{routeSource}
|
||||
</Map>
|
||||
<Pressable style={styles.closeButton} onPress={() => setFullscreen(false)}>
|
||||
<Text style={styles.closeText}>✕</Text>
|
||||
</Pressable>
|
||||
<View style={styles.zoomButtons}>
|
||||
<Pressable style={styles.zoomBtn} onPress={() => cameraRef.current?.setCamera({ zoomLevel: currentZoom + 1, animationDuration: 200 })}>
|
||||
<Text style={styles.zoomBtnText}>+</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.zoomBtn} onPress={() => cameraRef.current?.setCamera({ zoomLevel: Math.max(1, currentZoom - 1), animationDuration: 200 })}>
|
||||
<Text style={styles.zoomBtnText}>−</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// SVG route trace — used on Android <29 where MapLibre crashes the GPU driver.
|
||||
// Renders the GPS track as a colored path on a dark background with no tiles.
|
||||
function SvgRouteView({ geojson, accent }: { geojson: object; accent: string }) {
|
||||
const W = 320;
|
||||
const H = 180;
|
||||
const PAD = 16;
|
||||
|
||||
const all: [number, number][] = [];
|
||||
function collect(obj: unknown) {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
const o = obj as Record<string, unknown>;
|
||||
if (o.type === 'Feature') { collect(o.geometry); return; }
|
||||
if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; }
|
||||
if (o.type === 'LineString') { all.push(...(o.coordinates as [number, number][])); return; }
|
||||
if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => all.push(...c)); return; }
|
||||
}
|
||||
collect(geojson);
|
||||
if (!all.length) return null;
|
||||
|
||||
const step = Math.max(1, Math.floor(all.length / 500));
|
||||
const pts = all.filter((_, i) => i % step === 0);
|
||||
|
||||
const lons = pts.map(c => c[0]);
|
||||
const lats = pts.map(c => c[1]);
|
||||
const minLon = Math.min(...lons), maxLon = Math.max(...lons);
|
||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
|
||||
const spanLon = maxLon - minLon || 0.001;
|
||||
const spanLat = maxLat - minLat || 0.001;
|
||||
|
||||
// Correct longitude for latitude (equirectangular)
|
||||
const midLat = (minLat + maxLat) / 2;
|
||||
const lonFactor = Math.cos((midLat * Math.PI) / 180);
|
||||
const adjLon = spanLon * lonFactor;
|
||||
|
||||
const scale = Math.min((W - PAD * 2) / adjLon, (H - PAD * 2) / spanLat);
|
||||
const offX = (W - adjLon * scale) / 2;
|
||||
const offY = (H - spanLat * scale) / 2;
|
||||
|
||||
const toX = (lon: number) => offX + (lon - minLon) * lonFactor * scale;
|
||||
const toY = (lat: number) => H - offY - (lat - minLat) * scale;
|
||||
|
||||
const d = pts.map((c, i) => `${i === 0 ? 'M' : 'L'}${toX(c[0]).toFixed(1)},${toY(c[1]).toFixed(1)}`).join(' ');
|
||||
|
||||
return (
|
||||
<View style={[styles.mapContainer, { alignItems: 'center', justifyContent: 'center' }]}>
|
||||
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
|
||||
<Path d={d} fill="none" stroke={accent} strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Metric charts ─────────────────────────────────────────────────────────────
|
||||
|
||||
type TabKey = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power';
|
||||
|
||||
const TAB_META: Record<TabKey, { label: string; unit: string; color: string; decimals: number }> = {
|
||||
elevation: { label: 'Elevation', unit: 'm', color: '#00c8ff', decimals: 0 },
|
||||
speed: { label: 'Speed', unit: 'km/h', color: '#ff6b35', decimals: 1 },
|
||||
hr: { label: 'HR', unit: 'bpm', color: '#f87171', decimals: 0 },
|
||||
cadence: { label: 'Cadence', unit: 'rpm', color: '#a78bfa', decimals: 0 },
|
||||
power: { label: 'Power', unit: 'W', color: '#facc15', decimals: 0 },
|
||||
};
|
||||
|
||||
function MetricCharts({ timeseries, loading, accent }: { timeseries: Timeseries | null; loading: boolean; accent: string }) {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('elevation');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.chartPlaceholder}>
|
||||
<Text style={{ color: accent, fontSize: 13 }}>Loading chart…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!timeseries) return null;
|
||||
|
||||
const seriesMap: Record<TabKey, (number | null)[] | null | undefined> = {
|
||||
elevation: timeseries.elevation_m,
|
||||
speed: timeseries.speed_kmh,
|
||||
hr: timeseries.hr_bpm,
|
||||
cadence: timeseries.cadence_rpm,
|
||||
power: timeseries.power_w,
|
||||
};
|
||||
|
||||
const available = (Object.keys(TAB_META) as TabKey[]).filter(
|
||||
k => seriesMap[k]?.some(v => v != null)
|
||||
);
|
||||
|
||||
if (!available.length) return null;
|
||||
|
||||
const tab = available.includes(activeTab) ? activeTab : available[0];
|
||||
const { color, unit, decimals } = TAB_META[tab];
|
||||
const raw = seriesMap[tab]!;
|
||||
|
||||
return (
|
||||
<View style={styles.chartContainer}>
|
||||
{/* Tab row */}
|
||||
<View style={styles.chartTabs}>
|
||||
{available.map(k => (
|
||||
<Pressable
|
||||
key={k}
|
||||
style={[styles.chartTab, tab === k && { borderBottomColor: TAB_META[k].color, borderBottomWidth: 2 }]}
|
||||
onPress={() => setActiveTab(k)}
|
||||
>
|
||||
<Text style={[styles.chartTabText, tab === k && { color: TAB_META[k].color }]}>
|
||||
{TAB_META[k].label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
{/* Chart */}
|
||||
<MetricChart key={tab} times={timeseries.t} values={raw} color={color} unit={unit} decimals={decimals} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricChart({
|
||||
times, values, color, unit, decimals,
|
||||
}: {
|
||||
times: number[];
|
||||
values: (number | null)[];
|
||||
color: string;
|
||||
unit: string;
|
||||
decimals: number;
|
||||
}) {
|
||||
const W = 340;
|
||||
const H = 100;
|
||||
const PAD = 4;
|
||||
|
||||
// Downsample to ≤300 points
|
||||
const step = Math.max(1, Math.floor(values.length / 300));
|
||||
const ts = times.filter((_, i) => i % step === 0);
|
||||
const vs = values.filter((_, i) => i % step === 0).map(v => v ?? 0);
|
||||
|
||||
const minV = Math.min(...vs);
|
||||
const maxV = Math.max(...vs);
|
||||
const range = maxV - minV || 1;
|
||||
const maxT = ts[ts.length - 1] || 1;
|
||||
|
||||
const x = (t: number) => PAD + (t / maxT) * (W - PAD * 2);
|
||||
const y = (v: number) => PAD + (1 - (v - minV) / range) * (H - PAD * 2);
|
||||
|
||||
const pts = ts.map((t, i) => `${x(t).toFixed(1)},${y(vs[i]).toFixed(1)}`);
|
||||
const linePath = `M ${pts.join(' L ')}`;
|
||||
const areaPath = `M ${x(ts[0])},${H} L ${pts.join(' L ')} L ${x(maxT)},${H} Z`;
|
||||
const gradId = `grad-${color.replace('#', '')}`;
|
||||
|
||||
const fmt = (v: number) => decimals === 0 ? String(Math.round(v)) : v.toFixed(decimals);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text style={[styles.chartLabel, { color }]}>{fmt(maxV)} {unit}</Text>
|
||||
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
|
||||
<Defs>
|
||||
<LinearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<Stop offset="0" stopColor={color} stopOpacity="0.35" />
|
||||
<Stop offset="1" stopColor={color} stopOpacity="0.02" />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
<Path d={areaPath} fill={`url(#${gradId})`} />
|
||||
<Path d={linePath} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
<Text style={[styles.chartLabel, { color: '#3f3f46', marginBottom: 10 }]}>{fmt(minV)} {unit}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Returns [west, south, east, north] per LngLatBounds spec
|
||||
function geoJsonBounds(gj: object): [number, number, number, number] | null {
|
||||
const coords: [number, number][] = [];
|
||||
function collect(obj: unknown) {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
const o = obj as Record<string, unknown>;
|
||||
if (o.type === 'Feature') { collect(o.geometry); return; }
|
||||
if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; }
|
||||
if (o.type === 'LineString') { coords.push(...(o.coordinates as [number, number][])); return; }
|
||||
if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => coords.push(...c)); return; }
|
||||
}
|
||||
collect(gj);
|
||||
if (!coords.length) return null;
|
||||
const lons = coords.map(c => c[0]);
|
||||
const lats = coords.map(c => c[1]);
|
||||
return [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function StatCell({ label, value, unit }: { label: string; value: string; unit: string }) {
|
||||
return (
|
||||
<View style={styles.statCell}>
|
||||
<View style={styles.statValueRow}>
|
||||
<Text style={styles.statValue}>{value}</Text>
|
||||
{unit ? <Text style={styles.statUnit}>{unit}</Text> : null}
|
||||
</View>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{label}</Text>
|
||||
<Text style={styles.metaValue}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#09090b' },
|
||||
content: { paddingBottom: 40 },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#09090b' },
|
||||
notFound: { color: '#71717a', fontSize: 16 },
|
||||
topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingTop: 60, paddingBottom: 12 },
|
||||
backButton: { paddingHorizontal: 16 },
|
||||
backText: { fontSize: 15 },
|
||||
deleteButton: { paddingHorizontal: 16 },
|
||||
deleteText: { color: '#f87171', fontSize: 15 },
|
||||
sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 },
|
||||
titleRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, marginBottom: 4 },
|
||||
title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', flexShrink: 1 },
|
||||
titleInput: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4, borderBottomWidth: 1, borderBottomColor: '#3b82f6' },
|
||||
editHint: { color: '#52525b', fontSize: 16, marginLeft: 8 },
|
||||
date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 },
|
||||
mapContainer: { height: 220, marginBottom: 16, borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a' },
|
||||
map: { flex: 1 },
|
||||
mapPlaceholder: { height: 220, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a', marginBottom: 16 },
|
||||
mapExpandHint: { position: 'absolute', bottom: 8, right: 8, backgroundColor: 'rgba(0,0,0,0.55)', borderRadius: 6, paddingHorizontal: 8, paddingVertical: 4 },
|
||||
mapExpandText: { color: '#a1a1aa', fontSize: 11 },
|
||||
fullscreenMap: { flex: 1, backgroundColor: '#09090b' },
|
||||
closeButton: { position: 'absolute', top: 56, right: 16, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
||||
closeText: { color: '#fff', fontSize: 16 },
|
||||
zoomButtons: { position: 'absolute', bottom: 40, right: 16, gap: 8 },
|
||||
zoomBtn: { backgroundColor: 'rgba(0,0,0,0.65)', borderRadius: 20, width: 40, height: 40, alignItems: 'center', justifyContent: 'center' },
|
||||
zoomBtnText: { color: '#fff', fontSize: 22, fontWeight: '600', lineHeight: 28 },
|
||||
chartContainer: { marginHorizontal: 16, marginBottom: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', overflow: 'hidden' },
|
||||
chartPlaceholder: { height: 120, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', marginHorizontal: 16, marginBottom: 16 },
|
||||
chartTabs: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
||||
chartTab: { flex: 1, paddingVertical: 8, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' },
|
||||
chartTabText: { color: '#52525b', fontSize: 11, fontWeight: '600' },
|
||||
chartLabel: { color: '#3f3f46', fontSize: 10, marginBottom: 2, marginHorizontal: 12, marginTop: 10 },
|
||||
grid: { flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: 12, gap: 8, marginBottom: 16 },
|
||||
statCell: { backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', padding: 14, width: '47%' },
|
||||
statValueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginBottom: 4 },
|
||||
statValue: { color: '#f4f4f5', fontSize: 24, fontWeight: '700' },
|
||||
statUnit: { color: '#71717a', fontSize: 13 },
|
||||
statLabel: { color: '#71717a', fontSize: 12 },
|
||||
meta: { marginHorizontal: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a' },
|
||||
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 14, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
||||
metaLabel: { color: '#71717a', fontSize: 13 },
|
||||
metaValue: { color: '#a1a1aa', fontSize: 13 },
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,6 +0,0 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import type { ActivitySummary } from '@/db/queries';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
|
||||
export function ActivityCard({
|
||||
activity,
|
||||
selecting,
|
||||
checked,
|
||||
onToggleSelect,
|
||||
onLongPress,
|
||||
}: {
|
||||
activity: ActivitySummary;
|
||||
selecting: boolean;
|
||||
checked: boolean;
|
||||
onToggleSelect: () => void;
|
||||
onLongPress: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
const km = activity.distance_m != null ? (activity.distance_m / 1000).toFixed(1) : null;
|
||||
const elev = activity.elevation_gain_m != null ? Math.round(activity.elevation_gain_m) : null;
|
||||
const date = new Date(activity.started_at).toLocaleDateString(undefined, {
|
||||
day: 'numeric', month: 'short', year: 'numeric',
|
||||
});
|
||||
|
||||
function handlePress() {
|
||||
if (selecting) onToggleSelect();
|
||||
else router.push(`/activity/${activity.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.card, checked && { borderColor: theme.accent }]}
|
||||
onPress={handlePress}
|
||||
onLongPress={onLongPress}
|
||||
>
|
||||
<View style={styles.cardTop}>
|
||||
<View style={styles.cardLeft}>
|
||||
{selecting && (
|
||||
<View style={[styles.checkbox, checked && { backgroundColor: theme.accent, borderColor: theme.accent }]}>
|
||||
{checked && <Text style={styles.checkmark}>✓</Text>}
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
|
||||
</View>
|
||||
<View style={styles.cardMeta}>
|
||||
<Text style={styles.cardDate}>{date}</Text>
|
||||
{activity.origin === 'remote'
|
||||
? <Text style={[styles.remoteBadge, { color: theme.accent, borderColor: theme.accent }]}>cloud</Text>
|
||||
: !activity.synced_at && <Text style={styles.localBadge}>local</Text>
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.cardTitle} numberOfLines={1}>{activity.user_title ?? activity.title}</Text>
|
||||
<View style={styles.cardStats}>
|
||||
{km && <Stat label="km" value={km} />}
|
||||
{elev != null && <Stat label="m↑" value={String(elev)} />}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.stat}>
|
||||
<Text style={styles.statValue}>{value}</Text>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function sportIcon(sport: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
cycling: '🚴', running: '🏃', hiking: '🥾', swimming: '🏊', walking: '🚶',
|
||||
};
|
||||
return icons[sport] ?? '🏅';
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#18181b', borderRadius: 12,
|
||||
padding: 16, borderWidth: 1, borderColor: '#27272a',
|
||||
},
|
||||
cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
|
||||
cardLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||
sportIcon: { fontSize: 20 },
|
||||
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
cardDate: { color: '#71717a', fontSize: 12 },
|
||||
remoteBadge: {
|
||||
fontSize: 10, borderWidth: 1,
|
||||
borderRadius: 4, paddingHorizontal: 4,
|
||||
},
|
||||
localBadge: {
|
||||
color: '#a1a1aa', fontSize: 10, borderWidth: 1,
|
||||
borderColor: '#3f3f46', borderRadius: 4, paddingHorizontal: 4,
|
||||
},
|
||||
cardTitle: { color: '#f4f4f5', fontSize: 15, fontWeight: '600', marginBottom: 10 },
|
||||
cardStats: { flexDirection: 'row', gap: 16 },
|
||||
stat: { flexDirection: 'row', alignItems: 'baseline', gap: 3 },
|
||||
statValue: { color: '#f4f4f5', fontSize: 16, fontWeight: '600' },
|
||||
statLabel: { color: '#71717a', fontSize: 12 },
|
||||
checkbox: {
|
||||
width: 20, height: 20, borderRadius: 4, borderWidth: 1.5,
|
||||
borderColor: '#52525b', alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
checkmark: { color: '#fff', fontSize: 12, fontWeight: '700' },
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
||||
|
||||
export async function migrateDb(db: SQLiteDatabase): Promise<void> {
|
||||
await db.execAsync('PRAGMA journal_mode = WAL;');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_hash TEXT NOT NULL,
|
||||
detail_json TEXT NOT NULL,
|
||||
timeseries_json TEXT,
|
||||
geojson TEXT,
|
||||
original_path TEXT,
|
||||
synced_at INTEGER,
|
||||
origin TEXT NOT NULL CHECK(origin IN ('local', 'remote')),
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_created_at
|
||||
ON activities(created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration v2: source_path stores the original filesystem path a file was
|
||||
// imported from (e.g. /sdcard/Karoo/Rides/ride.fit), used for watch-folder
|
||||
// deduplication without re-hashing files.
|
||||
try {
|
||||
await db.execAsync('ALTER TABLE activities ADD COLUMN source_path TEXT');
|
||||
await db.execAsync(
|
||||
'CREATE INDEX IF NOT EXISTS idx_activities_source_path ON activities(source_path)',
|
||||
);
|
||||
} catch {
|
||||
// Column already exists — migration already ran, ignore.
|
||||
}
|
||||
|
||||
// Migration v3: edits_json stores user overrides (e.g. {"title": "My title"})
|
||||
// kept separate from detail_json so server re-extraction (Option A) never
|
||||
// clobbers user edits.
|
||||
try {
|
||||
await db.execAsync('ALTER TABLE activities ADD COLUMN edits_json TEXT');
|
||||
} catch {
|
||||
// Column already exists — migration already ran, ignore.
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ActivityRow = {
|
||||
id: string;
|
||||
source_hash: string;
|
||||
detail_json: string;
|
||||
timeseries_json: string | null;
|
||||
geojson: string | null;
|
||||
original_path: string | null;
|
||||
source_path: string | null;
|
||||
synced_at: number | null;
|
||||
origin: 'local' | 'remote';
|
||||
created_at: number;
|
||||
edits_json: string | null;
|
||||
};
|
||||
|
||||
export type ActivitySummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
user_title: string | null; // from edits_json; takes display priority over title
|
||||
sport: string;
|
||||
started_at: string;
|
||||
distance_m: number | null;
|
||||
duration_s: number | null;
|
||||
elevation_gain_m: number | null;
|
||||
origin: 'local' | 'remote';
|
||||
synced_at: number | null;
|
||||
};
|
||||
|
||||
// ── Activities ─────────────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export function useActivities(searchQuery = '', limit = PAGE_SIZE): ActivitySummary[] {
|
||||
const db = useSQLiteContext();
|
||||
const like = `%${searchQuery}%`;
|
||||
const rows = db.getAllSync<ActivitySummary>(`
|
||||
SELECT
|
||||
id, origin, synced_at,
|
||||
json_extract(detail_json, '$.title') AS title,
|
||||
json_extract(edits_json, '$.title') AS user_title,
|
||||
json_extract(detail_json, '$.sport') AS sport,
|
||||
json_extract(detail_json, '$.started_at') AS started_at,
|
||||
json_extract(detail_json, '$.distance_m') AS distance_m,
|
||||
json_extract(detail_json, '$.duration_s') AS duration_s,
|
||||
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
|
||||
FROM activities
|
||||
WHERE (? = '' OR json_extract(detail_json, '$.title') LIKE ?)
|
||||
ORDER BY json_extract(detail_json, '$.started_at') DESC
|
||||
LIMIT ?
|
||||
`, [searchQuery, like, limit]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function useActivityCount(searchQuery = ''): number {
|
||||
const db = useSQLiteContext();
|
||||
const like = `%${searchQuery}%`;
|
||||
const row = db.getFirstSync<{ n: number }>(
|
||||
`SELECT COUNT(*) as n FROM activities
|
||||
WHERE (? = '' OR json_extract(detail_json, '$.title') LIKE ?)`,
|
||||
[searchQuery, like],
|
||||
);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
export { PAGE_SIZE };
|
||||
|
||||
export type ActivityFilter = {
|
||||
sport: string; // '' = all sports
|
||||
dateFrom: string; // '' = no lower bound; ISO-like 'YYYY-MM-DDTHHMMSSZ' for comparison
|
||||
dateTo: string; // '' = no upper bound
|
||||
sort: 'date' | 'distance' | 'elevation';
|
||||
};
|
||||
|
||||
const SORT_SQL: Record<string, string> = {
|
||||
date: "json_extract(detail_json, '$.started_at') DESC",
|
||||
distance: "json_extract(detail_json, '$.distance_m') DESC",
|
||||
elevation: "json_extract(detail_json, '$.elevation_gain_m') DESC",
|
||||
};
|
||||
|
||||
export function useFilteredActivities(filter: ActivityFilter, limit = PAGE_SIZE): ActivitySummary[] {
|
||||
const db = useSQLiteContext();
|
||||
const order = SORT_SQL[filter.sort] ?? SORT_SQL.date;
|
||||
return db.getAllSync<ActivitySummary>(`
|
||||
SELECT
|
||||
id, origin, synced_at,
|
||||
json_extract(detail_json, '$.title') AS title,
|
||||
json_extract(edits_json, '$.title') AS user_title,
|
||||
json_extract(detail_json, '$.sport') AS sport,
|
||||
json_extract(detail_json, '$.started_at') AS started_at,
|
||||
json_extract(detail_json, '$.distance_m') AS distance_m,
|
||||
json_extract(detail_json, '$.duration_s') AS duration_s,
|
||||
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
|
||||
FROM activities
|
||||
WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?)
|
||||
AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?)
|
||||
AND (? = '' OR json_extract(detail_json, '$.started_at') < ?)
|
||||
ORDER BY ${order}
|
||||
LIMIT ?
|
||||
`, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom, filter.dateTo, filter.dateTo, limit]);
|
||||
}
|
||||
|
||||
export function useFilteredCount(filter: ActivityFilter): number {
|
||||
const db = useSQLiteContext();
|
||||
const row = db.getFirstSync<{ n: number }>(`
|
||||
SELECT COUNT(*) as n FROM activities
|
||||
WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?)
|
||||
AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?)
|
||||
AND (? = '' OR json_extract(detail_json, '$.started_at') < ?)
|
||||
`, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom, filter.dateTo, filter.dateTo]);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
export function useActivityYears(): string[] {
|
||||
const db = useSQLiteContext();
|
||||
const rows = db.getAllSync<{ year: string }>(
|
||||
`SELECT DISTINCT substr(json_extract(detail_json, '$.started_at'), 1, 4) AS year
|
||||
FROM activities
|
||||
WHERE json_extract(detail_json, '$.started_at') IS NOT NULL
|
||||
ORDER BY year DESC`,
|
||||
);
|
||||
return rows.map(r => r.year).filter(Boolean);
|
||||
}
|
||||
|
||||
export function useActivity(id: string): ActivityRow | null {
|
||||
const db = useSQLiteContext();
|
||||
return db.getFirstSync<ActivityRow>(
|
||||
'SELECT * FROM activities WHERE id = ?',
|
||||
[id],
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export async function insertActivity(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
row: Pick<ActivityRow, 'id' | 'source_hash' | 'detail_json' | 'timeseries_json' | 'geojson' | 'original_path' | 'origin'>
|
||||
& { source_path?: string | null },
|
||||
): Promise<void> {
|
||||
await db.runAsync(
|
||||
`INSERT OR IGNORE INTO activities
|
||||
(id, source_hash, detail_json, timeseries_json, geojson, original_path, source_path, origin)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.id,
|
||||
row.source_hash,
|
||||
row.detail_json,
|
||||
row.timeseries_json ?? null,
|
||||
row.geojson ?? null,
|
||||
row.original_path ?? null,
|
||||
row.source_path ?? null,
|
||||
row.origin,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function isSourcePathImported(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
sourcePath: string,
|
||||
): boolean {
|
||||
const row = db.getFirstSync<{ id: string }>(
|
||||
'SELECT id FROM activities WHERE source_path = ?',
|
||||
[sourcePath],
|
||||
);
|
||||
return row != null;
|
||||
}
|
||||
|
||||
export async function upsertRemoteActivity(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
id: string,
|
||||
detailJson: string,
|
||||
): Promise<boolean> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const result = await db.runAsync(
|
||||
`INSERT INTO activities (id, source_hash, detail_json, origin, synced_at)
|
||||
VALUES (?, ?, ?, 'remote', ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
detail_json = excluded.detail_json,
|
||||
synced_at = excluded.synced_at
|
||||
WHERE origin = 'remote'`,
|
||||
[id, id, detailJson, now],
|
||||
);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export async function deleteRemoteActivities(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
): Promise<number> {
|
||||
const result = await db.runAsync(`DELETE FROM activities WHERE origin = 'remote'`);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
export async function deleteActivity(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
id: string,
|
||||
): Promise<string | null> {
|
||||
const row = db.getFirstSync<{ original_path: string | null }>(
|
||||
'SELECT original_path FROM activities WHERE id = ?',
|
||||
[id],
|
||||
);
|
||||
await db.runAsync('DELETE FROM activities WHERE id = ?', [id]);
|
||||
return row?.original_path ?? null;
|
||||
}
|
||||
|
||||
export async function setActivityTitle(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
id: string,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
await db.runAsync(
|
||||
`UPDATE activities
|
||||
SET edits_json = json_set(COALESCE(edits_json, '{}'), '$.title', ?)
|
||||
WHERE id = ?`,
|
||||
[title, id],
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteActivities(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
ids: string[],
|
||||
): Promise<Array<string | null>> {
|
||||
if (ids.length === 0) return [];
|
||||
const rows = db.getAllSync<{ original_path: string | null }>(
|
||||
`SELECT original_path FROM activities WHERE id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids,
|
||||
);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
await db.runAsync(`DELETE FROM activities WHERE id IN (${placeholders})`, ids);
|
||||
return rows.map(r => r.original_path ?? null);
|
||||
}
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSetting(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
key: string,
|
||||
): Promise<string | null> {
|
||||
const row = db.getFirstSync<{ value: string }>(
|
||||
'SELECT value FROM settings WHERE key = ?',
|
||||
[key],
|
||||
);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export async function setSetting(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
await db.runAsync(
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
[key, value],
|
||||
);
|
||||
}
|
||||
|
||||
export function useSetting(key: string): string | null {
|
||||
const db = useSQLiteContext();
|
||||
const row = db.getFirstSync<{ value: string }>(
|
||||
'SELECT value FROM settings WHERE key = ?',
|
||||
[key],
|
||||
);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { getSetting, upsertRemoteActivity } from './queries';
|
||||
|
||||
export type SyncResult = {
|
||||
synced: number;
|
||||
total: number;
|
||||
fetched?: number;
|
||||
uploaded?: number;
|
||||
failed?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
async function resolveCredentials(db: SQLiteDatabase): Promise<{ instanceUrl: string; token: string } | { error: string }> {
|
||||
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
|
||||
const token = await getSetting(db, 'api_token');
|
||||
if (!instanceUrl || !token) return { error: 'No instance configured — add one in Settings.' };
|
||||
return { instanceUrl, token };
|
||||
}
|
||||
|
||||
export async function downloadFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
const creds = await resolveCredentials(db);
|
||||
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
||||
const { instanceUrl, token } = creds;
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${instanceUrl}/api/feed`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {
|
||||
return { synced: 0, total: 0, error: 'Could not reach instance — check your connection.' };
|
||||
}
|
||||
|
||||
if (resp.status === 401) return { synced: 0, total: 0, error: 'Session expired — reconnect in Settings.' };
|
||||
if (!resp.ok) return { synced: 0, total: 0, error: `Server error (${resp.status})` };
|
||||
|
||||
const data: { activities?: RemoteSummary[] } = await resp.json();
|
||||
const activities = data.activities ?? [];
|
||||
const syncMode = (await getSetting(db, 'sync_mode')) ?? 'summaries';
|
||||
|
||||
let synced = 0;
|
||||
for (const a of activities) {
|
||||
const detailJson = JSON.stringify({
|
||||
id: a.id,
|
||||
title: a.title ?? a.id,
|
||||
sport: a.sport ?? null,
|
||||
started_at: a.started_at ?? null,
|
||||
distance_m: a.distance_m ?? null,
|
||||
moving_time_s: a.moving_time_s ?? null,
|
||||
elevation_gain_m: a.elevation_gain_m ?? null,
|
||||
avg_speed_kmh: a.avg_speed_kmh ?? null,
|
||||
avg_hr_bpm: a.avg_hr_bpm ?? null,
|
||||
avg_power_w: a.avg_power_w ?? null,
|
||||
});
|
||||
const changed = await upsertRemoteActivity(db, a.id, detailJson);
|
||||
if (changed) synced++;
|
||||
}
|
||||
|
||||
if (syncMode !== 'full') return { synced, total: activities.length };
|
||||
|
||||
// Full mode: fetch geojson + timeseries for activities missing them
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
let fetched = 0;
|
||||
for (const a of activities) {
|
||||
const row = db.getFirstSync<{ g: number; t: number }>(
|
||||
'SELECT (geojson IS NOT NULL) as g, (timeseries_json IS NOT NULL) as t FROM activities WHERE id = ?',
|
||||
[a.id],
|
||||
);
|
||||
if (row?.g && row?.t) continue;
|
||||
|
||||
let gj: string | null = null;
|
||||
let ts: string | null = null;
|
||||
try {
|
||||
if (!row?.g) {
|
||||
const r = await fetch(`${instanceUrl}/api/activity/${a.id}/geojson`, { headers });
|
||||
if (r.ok) gj = await r.text();
|
||||
}
|
||||
if (!row?.t) {
|
||||
const r = await fetch(`${instanceUrl}/api/activity/${a.id}/timeseries`, { headers });
|
||||
if (r.ok) ts = await r.text();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (gj !== null || ts !== null) {
|
||||
await db.runAsync(
|
||||
`UPDATE activities SET
|
||||
geojson = COALESCE(geojson, ?),
|
||||
timeseries_json = COALESCE(timeseries_json, ?)
|
||||
WHERE id = ? AND origin = 'remote'`,
|
||||
[gj, ts, a.id],
|
||||
);
|
||||
fetched++;
|
||||
}
|
||||
}
|
||||
|
||||
return { synced, total: activities.length, fetched };
|
||||
}
|
||||
|
||||
export async function uploadFeed(
|
||||
db: SQLiteDatabase,
|
||||
onProgress?: (n: number, total: number) => void,
|
||||
): Promise<SyncResult> {
|
||||
const creds = await resolveCredentials(db);
|
||||
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
||||
const { instanceUrl, token } = creds;
|
||||
|
||||
// Reconcile local synced_at against what the server actually has.
|
||||
// If the server was wiped/reset, activities we thought were uploaded need
|
||||
// re-uploading — clear their synced_at so they re-enter the upload queue.
|
||||
try {
|
||||
const feedResp = await fetch(`${instanceUrl}/api/feed`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (feedResp.ok) {
|
||||
const feedData: { activities?: { id: string }[] } = await feedResp.json();
|
||||
const serverIds = new Set((feedData.activities ?? []).map(a => a.id));
|
||||
const syncedRows = db.getAllSync<{ id: string }>(
|
||||
`SELECT id FROM activities WHERE origin = 'local' AND synced_at IS NOT NULL`,
|
||||
);
|
||||
for (const row of syncedRows) {
|
||||
if (!serverIds.has(row.id)) {
|
||||
await db.runAsync(`UPDATE activities SET synced_at = NULL WHERE id = ?`, [row.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — proceed with upload even if reconciliation fails
|
||||
}
|
||||
|
||||
const { uploaded, failed } = await uploadLocalActivities(db, instanceUrl, token, onProgress);
|
||||
return { synced: 0, total: 0, uploaded, failed: failed || undefined };
|
||||
}
|
||||
|
||||
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
const dl = await downloadFeed(db);
|
||||
if (dl.error) return dl;
|
||||
|
||||
const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
|
||||
let uploaded = 0;
|
||||
if (uploadEnabled) {
|
||||
const ul = await uploadFeed(db);
|
||||
uploaded = ul.uploaded ?? 0;
|
||||
}
|
||||
|
||||
return { ...dl, uploaded: uploaded || undefined };
|
||||
}
|
||||
|
||||
export async function countPendingUploads(db: SQLiteDatabase): Promise<number> {
|
||||
const row = db.getFirstSync<{ n: number }>(
|
||||
`SELECT COUNT(*) as n FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
||||
);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
async function uploadLocalActivities(
|
||||
db: SQLiteDatabase,
|
||||
instanceUrl: string,
|
||||
token: string,
|
||||
onProgress?: (n: number, total: number) => void,
|
||||
): Promise<{ uploaded: number; failed: number }> {
|
||||
const rows = db.getAllSync<{
|
||||
id: string;
|
||||
detail_json: string;
|
||||
timeseries_json: string | null;
|
||||
geojson: string | null;
|
||||
original_path: string | null;
|
||||
edits_json: string | null;
|
||||
}>(
|
||||
`SELECT id, detail_json, timeseries_json, geojson, original_path, edits_json
|
||||
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
||||
);
|
||||
|
||||
const preferRaw = (await getSetting(db, 'upload_format') ?? 'raw') === 'raw';
|
||||
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||||
let uploaded = 0;
|
||||
let failed = 0;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const total = rows.length;
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
onProgress?.(i + 1, total);
|
||||
try {
|
||||
let resp: Response;
|
||||
|
||||
// When preferRaw is set and the original file is still on disk, send the raw
|
||||
// bytes to /api/upload/raw so the server re-extracts with DEM elevation correction.
|
||||
const useRaw = preferRaw &&
|
||||
row.original_path !== null &&
|
||||
(await FileSystem.getInfoAsync(row.original_path)).exists;
|
||||
|
||||
const userTitle: string | null = row.edits_json
|
||||
? (JSON.parse(row.edits_json).title ?? null)
|
||||
: null;
|
||||
|
||||
if (useRaw) {
|
||||
const filename = row.original_path!.split('/').pop() ?? 'activity.fit';
|
||||
const base64 = await FileSystem.readAsStringAsync(row.original_path!, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ filename, base64, ...(userTitle ? { user_title: userTitle } : {}) }),
|
||||
});
|
||||
} else {
|
||||
const detail = JSON.parse(row.detail_json);
|
||||
if (userTitle) detail.title = userTitle;
|
||||
const body: Record<string, unknown> = { activity: { id: row.id, ...detail } };
|
||||
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
|
||||
if (row.geojson) body.geojson = JSON.parse(row.geojson);
|
||||
resp = await fetch(`${instanceUrl}/api/upload/bas`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
if (resp.ok) {
|
||||
await db.runAsync(`UPDATE activities SET synced_at = ? WHERE id = ?`, [now, row.id]);
|
||||
// Option A: after a raw upload, update local detail/timeseries/geojson with the
|
||||
// server's DEM-corrected extraction so the app shows better elevation data.
|
||||
if (useRaw) {
|
||||
try {
|
||||
const data = await resp.json() as {
|
||||
id: string;
|
||||
detail: object;
|
||||
timeseries: object | null;
|
||||
geojson: object | null;
|
||||
source_hash: string;
|
||||
};
|
||||
if (data.id === row.id) {
|
||||
await db.runAsync(
|
||||
`UPDATE activities
|
||||
SET detail_json = ?,
|
||||
timeseries_json = COALESCE(?, timeseries_json),
|
||||
geojson = COALESCE(?, geojson),
|
||||
source_hash = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
JSON.stringify(data.detail),
|
||||
data.timeseries ? JSON.stringify(data.timeseries) : null,
|
||||
data.geojson ? JSON.stringify(data.geojson) : null,
|
||||
data.source_hash,
|
||||
row.id,
|
||||
],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: synced_at is already set, local data stays as-is
|
||||
}
|
||||
}
|
||||
uploaded++;
|
||||
} else {
|
||||
console.warn(`upload ${row.id}: HTTP ${resp.status}`);
|
||||
failed++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`upload ${row.id}:`, err);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { uploaded, failed };
|
||||
}
|
||||
|
||||
type RemoteSummary = {
|
||||
id: string;
|
||||
title?: string;
|
||||
sport?: string;
|
||||
started_at?: string;
|
||||
distance_m?: number | null;
|
||||
moving_time_s?: number | null;
|
||||
elevation_gain_m?: number | null;
|
||||
avg_speed_kmh?: number | null;
|
||||
avg_hr_bpm?: number | null;
|
||||
avg_power_w?: number | null;
|
||||
};
|
||||
@@ -1,248 +0,0 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import WebView from 'react-native-webview';
|
||||
import { handleWebViewMessage, pyodideRef } from './extractActivity';
|
||||
|
||||
const CDN = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/';
|
||||
// v0.18.1: last version whose JS wrapper avoids ??, ?., and other syntax
|
||||
// unavailable on Chrome <80 (e.g. Karoo WebView 61). Used in the compat path.
|
||||
const CDN_COMPAT = 'https://cdn.jsdelivr.net/pyodide/v0.18.1/full/';
|
||||
|
||||
// Python snippets embedded as JSON strings to avoid any JS/TS escaping issues.
|
||||
const PY_INSTALL_PACKAGES = [
|
||||
'import micropip',
|
||||
'await micropip.install(["fitdecode", "gpxpy"])',
|
||||
].join('\n');
|
||||
|
||||
// emfs:// is Pyodide's Emscripten-FS URL scheme — the only reliable way to
|
||||
// install a wheel from bytes without an http/https URL (blob: URLs are not
|
||||
// recognised by micropip and cause an InvalidRequirement parse error).
|
||||
// _wheel_path is set as a Pyodide global before this runs.
|
||||
const PY_INSTALL_WHEEL = [
|
||||
'import micropip',
|
||||
'await micropip.install("emfs://" + _wheel_path, deps=False)',
|
||||
].join('\n');
|
||||
|
||||
const PY_EXTRACT = [
|
||||
'import json, shutil',
|
||||
'from pathlib import Path',
|
||||
'from bincio.extract.parsers.factory import parse_file',
|
||||
'from bincio.extract.metrics import compute',
|
||||
'from bincio.extract.writer import make_activity_id, write_activity',
|
||||
'',
|
||||
'outdir = Path("/tmp/bincio_out")',
|
||||
'if outdir.exists(): shutil.rmtree(outdir)',
|
||||
'outdir.mkdir()',
|
||||
'',
|
||||
'activity = parse_file(Path("/tmp/" + _filename))',
|
||||
'metrics = compute(activity)',
|
||||
'write_activity(activity, metrics, outdir, privacy="public", rdp_epsilon=0.0001)',
|
||||
'act_id = make_activity_id(activity)',
|
||||
'',
|
||||
'detail_path = outdir / "activities" / (act_id + ".json")',
|
||||
'ts_path = outdir / "activities" / (act_id + ".timeseries.json")',
|
||||
'geojson_path = outdir / "activities" / (act_id + ".geojson")',
|
||||
'',
|
||||
'# write_activity in the installed wheel silently skips timeseries — write it directly.',
|
||||
'if not ts_path.exists():',
|
||||
' from bincio.extract.timeseries import build_timeseries as _bts',
|
||||
' _ts = _bts(activity.points, activity.started_at, "public")',
|
||||
' if _ts.get("t"):',
|
||||
' ts_path.write_text(json.dumps(_ts))',
|
||||
'',
|
||||
'json.dumps({',
|
||||
' "id": act_id,',
|
||||
' "detail": json.loads(detail_path.read_text()),',
|
||||
' "timeseries": json.loads(ts_path.read_text()) if ts_path.exists() else None,',
|
||||
' "geojson": json.loads(geojson_path.read_text()) if geojson_path.exists() else None,',
|
||||
'})',
|
||||
].join('\n');
|
||||
|
||||
// JSON.stringify gives us safely-quoted JS string literals for embedding in HTML.
|
||||
const PYODIDE_HTML = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"></head>
|
||||
<body>
|
||||
<script>
|
||||
var _PY_INSTALL_PACKAGES = ${JSON.stringify(PY_INSTALL_PACKAGES)};
|
||||
var _PY_INSTALL_WHEEL = ${JSON.stringify(PY_INSTALL_WHEEL)};
|
||||
var _PY_EXTRACT = ${JSON.stringify(PY_EXTRACT)};
|
||||
var _CDN = ${JSON.stringify(CDN)};
|
||||
var _CDN_COMPAT = ${JSON.stringify(CDN_COMPAT)};
|
||||
|
||||
function _post(m) { window.ReactNativeWebView.postMessage(JSON.stringify(m)); }
|
||||
|
||||
var pyodide = null;
|
||||
var packagesReady = false;
|
||||
var wheelReady = false;
|
||||
var initError = null;
|
||||
|
||||
(async function init() {
|
||||
try {
|
||||
// WebAssembly.Global was added in Chrome 69. Without it Pyodide cannot
|
||||
// initialise on any version. Bail out immediately so the mobile app can
|
||||
// fall back to server-side extraction without attempting a 35 MB download.
|
||||
if (typeof WebAssembly === 'undefined' || typeof WebAssembly.Global === 'undefined') {
|
||||
_post({ type: 'engine_unavailable', reason: 'wasm_global' });
|
||||
return;
|
||||
}
|
||||
|
||||
_post({ type: 'progress', msg: 'Loading Python runtime…' });
|
||||
|
||||
// Chrome <80 is missing features that modern Pyodide uses in its JS wrapper:
|
||||
// Chrome <71: no globalThis → factory throws ReferenceError immediately
|
||||
// Chrome <63: no dynamic import() / for-await-of → parse/runtime failure
|
||||
// Detection: read Chrome version from UA; absent means non-Chrome (assume modern).
|
||||
var _chromeVer = (navigator.userAgent.match(/Chrome\\/([0-9]+)/) || [])[1];
|
||||
var _needsPatch = _chromeVer && parseInt(_chromeVer) < 80;
|
||||
|
||||
if (_needsPatch) {
|
||||
// Use v0.18.1 — its JS wrapper avoids ??, ?., and other Chrome-80+ syntax.
|
||||
// Then apply three text patches before injecting via Blob URL (Blob scripts
|
||||
// bypass the browser's module pre-scanner, so patched keywords are invisible).
|
||||
//
|
||||
// Patches (split/join avoids regex escapes, which template literals corrupt):
|
||||
// 1. globalThis polyfill prepended — Chrome <71 lacks globalThis entirely
|
||||
// 2. import( → __loadScript( — Chrome <63 cannot parse dynamic import
|
||||
// 3. for await( → for( — Chrome <63 lacks async iteration;
|
||||
// the only affected fn (getFsHandles/NativeFS) is never called by us
|
||||
window.__loadScript = function(url) {
|
||||
return new Promise(function(res, rej) {
|
||||
var s = document.createElement('script');
|
||||
s.src = url;
|
||||
s.onload = res;
|
||||
s.onerror = function() { rej(new Error('Failed to load ' + url)); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
};
|
||||
var _pyResp = await fetch(_CDN_COMPAT + 'pyodide.js');
|
||||
if (!_pyResp.ok) throw new Error('Could not fetch pyodide.js (' + _pyResp.status + ')');
|
||||
var _pyCode = await _pyResp.text();
|
||||
_pyCode = 'var globalThis=typeof globalThis!=="undefined"?globalThis:self;\\n' + _pyCode;
|
||||
_pyCode = _pyCode.split('import(').join('__loadScript(');
|
||||
_pyCode = _pyCode.split('for await(').join('for(');
|
||||
await new Promise(function(res, rej) {
|
||||
var blob = new Blob([_pyCode], { type: 'application/javascript' });
|
||||
var blobUrl = URL.createObjectURL(blob);
|
||||
var s = document.createElement('script');
|
||||
s.src = blobUrl;
|
||||
s.onload = function() { URL.revokeObjectURL(blobUrl); res(); };
|
||||
s.onerror = function() { URL.revokeObjectURL(blobUrl); rej(new Error('Failed to inject patched pyodide.js')); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
pyodide = await loadPyodide({ indexURL: _CDN_COMPAT });
|
||||
} else {
|
||||
await new Promise(function(res, rej) {
|
||||
var s = document.createElement('script');
|
||||
s.src = _CDN + 'pyodide.js';
|
||||
s.onload = res; s.onerror = rej;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
pyodide = await loadPyodide({ indexURL: _CDN });
|
||||
}
|
||||
|
||||
_post({ type: 'progress', msg: 'Loading packages…' });
|
||||
await pyodide.loadPackage(['lxml', 'pyyaml', 'micropip']);
|
||||
|
||||
_post({ type: 'progress', msg: 'Installing fitdecode, gpxpy…' });
|
||||
await pyodide.runPythonAsync(_PY_INSTALL_PACKAGES);
|
||||
|
||||
packagesReady = true;
|
||||
_post({ type: 'pyodide_ready' });
|
||||
} catch(e) {
|
||||
initError = String(e);
|
||||
_post({ type: 'init_error', message: initError });
|
||||
}
|
||||
})();
|
||||
|
||||
window._bincioExtract = async function(params) {
|
||||
var reqId = params.reqId;
|
||||
var filename = params.filename;
|
||||
var base64 = params.base64;
|
||||
var wheelBase64 = params.wheelBase64; // pre-fetched by React Native (avoids ATS/HTTP issues)
|
||||
var wheelFilename = params.wheelFilename; // e.g. "bincio-0.1.0-py3-none-any.whl"
|
||||
|
||||
function post(m) { _post(Object.assign({}, m, { reqId: reqId })); }
|
||||
|
||||
try {
|
||||
// Wait for base packages if still loading
|
||||
if (!packagesReady && !initError) {
|
||||
await new Promise(function(res, rej) {
|
||||
var n = 0;
|
||||
var id = setInterval(function() {
|
||||
if (packagesReady) { clearInterval(id); res(undefined); }
|
||||
else if (initError) { clearInterval(id); rej(new Error(initError)); }
|
||||
else if (++n > 300) { clearInterval(id); rej(new Error('Pyodide init timed out')); }
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
if (initError) throw new Error(initError);
|
||||
|
||||
// Install bincio wheel on first extraction.
|
||||
// Wheel bytes arrive pre-fetched from React Native (avoids ATS/HTTP issues).
|
||||
// Write to Pyodide's Emscripten FS so micropip can install via emfs:// URL
|
||||
// (blob: URLs are not recognised by micropip — they cause an InvalidRequirement error).
|
||||
if (!wheelReady) {
|
||||
post({ type: 'progress', msg: 'Loading Bincio…' });
|
||||
var wheelBytes = Uint8Array.from(atob(wheelBase64), function(c) { return c.charCodeAt(0); });
|
||||
var wheelPath = '/tmp/' + wheelFilename;
|
||||
pyodide.FS.writeFile(wheelPath, wheelBytes);
|
||||
pyodide.globals.set('_wheel_path', wheelPath);
|
||||
await pyodide.runPythonAsync(_PY_INSTALL_WHEEL);
|
||||
wheelReady = true;
|
||||
}
|
||||
|
||||
post({ type: 'progress', msg: 'Extracting…' });
|
||||
|
||||
// Decode base64 file bytes and write to Pyodide's virtual filesystem
|
||||
var bytes = Uint8Array.from(atob(base64), function(c) { return c.charCodeAt(0); });
|
||||
pyodide.FS.writeFile('/tmp/' + filename, bytes);
|
||||
|
||||
// SHA-256 of original file bytes (replaces the stub source_hash)
|
||||
var hashBuf = await crypto.subtle.digest('SHA-256', bytes.buffer);
|
||||
var sourceHash = Array.from(new Uint8Array(hashBuf))
|
||||
.map(function(b) { return b.toString(16).padStart(2, '0'); })
|
||||
.join('');
|
||||
|
||||
// Run the bincio extraction pipeline
|
||||
pyodide.globals.set('_filename', filename);
|
||||
var resultJson = await pyodide.runPythonAsync(_PY_EXTRACT);
|
||||
var result = JSON.parse(resultJson);
|
||||
|
||||
_post({
|
||||
type: 'result',
|
||||
reqId: reqId,
|
||||
id: result.id,
|
||||
detail: result.detail,
|
||||
timeseries: result.timeseries,
|
||||
geojson: result.geojson,
|
||||
sourceHash: sourceHash,
|
||||
});
|
||||
} catch(e) {
|
||||
_post({ type: 'error', reqId: reqId, message: e.message || String(e) });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
export function PyodideWebView() {
|
||||
return (
|
||||
<WebView
|
||||
ref={pyodideRef}
|
||||
source={{ html: PYODIDE_HTML, baseUrl: 'https://localhost' }}
|
||||
style={styles.hidden}
|
||||
onMessage={handleWebViewMessage}
|
||||
javaScriptEnabled
|
||||
originWhitelist={['*']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Off-screen but still rendered — display:none / opacity:0 can suppress JS on some platforms.
|
||||
hidden: {
|
||||
position: 'absolute',
|
||||
top: -2000,
|
||||
left: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import { createRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import type WebView from 'react-native-webview';
|
||||
import type { WebViewMessageEvent } from 'react-native-webview';
|
||||
|
||||
export type ExtractionResult = {
|
||||
id: string;
|
||||
detail: object;
|
||||
timeseries: object | null;
|
||||
geojson: object | null;
|
||||
sourceHash: string;
|
||||
};
|
||||
|
||||
type Pending = {
|
||||
resolve: (r: ExtractionResult) => void;
|
||||
reject: (e: Error) => void;
|
||||
onStatus: (msg: string) => void;
|
||||
};
|
||||
|
||||
export const pyodideRef = createRef<WebView>();
|
||||
|
||||
const pending = new Map<string, Pending>();
|
||||
let reqCounter = 0;
|
||||
let isExtracting = false;
|
||||
|
||||
// Engine readiness — tracked so callers can wait before batching files.
|
||||
let _engineReady = false;
|
||||
let _engineError: string | null = null;
|
||||
// Android <29 (API 27 = Android 8.1, e.g. Karoo) ships with a system WebView
|
||||
// (Chrome <69) that lacks WebAssembly.Global, so Pyodide cannot run. Mounting
|
||||
// a WebView on those devices also causes GPU driver crashes (SurfaceView
|
||||
// conflicts). Skip the engine entirely and route to server extraction instead.
|
||||
let _engineUnavailable = Platform.OS === 'android' && (Platform.Version as number) < 29;
|
||||
const _engineResolvers: Array<() => void> = [];
|
||||
const _engineRejecters: Array<(e: Error) => void> = [];
|
||||
|
||||
// Init-phase progress listeners (messages sent before any extraction starts).
|
||||
const _progressListeners = new Set<(msg: string) => void>();
|
||||
export function onEngineProgress(cb: (msg: string) => void): () => void {
|
||||
_progressListeners.add(cb);
|
||||
return () => _progressListeners.delete(cb);
|
||||
}
|
||||
|
||||
export function isEngineAvailable(): boolean | null {
|
||||
// null = not yet determined; true = ready; false = unavailable
|
||||
if (_engineReady) return true;
|
||||
if (_engineUnavailable || _engineError) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function waitForEngine(timeoutMs = 300_000): Promise<void> {
|
||||
if (_engineReady) return Promise.resolve();
|
||||
if (_engineUnavailable) return Promise.reject(new Error('engine_unavailable'));
|
||||
if (_engineError) return Promise.reject(new Error(_engineError));
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('Extraction engine timed out — check network and Bincio instance URL'));
|
||||
}, timeoutMs);
|
||||
_engineResolvers.push(() => { clearTimeout(timer); resolve(); });
|
||||
_engineRejecters.push((e) => { clearTimeout(timer); reject(e); });
|
||||
});
|
||||
}
|
||||
|
||||
export function handleWebViewMessage(e: WebViewMessageEvent): void {
|
||||
let msg: Record<string, unknown>;
|
||||
try { msg = JSON.parse(e.nativeEvent.data); } catch { return; }
|
||||
|
||||
const reqId = msg.reqId as string | undefined;
|
||||
const p = reqId ? pending.get(reqId) : undefined;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'pyodide_ready':
|
||||
_engineReady = true;
|
||||
_engineResolvers.splice(0).forEach(fn => fn());
|
||||
break;
|
||||
case 'engine_unavailable':
|
||||
_engineUnavailable = true;
|
||||
_engineRejecters.splice(0).forEach(fn => fn(new Error('engine_unavailable')));
|
||||
break;
|
||||
case 'init_error':
|
||||
_engineError = msg.message as string;
|
||||
_engineRejecters.splice(0).forEach(fn => fn(new Error(_engineError!)));
|
||||
break;
|
||||
case 'result':
|
||||
if (p) {
|
||||
pending.delete(reqId!);
|
||||
p.resolve({
|
||||
id: msg.id as string,
|
||||
detail: msg.detail as object,
|
||||
timeseries: (msg.timeseries as object | null) ?? null,
|
||||
geojson: (msg.geojson as object | null) ?? null,
|
||||
sourceHash: msg.sourceHash as string,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
if (p) {
|
||||
pending.delete(reqId!);
|
||||
p.reject(new Error(msg.message as string));
|
||||
}
|
||||
break;
|
||||
case 'progress':
|
||||
if (p) {
|
||||
p.onStatus(msg.msg as string);
|
||||
} else {
|
||||
_progressListeners.forEach(fn => fn(msg.msg as string));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// wheelBase64 is the bincio .whl file pre-fetched by the React Native side
|
||||
// (native networking supports HTTP on local network; WKWebView does not).
|
||||
export function extractFile(
|
||||
filename: string,
|
||||
base64: string,
|
||||
wheelBase64: string,
|
||||
wheelFilename: string,
|
||||
onStatus: (msg: string) => void = () => {},
|
||||
): Promise<ExtractionResult> {
|
||||
if (isExtracting) return Promise.reject(new Error('Another extraction is already in progress'));
|
||||
|
||||
const webview = pyodideRef.current;
|
||||
if (!webview) return Promise.reject(new Error('Extraction engine not ready — restart the app'));
|
||||
|
||||
isExtracting = true;
|
||||
const reqId = String(++reqCounter);
|
||||
const args = JSON.stringify({ reqId, filename, base64, wheelBase64, wheelFilename });
|
||||
|
||||
return new Promise<ExtractionResult>((resolve, reject) => {
|
||||
pending.set(reqId, {
|
||||
resolve: (r) => { isExtracting = false; resolve(r); },
|
||||
reject: (e) => { isExtracting = false; reject(e); },
|
||||
onStatus,
|
||||
});
|
||||
webview.injectJavaScript(`window._bincioExtract(${args}); true;`);
|
||||
});
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { ExtractionResult } from './extractActivity';
|
||||
|
||||
export async function checkServerAuth(instanceUrl: string, token: string): Promise<void> {
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${instanceUrl}/api/feed`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {
|
||||
throw new Error('Could not reach Bincio instance — check your connection.');
|
||||
}
|
||||
if (resp.status === 401) throw new Error('Session expired — reconnect in Settings.');
|
||||
if (!resp.ok) throw new Error(`Server error (${resp.status})`);
|
||||
}
|
||||
|
||||
export async function extractFileViaServer(
|
||||
filename: string,
|
||||
base64: string,
|
||||
instanceUrl: string,
|
||||
token: string,
|
||||
onStatus: (msg: string) => void = () => {},
|
||||
): Promise<ExtractionResult> {
|
||||
onStatus('Uploading to Bincio instance…');
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filename, base64 }),
|
||||
});
|
||||
} catch {
|
||||
throw new Error('Could not reach Bincio instance — check your connection.');
|
||||
}
|
||||
|
||||
if (resp.status === 401) throw new Error('Session expired — reconnect in Settings.');
|
||||
if (resp.status === 422) {
|
||||
const body = await resp.json().catch(() => ({})) as { detail?: string };
|
||||
throw new Error(body.detail ?? 'Server could not process this file.');
|
||||
}
|
||||
if (!resp.ok) throw new Error(`Server error (${resp.status})`);
|
||||
|
||||
onStatus('Processing on server…');
|
||||
const data = await resp.json() as {
|
||||
ok: boolean;
|
||||
id: string;
|
||||
detail: object;
|
||||
timeseries: object | null;
|
||||
geojson: object | null;
|
||||
source_hash: string;
|
||||
};
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
detail: data.detail,
|
||||
timeseries: data.timeseries,
|
||||
geojson: data.geojson,
|
||||
sourceHash: data.source_hash,
|
||||
};
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
module.exports = getDefaultConfig(__dirname);
|
||||
Generated
-9819
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"name": "bincio",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@maplibre/maplibre-react-native": "~11.0.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-background-fetch": "~14.0.9",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-notifications": "~0.32.16",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-task-manager": "~14.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "~15.15.0",
|
||||
"react-native-webview": "13.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.0",
|
||||
"@types/react": "~19.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
}
|
||||
}
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bincio mobile app — one-time setup
|
||||
# Run from the mobile/ directory: ./setup.sh
|
||||
# Or from the repo root: bash mobile/setup.sh
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# ── Colours ───────────────────────────────────────────────────────────────────
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; RESET='\033[0m'
|
||||
ok() { echo -e "${GREEN}✓${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}⚠${RESET} $*"; }
|
||||
die() { echo -e "${RED}✗${RESET} $*" >&2; exit 1; }
|
||||
step() { echo -e "\n${YELLOW}▸${RESET} $*"; }
|
||||
|
||||
echo ""
|
||||
echo " Bincio mobile setup"
|
||||
echo " ═══════════════════"
|
||||
echo ""
|
||||
|
||||
# ── 1. Node.js ────────────────────────────────────────────────────────────────
|
||||
step "Checking Node.js..."
|
||||
if ! command -v node &>/dev/null; then
|
||||
die "Node.js not found. Install from https://nodejs.org (v20+ recommended)."
|
||||
fi
|
||||
NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1)
|
||||
if [ "$NODE_MAJOR" -lt 18 ]; then
|
||||
die "Node.js 18+ required (found $(node -v)). Update at https://nodejs.org"
|
||||
fi
|
||||
ok "Node.js $(node -v)"
|
||||
|
||||
# ── 2. npm ────────────────────────────────────────────────────────────────────
|
||||
if ! command -v npm &>/dev/null; then
|
||||
die "npm not found. It ships with Node.js — check your installation."
|
||||
fi
|
||||
ok "npm $(npm -v)"
|
||||
|
||||
# ── 3. Expo CLI (global, optional — we use npx) ───────────────────────────────
|
||||
step "Checking Expo CLI..."
|
||||
if command -v expo &>/dev/null; then
|
||||
ok "Expo CLI $(expo --version) (global)"
|
||||
else
|
||||
warn "Expo CLI not installed globally. Using npx instead (slightly slower)."
|
||||
warn "Install globally with: npm install -g expo-cli"
|
||||
fi
|
||||
|
||||
# ── 4. Platform tools ─────────────────────────────────────────────────────────
|
||||
step "Checking platform tools..."
|
||||
PLATFORM="$(uname -s)"
|
||||
|
||||
if [ "$PLATFORM" = "Darwin" ]; then
|
||||
if command -v xcodebuild &>/dev/null; then
|
||||
ok "Xcode $(xcodebuild -version 2>/dev/null | head -1 | awk '{print $2}')"
|
||||
else
|
||||
warn "Xcode not found — iOS builds will not work."
|
||||
warn "Install Xcode from the App Store, then: xcode-select --install"
|
||||
fi
|
||||
if command -v xcrun &>/dev/null && xcrun --sdk iphoneos --show-sdk-version &>/dev/null; then
|
||||
ok "iOS SDK available"
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v adb &>/dev/null; then
|
||||
ok "Android SDK / adb found"
|
||||
else
|
||||
warn "adb not found — Android builds require Android Studio."
|
||||
warn "Install from https://developer.android.com/studio"
|
||||
fi
|
||||
|
||||
# ── 5. Install dependencies ───────────────────────────────────────────────────
|
||||
step "Installing npm dependencies..."
|
||||
if [ -d node_modules ] && [ -f node_modules/.package-lock.json ]; then
|
||||
ok "node_modules already present — running npm install to sync..."
|
||||
fi
|
||||
npm install
|
||||
ok "Dependencies installed"
|
||||
|
||||
# ── 6. expo-env.d.ts (required by expo-router) ────────────────────────────────
|
||||
step "Generating Expo type declarations..."
|
||||
npx expo customize expo-env.d.ts --no-install 2>/dev/null || true
|
||||
if [ ! -f expo-env.d.ts ]; then
|
||||
echo '/// <reference types="expo-router/types" />' > expo-env.d.ts
|
||||
fi
|
||||
ok "expo-env.d.ts ready"
|
||||
|
||||
# ── 7. Summary ────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo " ══════════════════════════════════════════"
|
||||
echo " Setup complete! Next steps:"
|
||||
echo ""
|
||||
echo " Start with Expo Go (scan QR on your phone):"
|
||||
echo " npx expo start"
|
||||
echo ""
|
||||
echo " Run on Android emulator:"
|
||||
echo " npx expo run:android"
|
||||
echo ""
|
||||
echo " Run on iOS simulator (macOS only):"
|
||||
echo " npx expo run:ios"
|
||||
echo ""
|
||||
echo " Build APK for Karoo sideload:"
|
||||
echo " npx eas build -p android --profile preview"
|
||||
echo " ══════════════════════════════════════════"
|
||||
echo ""
|
||||
@@ -1,29 +0,0 @@
|
||||
export type PaletteKey = 'auto' | 'default' | 'giro' | 'tour' | 'vuelta';
|
||||
|
||||
export const PALETTES = {
|
||||
default: { accent: '#60a5fa', dim: 'rgba(96,165,250,0.15)', label: 'Default' },
|
||||
giro: { accent: '#f472b6', dim: 'rgba(244,114,182,0.15)', label: "Giro d'Italia" },
|
||||
tour: { accent: '#facc15', dim: 'rgba(250,204,21,0.15)', label: 'Tour de France' },
|
||||
vuelta: { accent: '#ef4444', dim: 'rgba(239,68,68,0.15)', label: 'Vuelta a España' },
|
||||
} as const satisfies Record<string, { accent: string; dim: string; label: string }>;
|
||||
|
||||
export type Theme = (typeof PALETTES)[keyof typeof PALETTES];
|
||||
|
||||
// Race windows [month 0-indexed, day inclusive] — update each year
|
||||
const RACES: Array<{ key: Exclude<PaletteKey, 'auto' | 'default'>; start: [number, number]; end: [number, number] }> = [
|
||||
{ key: 'giro', start: [4, 8], end: [5, 1] }, // May 8 – Jun 1
|
||||
{ key: 'tour', start: [5, 27], end: [6, 19] }, // Jun 27 – Jul 19
|
||||
{ key: 'vuelta', start: [7, 15], end: [8, 6] }, // Aug 15 – Sep 6
|
||||
];
|
||||
|
||||
export function autoKey(): Exclude<PaletteKey, 'auto'> {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
for (const r of RACES) {
|
||||
const start = new Date(y, r.start[0], r.start[1]);
|
||||
const end = new Date(y, r.end[0], r.end[1] + 1);
|
||||
if (now >= start && now < end) return r.key;
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -24,6 +24,8 @@ dependencies = [
|
||||
"rich>=13.0", # pretty console output
|
||||
# Schema validation
|
||||
"jsonschema>=4.23",
|
||||
# Image generation (OG track images)
|
||||
"Pillow>=10.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -40,6 +42,7 @@ serve = [
|
||||
"uvicorn[standard]>=0.29",
|
||||
"python-multipart>=0.0.9",
|
||||
"bcrypt>=4.1",
|
||||
"PyJWT>=2.8",
|
||||
]
|
||||
strava = [
|
||||
"requests>=2.32",
|
||||
@@ -77,6 +80,7 @@ dev = [
|
||||
"uvicorn[standard]>=0.29",
|
||||
"python-multipart>=0.0.9",
|
||||
"bcrypt>=4.1",
|
||||
"PyJWT>=2.8",
|
||||
"httpx>=0.28.1",
|
||||
]
|
||||
|
||||
|
||||
+479
@@ -0,0 +1,479 @@
|
||||
# Refactoring Plan
|
||||
|
||||
Branch: `refactoring`
|
||||
Approach: test-first — each step starts with tests that prove correctness of the current behaviour, then the refactor makes those tests pass against the new structure.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Extract shared image utilities
|
||||
|
||||
### Problem
|
||||
`_ALLOWED_IMAGE_TYPES`, `_MAX_IMAGE_BYTES`, and `_unique_image_name()` are defined identically in two files:
|
||||
|
||||
| File | Lines |
|
||||
|---|---|
|
||||
| `bincio/edit/server.py` | 46–58 |
|
||||
| `bincio/serve/server.py` | 337–357 |
|
||||
|
||||
Any change (e.g. adding `image/avif`) must be made in both places.
|
||||
|
||||
### Target
|
||||
New module: `bincio/shared/images.py`
|
||||
|
||||
```python
|
||||
# bincio/shared/images.py
|
||||
from pathlib import Path
|
||||
|
||||
ALLOWED_IMAGE_TYPES: frozenset[str] = frozenset({
|
||||
"image/jpeg", "image/png", "image/webp", "image/gif"
|
||||
})
|
||||
MAX_IMAGE_BYTES: int = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
def unique_image_name(directory: Path, filename: str) -> str:
|
||||
"""Return a filename that does not collide with existing files in directory."""
|
||||
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||
candidate = filename
|
||||
counter = 1
|
||||
while (directory / candidate).exists():
|
||||
candidate = f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
```
|
||||
|
||||
### Test plan
|
||||
|
||||
**New file**: `tests/test_shared_images.py`
|
||||
|
||||
Write these tests first (they will fail until the module exists):
|
||||
|
||||
```python
|
||||
# tests/test_shared_images.py
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from bincio.shared.images import ALLOWED_IMAGE_TYPES, MAX_IMAGE_BYTES, unique_image_name
|
||||
|
||||
def test_constants():
|
||||
assert "image/jpeg" in ALLOWED_IMAGE_TYPES
|
||||
assert "image/png" in ALLOWED_IMAGE_TYPES
|
||||
assert "image/webp" in ALLOWED_IMAGE_TYPES
|
||||
assert "image/gif" in ALLOWED_IMAGE_TYPES
|
||||
assert "image/avif" not in ALLOWED_IMAGE_TYPES # guard against accidental expansion
|
||||
assert MAX_IMAGE_BYTES == 10 * 1024 * 1024
|
||||
|
||||
def test_unique_name_no_collision(tmp_path):
|
||||
assert unique_image_name(tmp_path, "photo.jpg") == "photo.jpg"
|
||||
|
||||
def test_unique_name_single_collision(tmp_path):
|
||||
(tmp_path / "photo.jpg").touch()
|
||||
assert unique_image_name(tmp_path, "photo.jpg") == "photo_1.jpg"
|
||||
|
||||
def test_unique_name_multiple_collisions(tmp_path):
|
||||
(tmp_path / "photo.jpg").touch()
|
||||
(tmp_path / "photo_1.jpg").touch()
|
||||
assert unique_image_name(tmp_path, "photo.jpg") == "photo_2.jpg"
|
||||
|
||||
def test_unique_name_no_suffix(tmp_path):
|
||||
(tmp_path / "photo").touch()
|
||||
assert unique_image_name(tmp_path, "photo") == "photo_1"
|
||||
|
||||
def test_unique_name_preserves_case(tmp_path):
|
||||
assert unique_image_name(tmp_path, "MyPhoto.PNG") == "MyPhoto.PNG"
|
||||
```
|
||||
|
||||
### Implementation steps
|
||||
|
||||
1. Create `bincio/shared/__init__.py` (empty).
|
||||
2. Create `bincio/shared/images.py` with the public constants and function (no leading underscore).
|
||||
3. In `bincio/edit/server.py`: replace lines 46–58 with:
|
||||
```python
|
||||
from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES
|
||||
from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES
|
||||
from bincio.shared.images import unique_image_name as _unique_image_name
|
||||
```
|
||||
4. In `bincio/serve/server.py`: same replacement at lines 337–357.
|
||||
5. Run `pytest tests/test_shared_images.py` — all pass.
|
||||
6. Run full test suite to confirm no regressions.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Extract embedded HTML template from `edit/server.py`
|
||||
|
||||
### Problem
|
||||
`bincio/edit/server.py` contains 285 lines of static HTML/CSS/JS as a Python string literal (`_HTML`, lines 63–347). This:
|
||||
|
||||
- Makes the file 30% larger than it needs to be.
|
||||
- Prevents syntax highlighting and linting of the HTML/JS.
|
||||
- Cannot be tested in isolation (template substitution is done inline by the route handler).
|
||||
|
||||
### Target
|
||||
New file: `bincio/edit/templates/edit.html`
|
||||
|
||||
The template already uses three placeholder tokens:
|
||||
- `__SITE_URL__` — replaced with `site_url` at request time
|
||||
- `__SPORT_OPTIONS__` — replaced with generated `<option>` tags
|
||||
- `__STAT_CHECKBOXES__` — replaced with generated `<label>` tags
|
||||
|
||||
Extract a helper that loads and renders the template:
|
||||
|
||||
```python
|
||||
# bincio/edit/server.py — replaces the _HTML string and inline render
|
||||
from pathlib import Path as _Path
|
||||
_TEMPLATE_PATH = _Path(__file__).parent / "templates" / "edit.html"
|
||||
|
||||
def _render_edit_html(activity_id: str) -> str:
|
||||
template = _TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
sport_options = "\n".join(
|
||||
f'<option value="{s}">{s.title()}</option>' for s in SPORTS
|
||||
)
|
||||
stat_checkboxes = "\n".join(
|
||||
f'<label class="check-item"><input type="checkbox" data-stat="{k}"> {v}</label>'
|
||||
for k, v in STAT_PANELS.items()
|
||||
)
|
||||
return (
|
||||
template
|
||||
.replace("__SITE_URL__", site_url)
|
||||
.replace("__SPORT_OPTIONS__", sport_options)
|
||||
.replace("__STAT_CHECKBOXES__", stat_checkboxes)
|
||||
)
|
||||
```
|
||||
|
||||
### Test plan
|
||||
|
||||
**Extend** `tests/test_edit_server.py` with a new section:
|
||||
|
||||
```python
|
||||
# tests/test_edit_server.py — new tests for template loading
|
||||
import bincio.edit.server as edit_server
|
||||
|
||||
def test_edit_ui_returns_html(tmp_path):
|
||||
"""GET /edit/<id> returns 200 with an HTML body containing the form."""
|
||||
edit_server.data_dir = tmp_path
|
||||
activities = tmp_path / "activities"
|
||||
activities.mkdir()
|
||||
(activities / "run-001.json").write_text('{"id":"run-001"}')
|
||||
resp = CLIENT.get("/edit/run-001")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("text/html")
|
||||
assert '<form id="form"' in resp.text
|
||||
|
||||
def test_edit_ui_injects_site_url(tmp_path):
|
||||
"""Template placeholder __SITE_URL__ is replaced with the configured site_url."""
|
||||
edit_server.data_dir = tmp_path
|
||||
edit_server.site_url = "http://localhost:1234"
|
||||
(tmp_path / "activities").mkdir(exist_ok=True)
|
||||
(tmp_path / "activities" / "run-001.json").write_text('{"id":"run-001"}')
|
||||
resp = CLIENT.get("/edit/run-001")
|
||||
assert "http://localhost:1234" in resp.text
|
||||
assert "__SITE_URL__" not in resp.text
|
||||
|
||||
def test_edit_ui_no_unresolved_placeholders(tmp_path):
|
||||
"""No placeholder tokens remain after rendering."""
|
||||
edit_server.data_dir = tmp_path
|
||||
(tmp_path / "activities").mkdir(exist_ok=True)
|
||||
(tmp_path / "activities" / "run-001.json").write_text('{"id":"run-001"}')
|
||||
resp = CLIENT.get("/edit/run-001")
|
||||
for token in ("__SITE_URL__", "__SPORT_OPTIONS__", "__STAT_CHECKBOXES__"):
|
||||
assert token not in resp.text, f"Unresolved placeholder: {token}"
|
||||
|
||||
def test_edit_template_file_exists():
|
||||
"""The template file is present on disk (guards against accidental deletion)."""
|
||||
from pathlib import Path
|
||||
template = Path(edit_server.__file__).parent / "templates" / "edit.html"
|
||||
assert template.exists(), f"Template not found at {template}"
|
||||
```
|
||||
|
||||
### Implementation steps
|
||||
|
||||
1. Create `bincio/edit/templates/` directory.
|
||||
2. Move the HTML content of `_HTML` into `bincio/edit/templates/edit.html` verbatim (keep the `__PLACEHOLDER__` tokens as-is).
|
||||
3. Delete the `_HTML = """..."""` string literal from `server.py` (lines 63–347).
|
||||
4. Add `_render_edit_html()` helper as shown above.
|
||||
5. Update the `/edit/{activity_id}` route handler to call `_render_edit_html(activity_id)` instead of the inline `_HTML.replace(...)` chain.
|
||||
6. Run `pytest tests/test_edit_server.py` — all pass (new + existing).
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Split `serve/server.py` into APIRouter modules
|
||||
|
||||
### Problem
|
||||
`bincio/serve/server.py` is 3,230 lines containing ~60 routes across 10 logical domains, all shared dependencies, all Pydantic models, and all background task machinery. It cannot be meaningfully reviewed, tested in isolation, or understood at a glance.
|
||||
|
||||
### Target structure
|
||||
|
||||
```
|
||||
bincio/serve/
|
||||
├── server.py # ~150 lines: app factory, middleware, router registration, startup
|
||||
├── deps.py # module-level globals + shared FastAPI dependency functions
|
||||
├── models.py # all Pydantic request/response models
|
||||
├── tasks.py # background workers: site-rebuild, rebuild-for-handle, jobs registry
|
||||
├── db.py # unchanged
|
||||
└── routers/
|
||||
├── __init__.py
|
||||
├── auth.py # /api/auth/*, /api/register, /api/invites
|
||||
├── me.py # /api/me/*
|
||||
├── admin.py # /api/admin/*
|
||||
├── activities.py # /api/activity/*, /api/activities/*
|
||||
├── uploads.py # /api/upload/*
|
||||
├── segments.py # /api/segments/*
|
||||
├── strava.py # /api/strava/*
|
||||
├── garmin.py # /api/garmin/*
|
||||
├── ideas.py # /api/ideas/*, /api/feedback
|
||||
└── feed.py # /api/feed, /api/stats, /api/me (read-only), /api/wheel/*
|
||||
```
|
||||
|
||||
### `deps.py` — shared state and dependency functions
|
||||
|
||||
All module-level globals that multiple routers need move here. The CLI sets them on this module before uvicorn starts.
|
||||
|
||||
```python
|
||||
# bincio/serve/deps.py
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import sqlite3
|
||||
|
||||
from fastapi import Cookie, HTTPException
|
||||
from bincio.serve.db import User, get_session, get_user
|
||||
|
||||
# ── Module-level state (set by CLI) ──────────────────────────────────────────
|
||||
data_dir: Path | None = None
|
||||
site_dir: Path | None = None
|
||||
webroot: Path | None = None
|
||||
strava_client_id: str = ""
|
||||
strava_client_secret: str = ""
|
||||
public_url: str = ""
|
||||
dem_url: str = "https://api.open-elevation.com"
|
||||
sync_secret: str = ""
|
||||
garmin_key: bytes | None = None
|
||||
_db: sqlite3.Connection | None = None
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
|
||||
SESSION_COOKIE = "bincio_session"
|
||||
COOKIE_MAX_AGE = 30 * 86400
|
||||
SESSION_DOMAIN = os.environ.get("SESSION_DOMAIN") or None
|
||||
|
||||
# ── Dependency functions ───────────────────────────────────────────────────────
|
||||
def get_data_dir() -> Path:
|
||||
if data_dir is None:
|
||||
raise HTTPException(500, "Server not configured")
|
||||
return data_dir
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
global _db
|
||||
if _db is None:
|
||||
from bincio.serve.db import open_db
|
||||
_db = open_db(get_data_dir())
|
||||
return _db
|
||||
|
||||
def get_current_user(bincio_session: Optional[str] = Cookie(default=None)) -> User:
|
||||
if not bincio_session:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
sess = get_session(get_db(), bincio_session)
|
||||
if sess is None:
|
||||
raise HTTPException(401, "Invalid or expired session")
|
||||
user = get_user(get_db(), sess.handle)
|
||||
if user is None or user.suspended:
|
||||
raise HTTPException(401, "Account not found or suspended")
|
||||
return user
|
||||
|
||||
def require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User:
|
||||
user = get_current_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Admin required")
|
||||
return user
|
||||
```
|
||||
|
||||
### `models.py` — Pydantic models
|
||||
|
||||
All `class *Request(BaseModel)` and `class *Response(BaseModel)` definitions move to `bincio/serve/models.py`. Routers import from there.
|
||||
|
||||
### `tasks.py` — background workers
|
||||
|
||||
Move `_site_rebuild_worker`, `_site_rebuild_event`, `_rebuild_for_handle`, `_active_jobs`, `_jobs_lock`, `_job_start`, `_job_update`, `_job_finish` to `bincio/serve/tasks.py`. These import from `deps.py` for `webroot` and `site_dir`.
|
||||
|
||||
### Router example — `routers/auth.py`
|
||||
|
||||
```python
|
||||
# bincio/serve/routers/auth.py
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
|
||||
from bincio.serve import deps
|
||||
from bincio.serve.models import LoginRequest, LoginResponse, ...
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/api/auth/login", response_model=LoginResponse)
|
||||
async def login(body: LoginRequest, request: Request, response: Response):
|
||||
db = deps.get_db()
|
||||
...
|
||||
```
|
||||
|
||||
Main `server.py` becomes:
|
||||
|
||||
```python
|
||||
# bincio/serve/server.py (~150 lines)
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from bincio.serve.routers import auth, me, admin, activities, uploads, segments, strava, garmin, ideas, feed
|
||||
|
||||
app = FastAPI(title="BincioActivity Serve")
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||
app.add_middleware(CORSMiddleware, ...)
|
||||
|
||||
for router in [auth.router, me.router, admin.router, activities.router,
|
||||
uploads.router, segments.router, strava.router, garmin.router,
|
||||
ideas.router, feed.router]:
|
||||
app.include_router(router)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _on_startup() -> None: ...
|
||||
```
|
||||
|
||||
### Test plan
|
||||
|
||||
**Philosophy**: write tests that call routes through the full app (via `TestClient`), so they remain valid before and after the split. Each router gets its own test file.
|
||||
|
||||
#### Before starting the split
|
||||
|
||||
Write `tests/serve/test_auth_router.py` (and equivalents for each router). These tests pass against the current monolith. After the split they must still pass — this is the regression guard.
|
||||
|
||||
```python
|
||||
# tests/serve/test_auth_router.py
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from bincio.serve.server import app
|
||||
import bincio.serve.deps as deps
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path):
|
||||
deps.data_dir = tmp_path
|
||||
deps._db = None
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
def test_login_missing_body(client):
|
||||
r = client.post("/api/auth/login", json={})
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_login_wrong_password(client):
|
||||
r = client.post("/api/auth/login", json={"handle": "nobody", "password": "x"})
|
||||
assert r.status_code in (401, 404)
|
||||
|
||||
def test_logout_unauthenticated(client):
|
||||
r = client.post("/api/auth/logout")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_register_invite_required(client):
|
||||
r = client.post("/api/register", json={"handle": "alice", "password": "pass1234", "invite": ""})
|
||||
assert r.status_code in (400, 403)
|
||||
```
|
||||
|
||||
Write similar test files for `me`, `admin`, `activities`, `uploads` before touching any production code.
|
||||
|
||||
#### After the split
|
||||
|
||||
Run the full test suite. No test should fail — the tests are route-level and do not import from the monolith's internals.
|
||||
|
||||
#### Additional tests enabled by the split
|
||||
|
||||
Once routers are isolated, add unit tests for dependency functions in `deps.py`:
|
||||
|
||||
```python
|
||||
# tests/serve/test_deps.py
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
import bincio.serve.deps as deps
|
||||
|
||||
def test_get_data_dir_raises_when_unset():
|
||||
deps.data_dir = None
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
deps.get_data_dir()
|
||||
assert exc.value.status_code == 500
|
||||
|
||||
def test_get_current_user_raises_without_cookie(tmp_path):
|
||||
deps.data_dir = tmp_path
|
||||
deps._db = None
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
deps.get_current_user(bincio_session=None)
|
||||
assert exc.value.status_code == 401
|
||||
```
|
||||
|
||||
### Implementation steps (do not start until all pre-split tests are green)
|
||||
|
||||
1. Create `bincio/serve/deps.py` — move globals, constants, `get_db`, `get_current_user`, `require_admin`, `get_data_dir`. Update `serve/cli.py` to set `deps.*` instead of `server.*`.
|
||||
2. Create `bincio/serve/models.py` — move all Pydantic models. Update imports in `server.py`.
|
||||
3. Create `bincio/serve/tasks.py` — move `_site_rebuild_worker`, `_site_rebuild_event`, `_rebuild_for_handle`, jobs registry. Import from `deps`.
|
||||
4. Create `bincio/serve/routers/__init__.py` (empty).
|
||||
5. Extract one router at a time in this order (least-coupled first):
|
||||
- `feed.py` (read-only, minimal deps)
|
||||
- `auth.py` (depends only on db, no user dir operations)
|
||||
- `me.py` (depends on current user + user dir)
|
||||
- `ideas.py`
|
||||
- `segments.py`
|
||||
- `strava.py`
|
||||
- `garmin.py`
|
||||
- `activities.py`
|
||||
- `uploads.py`
|
||||
- `admin.py` (most complex, depends on tasks)
|
||||
6. After each router extraction: run the full test suite before proceeding.
|
||||
7. Once all routers are extracted, reduce `server.py` to the app factory and middleware only.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Narrow broad `except Exception:` catches
|
||||
|
||||
### Problem
|
||||
After the Step 3 split, the router files inherited ~35 bare `except Exception:` clauses from the original monolith. Most were in route handlers where a specific narrow type is knowable and preferable — broad catches hide bugs and let surprising failures silently produce wrong results.
|
||||
|
||||
### Classification rule
|
||||
|
||||
| Situation | Decision |
|
||||
|---|---|
|
||||
| Background thread top-level guard (calls `log.exception`) | **Keep** — last-resort, full traceback essential |
|
||||
| SSE stream generator top-level | **Keep** — must convert any error to a client event |
|
||||
| Per-item batch loop (must not abort on one failure) | **Keep** — `log.warning/error` already present |
|
||||
| Explicitly non-fatal post-upload merge step | **Keep** — `log.warning` present; upload already succeeded |
|
||||
| Route handler: reading/writing JSON files | `(OSError, json.JSONDecodeError)` |
|
||||
| Route handler: datetime parsing | `ValueError` |
|
||||
| Route handler: base64 decoding | `ValueError` |
|
||||
| Route handler: YAML parsing | `(OSError, yaml.YAMLError)` |
|
||||
| Route handler: GeoJSON coord extraction | `(TypeError, IndexError, AttributeError)` |
|
||||
| Startup cleanup (`Path.unlink`) | `OSError` |
|
||||
| JSON line parsing inside SSE batch | `json.JSONDecodeError` |
|
||||
|
||||
### What was changed (28 catches narrowed across 8 files)
|
||||
|
||||
- `server.py` — startup `tmp*.zip` cleanup → `OSError`
|
||||
- `segments.py` — file-scan loops (6 catches) → `(OSError, json.JSONDecodeError, ValueError)` / `ValueError`
|
||||
- `me.py` — credential file reads, manifest write (4 catches) → `(OSError, json.JSONDecodeError)`
|
||||
- `activities.py` — index/cache reads (2 catches) → `(OSError, json.JSONDecodeError)`; YAML enrichment (1) → `(OSError, yaml.YAMLError)` with `import yaml` moved above the `try`
|
||||
- `admin.py` — diag index reads, strava-status loop reads (5 catches) → `(OSError, json.JSONDecodeError)` / `json.JSONDecodeError`
|
||||
- `ideas.py` — idea file reads (3 catches) → `(OSError, json.JSONDecodeError)`
|
||||
- `strava.py` — index parse in reset endpoint → `(OSError, json.JSONDecodeError, ValueError)`
|
||||
- `uploads.py` — GeoJSON coords, base64 decode, cache update (3 catches)
|
||||
|
||||
### What was kept (11 catches, all intentional)
|
||||
|
||||
`tasks.py:97`, `tasks.py:133` — background thread tops with `log.exception`
|
||||
`admin.py:579` — admin strava-sync background thread top with `log.exception`
|
||||
`admin.py:630` — per-activity batch loop in recompute-elevation with `log.warning`
|
||||
`garmin.py:112`, `strava.py:164`, `uploads.py:491` — SSE stream tops
|
||||
`uploads.py:143`, `uploads.py:259` — non-fatal post-upload merge with `log.warning`
|
||||
`uploads.py:245` — extraction failure → 422 (any parser failure must surface as 422)
|
||||
`uploads.py:404` — per-file batch loop in upload event stream
|
||||
|
||||
---
|
||||
|
||||
## Progress tracker
|
||||
|
||||
| # | Step | Status |
|
||||
|---|---|---|
|
||||
| 1 | Extract shared image utilities → `bincio/shared/images.py` | Done |
|
||||
| 2 | Extract HTML template → `bincio/edit/templates/edit.html` | Done |
|
||||
| 3 | Split `serve/server.py` into `deps.py` + `routers/*` | Done |
|
||||
| 4 | Narrow broad `except Exception:` catches | Done |
|
||||
|
||||
> **Note on dependency pinning**: not included. `uv.lock` already pins every dependency (including transitives) to exact versions, which is strictly stronger than switching `>=` to `~=` in `pyproject.toml`. The lockfile is the right mechanism for this concern.
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill Garmin gear for all users who have stored Garmin credentials.
|
||||
|
||||
Usage (on VPS):
|
||||
cd /opt/bincio
|
||||
uv run python3 scripts/backfill_garmin_gear.py --data-dir /var/bincio/data
|
||||
|
||||
# Limit to specific users:
|
||||
uv run python3 scripts/backfill_garmin_gear.py --data-dir /var/bincio/data --users plagzo12
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Backfill Garmin gear for all users")
|
||||
parser.add_argument("--data-dir", required=True, type=Path, help="Root data directory")
|
||||
parser.add_argument("--users", nargs="*", help="Limit to these user handles (default: all)")
|
||||
args = parser.parse_args()
|
||||
|
||||
data_dir: Path = args.data_dir.resolve()
|
||||
if not data_dir.is_dir():
|
||||
sys.exit(f"data-dir not found: {data_dir}")
|
||||
|
||||
from bincio.extract.garmin_api import GarminError, has_credentials
|
||||
from bincio.extract.garmin_sync import import_garmin_gear
|
||||
|
||||
candidates = (
|
||||
[data_dir / h for h in args.users]
|
||||
if args.users
|
||||
else sorted(p for p in data_dir.iterdir() if p.is_dir())
|
||||
)
|
||||
|
||||
garmin_users = [p for p in candidates if has_credentials(p)]
|
||||
if not garmin_users:
|
||||
print("No users with Garmin credentials found.")
|
||||
return
|
||||
|
||||
print(f"Found {len(garmin_users)} Garmin user(s): {[p.name for p in garmin_users]}\n")
|
||||
|
||||
for user_dir in garmin_users:
|
||||
handle = user_dir.name
|
||||
print(f"[{handle}] importing gear...", flush=True)
|
||||
try:
|
||||
result = import_garmin_gear(data_dir, user_dir)
|
||||
print(
|
||||
f"[{handle}] done — "
|
||||
f"gear_added={result['gear_added']}, "
|
||||
f"activities_updated={result['activities_updated']}"
|
||||
)
|
||||
except GarminError as exc:
|
||||
print(f"[{handle}] Garmin error: {exc}")
|
||||
except Exception as exc:
|
||||
print(f"[{handle}] unexpected error: {type(exc).__name__}: {exc}")
|
||||
|
||||
print("\nAll done. Run merge_all or trigger a rebuild to refresh the index shards.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -142,6 +142,11 @@ def prepare_serve() -> None:
|
||||
_write_root_manifest(DATA_DIR)
|
||||
ok("root manifest updated")
|
||||
|
||||
from bincio.explore import bake_tracks
|
||||
for handle in ("dave", "brut"):
|
||||
n = bake_tracks(handle, DATA_DIR)
|
||||
ok(f"{handle}: {n} track(s) baked for explore")
|
||||
|
||||
|
||||
# ── 4. Hand off to bincio dev ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Pre-generate OG track images for all activities.
|
||||
|
||||
Writes 400×400 PNGs to {www_root}/og-image/{user}/{activity_id}.png.
|
||||
Skips activities that already have an up-to-date image (mtime check).
|
||||
Safe to run repeatedly — only processes new/changed activities.
|
||||
|
||||
Usage:
|
||||
uv run scripts/generate_og_images.py [--data-dir /var/bincio/data] [--www-root /var/www/activity]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def generate_all(data_dir: Path, www_root: Path) -> None:
|
||||
out_root = www_root / "og-image"
|
||||
out_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from bincio.render.ogimage import generate
|
||||
|
||||
total = generated = skipped = errors = 0
|
||||
|
||||
users = sorted(
|
||||
d.name for d in data_dir.iterdir()
|
||||
if d.is_dir() and not d.name.startswith("_") and d.name != "segments"
|
||||
)
|
||||
|
||||
for handle in users:
|
||||
user_dir = data_dir / handle
|
||||
acts_dir = user_dir / "activities"
|
||||
img_dir = out_root / handle
|
||||
if not acts_dir.exists():
|
||||
continue
|
||||
img_dir.mkdir(exist_ok=True)
|
||||
u_gen = u_skip = u_err = 0
|
||||
|
||||
for ts_path in sorted(acts_dir.glob("*.timeseries.json")):
|
||||
activity_id = ts_path.name.replace(".timeseries.json", "")
|
||||
out_path = img_dir / f"{activity_id}.png"
|
||||
total += 1
|
||||
|
||||
# Skip if image is newer than timeseries
|
||||
if out_path.exists() and out_path.stat().st_mtime >= ts_path.stat().st_mtime:
|
||||
skipped += 1
|
||||
u_skip += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
lat_arr = ts.get("lat") or []
|
||||
lon_arr = ts.get("lon") or []
|
||||
ele_arr = ts.get("elevation_m") or []
|
||||
png = generate(lat_arr, lon_arr, ele_arr)
|
||||
out_path.write_bytes(png)
|
||||
generated += 1
|
||||
u_gen += 1
|
||||
time.sleep(0.05)
|
||||
except Exception as exc:
|
||||
errors += 1
|
||||
u_err += 1
|
||||
print(f" ERROR {handle}/{activity_id}: {exc}", file=sys.stderr)
|
||||
|
||||
if u_gen or u_err:
|
||||
print(f"{handle:<25} generated={u_gen:4d} skipped={u_skip:4d} errors={u_err}")
|
||||
else:
|
||||
print(f"{handle:<25} skipped={u_skip:4d} (all up to date)")
|
||||
|
||||
print(f"\nDone — {generated} generated, {skipped} skipped, {errors} errors (total {total})")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Pre-generate OG track images")
|
||||
ap.add_argument("--data-dir", default="/var/bincio/data", type=Path)
|
||||
ap.add_argument("--www-root", default="/var/www/activity", type=Path)
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.data_dir.exists():
|
||||
print(f"ERROR: data dir not found: {args.data_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"data-dir : {args.data_dir}")
|
||||
print(f"www-root : {args.www_root}")
|
||||
print(f"output : {args.www_root}/og-image/\n")
|
||||
generate_all(args.data_dir, args.www_root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,17 +6,70 @@ set -e
|
||||
VPS=${1:?Usage: $0 user@host}
|
||||
REMOTE=/var/bincio/data/_feedback
|
||||
LOCAL=$(dirname "$0")/../feedback
|
||||
SEEN="$LOCAL/.seen"
|
||||
|
||||
mkdir -p "$LOCAL"
|
||||
|
||||
echo "Syncing feedback from $VPS:$REMOTE → $LOCAL"
|
||||
rsync -avz --progress "${VPS}:${REMOTE}/" "$LOCAL/"
|
||||
rsync -az "${VPS}:${REMOTE}/" "$LOCAL/"
|
||||
|
||||
echo ""
|
||||
echo "=== Feedback summary ==="
|
||||
for f in "$LOCAL"/*.json; do
|
||||
[[ -f "$f" ]] || continue
|
||||
handle=$(basename "$f" .json)
|
||||
count=$(python3 -c "import json,sys; d=json.load(open('$f')); print(len(d) if isinstance(d, list) else 1)" 2>/dev/null || echo "?")
|
||||
echo " @$handle: $count submission(s)"
|
||||
done
|
||||
python3 - "$LOCAL" "$SEEN" <<'EOF'
|
||||
import json, sys, os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
local_dir, seen_file = sys.argv[1], sys.argv[2]
|
||||
|
||||
seen = set()
|
||||
if os.path.exists(seen_file):
|
||||
seen = set(json.loads(open(seen_file).read()))
|
||||
|
||||
new_entries = []
|
||||
all_ids = []
|
||||
|
||||
for fname in sorted(os.listdir(local_dir)):
|
||||
if not fname.endswith('.json') or fname.startswith('.'):
|
||||
continue
|
||||
handle = fname[:-5]
|
||||
try:
|
||||
entries = json.load(open(os.path.join(local_dir, fname)))
|
||||
if not isinstance(entries, list):
|
||||
entries = [entries]
|
||||
except Exception:
|
||||
continue
|
||||
for e in entries:
|
||||
eid = e.get('id', '')
|
||||
all_ids.append(eid)
|
||||
if eid not in seen:
|
||||
new_entries.append((handle, e))
|
||||
|
||||
if new_entries:
|
||||
print(f'\n\033[1;32m=== {len(new_entries)} new submission(s) ===\033[0m')
|
||||
for handle, e in new_entries:
|
||||
ts = e.get('submitted_at', '')[:10]
|
||||
text = e.get('text', '').replace('\n', ' ')
|
||||
images = e.get('images', [])
|
||||
img_note = f' [{len(images)} image(s)]' if images else ''
|
||||
print(f'\n \033[1m@{handle}\033[0m {ts}')
|
||||
if text:
|
||||
print(f' {text[:200]}{"…" if len(text) > 200 else ""}')
|
||||
if img_note:
|
||||
print(f' {img_note}')
|
||||
else:
|
||||
print('\n=== No new feedback ===')
|
||||
|
||||
# Print totals
|
||||
print('\n--- totals ---')
|
||||
for fname in sorted(os.listdir(local_dir)):
|
||||
if not fname.endswith('.json') or fname.startswith('.'):
|
||||
continue
|
||||
handle = fname[:-5]
|
||||
try:
|
||||
entries = json.load(open(os.path.join(local_dir, fname)))
|
||||
count = len(entries) if isinstance(entries, list) else 1
|
||||
print(f' @{handle}: {count}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Save updated seen set
|
||||
open(seen_file, 'w').write(json.dumps(list(set(all_ids))))
|
||||
EOF
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Audit elevation accuracy vs Strava.
|
||||
|
||||
Friends add a note with the Strava elevation to their activity descriptions.
|
||||
Supported formats (case-insensitive):
|
||||
- "strava 1323md+" most common
|
||||
- "strava 1323 m d+"
|
||||
- "Strava 1625 m d+"
|
||||
- "Strava Elevation 1173m"
|
||||
- "1038 m d+ Strava" number before the word strava
|
||||
- "Strava 207 metri di dislivello"
|
||||
|
||||
Descriptions live in _merged/activities/ (sidecar merge).
|
||||
Computed elevation_gain_m is read from activities/ (main file).
|
||||
|
||||
Usage:
|
||||
uv run scripts/strava_elevation_audit.py [--data-dir /var/bincio/data] [--out elevation_audit.csv]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from bincio.extract.metrics import elevation_params
|
||||
|
||||
# Patterns tried in order; first match wins.
|
||||
# Each pattern must have exactly one capturing group for the numeric value.
|
||||
_PATTERNS: list[re.Pattern] = [
|
||||
# "strava NNN m ..." or "strava NNNmd+"
|
||||
re.compile(r'\bstrava\b\s*([0-9][0-9.,]*)\s*m', re.IGNORECASE),
|
||||
# "Strava Elevation NNNm" or "Strava ... NNNm" (one word between)
|
||||
re.compile(r'\bstrava\b\s+\w+\s+([0-9][0-9.,]*)\s*m', re.IGNORECASE),
|
||||
# "NNN m ... strava" (number comes first, up to 20 chars before strava)
|
||||
re.compile(r'([0-9][0-9.,]*)\s*m\b.{0,20}?\bstrava\b', re.IGNORECASE),
|
||||
# "Strava NNN metri di dislivello" (Italian)
|
||||
re.compile(r'\bstrava\b.*?([0-9][0-9.,]*)\s+metr', re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
def _find_strava_elevation(description: str) -> float | None:
|
||||
for pat in _PATTERNS:
|
||||
m = pat.search(description)
|
||||
if m:
|
||||
raw = m.group(1).replace(',', '.')
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def audit(data_dir: Path, out_path: Path) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
unmatched: list[tuple[str, str]] = [] # (path, desc) couldn't parse elevation
|
||||
|
||||
for merged_path in sorted(data_dir.glob("*/_merged/activities/*.json")):
|
||||
if merged_path.suffix != ".json":
|
||||
continue
|
||||
if ".timeseries." in merged_path.name or ".geojson" in merged_path.name:
|
||||
continue
|
||||
|
||||
try:
|
||||
merged = json.loads(merged_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
|
||||
description = merged.get("description") or ""
|
||||
if not description or "strava" not in description.lower():
|
||||
continue
|
||||
|
||||
# Skip strava:// athlete-mention links (not elevation notes)
|
||||
if re.search(r'strava://', description, re.IGNORECASE):
|
||||
continue
|
||||
|
||||
strava_elev = _find_strava_elevation(description)
|
||||
if strava_elev is None:
|
||||
unmatched.append((str(merged_path), description))
|
||||
continue
|
||||
|
||||
# Read computed elevation from main activity file
|
||||
main_path = (
|
||||
merged_path.parents[3] # data_dir
|
||||
/ merged_path.parents[2].name # user
|
||||
/ "activities"
|
||||
/ merged_path.name
|
||||
)
|
||||
try:
|
||||
main = json.loads(main_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
main = merged # fall back to merged values
|
||||
|
||||
our_elev = main.get("elevation_gain_m")
|
||||
title = main.get("title") or merged.get("title") or merged_path.stem
|
||||
user = merged_path.parents[2].name
|
||||
altitude_source = main.get("altitude_source") or "unknown"
|
||||
source = main.get("source") or ""
|
||||
device = main.get("device") or "unknown"
|
||||
ma_window, threshold = elevation_params(altitude_source, source)
|
||||
|
||||
delta = round(our_elev - strava_elev, 1) if our_elev is not None else None
|
||||
pct = (
|
||||
round((our_elev - strava_elev) / strava_elev * 100, 1)
|
||||
if our_elev is not None and strava_elev != 0
|
||||
else None
|
||||
)
|
||||
|
||||
rows.append({
|
||||
"file": merged_path.name,
|
||||
"user": user,
|
||||
"title": title,
|
||||
"device": device,
|
||||
"altitude_source": altitude_source,
|
||||
"source": source,
|
||||
"ma_window_s": ma_window,
|
||||
"threshold_m": threshold,
|
||||
"our_elevation_m": our_elev,
|
||||
"strava_elevation_m": strava_elev,
|
||||
"delta_m": delta,
|
||||
"delta_pct": pct,
|
||||
"description": description[:120].replace("\n", " ").replace("\r", ""),
|
||||
})
|
||||
|
||||
rows.sort(key=lambda r: abs(r["delta_m"] or 0), reverse=True)
|
||||
|
||||
if rows:
|
||||
with out_path.open("w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
if unmatched:
|
||||
print(f"\nCould not parse elevation from {len(unmatched)} description(s):")
|
||||
for path, desc in unmatched:
|
||||
print(f" {Path(path).name} {desc[:80]!r}")
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Audit elevation accuracy vs Strava notes")
|
||||
ap.add_argument("--data-dir", default="/var/bincio/data", type=Path)
|
||||
ap.add_argument("--out", default="elevation_audit.csv", type=Path)
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.data_dir.exists():
|
||||
print(f"ERROR: data dir not found: {args.data_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Scanning {args.data_dir} …")
|
||||
rows = audit(args.data_dir, args.out)
|
||||
|
||||
if not rows:
|
||||
print("No activities found with a parseable Strava elevation note.")
|
||||
return
|
||||
|
||||
print(f"\nFound {len(rows)} activit{'y' if len(rows)==1 else 'ies'}:\n")
|
||||
header = (
|
||||
f"{'File':<50} {'User':<15} {'Source':<16} {'AltSrc':<12}"
|
||||
f" {'MA':>4} {'Thr':>5} {'Ours':>8} {'Strava':>8} {'Delta':>8} {'Delta%':>7}"
|
||||
)
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
for r in rows:
|
||||
delta_str = f"{r['delta_m']:+.0f}" if r['delta_m'] is not None else "n/a"
|
||||
pct_str = f"{r['delta_pct']:+.1f}%" if r['delta_pct'] is not None else "n/a"
|
||||
our_str = f"{r['our_elevation_m']:.0f}" if r['our_elevation_m'] is not None else "n/a"
|
||||
print(
|
||||
f"{r['file']:<50} {r['user']:<15} {r['source']:<16} {r['altitude_source']:<12}"
|
||||
f" {r['ma_window_s']:>4} {r['threshold_m']:>5.1f}"
|
||||
f" {our_str:>8} {r['strava_elevation_m']:>8.0f}"
|
||||
f" {delta_str:>8} {pct_str:>7}"
|
||||
)
|
||||
|
||||
n = len(rows)
|
||||
pcts = [r["delta_pct"] for r in rows if r["delta_pct"] is not None]
|
||||
deltas = [r["delta_m"] for r in rows if r["delta_m"] is not None]
|
||||
if pcts:
|
||||
avg_pct = sum(pcts) / len(pcts)
|
||||
sorted_pcts = sorted(pcts)
|
||||
median_pct = sorted_pcts[len(sorted_pcts) // 2]
|
||||
within_10 = sum(1 for p in pcts if abs(p) <= 10)
|
||||
within_15 = sum(1 for p in pcts if abs(p) <= 15)
|
||||
avg_d = sum(deltas) / len(deltas) if deltas else 0
|
||||
print(
|
||||
f"\n n={n} avg={avg_pct:+.1f}% median={median_pct:+.1f}%"
|
||||
f" avg delta={avg_d:+.0f} m"
|
||||
f" within ±10%: {within_10}/{n} within ±15%: {within_15}/{n}"
|
||||
)
|
||||
|
||||
print(f"\nCSV saved to: {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user