reorg documentation

This commit is contained in:
Davide Scaini
2026-04-08 19:37:33 +02:00
parent f76cc0ce7e
commit 2007f53580
10 changed files with 1065 additions and 456 deletions
-439
View File
@@ -1,439 +0,0 @@
# BincioActivity — Architecture
## Overview
BincioActivity is a two-stage pipeline that turns raw activity files (GPX, FIT, TCX) into a self-hosted static website. There is no database and no application server — everything is files.
```
Raw files ──► BAS data store ──► Static site
extract render
(Python) (Astro/Node)
```
---
## Stage 1 — Extract
`bincio extract` reads your activity files and writes a **BAS (BincioActivity Schema)** data store: a directory of JSON files.
```mermaid
flowchart LR
subgraph Input
A[GPX files]
B[FIT files]
C[TCX files]
D[activities.csv\nStrava metadata]
end
subgraph Extract ["bincio extract (Python)"]
E[Parse]
F[Compute metrics]
G[Deduplicate]
H[Write BAS JSON]
end
subgraph Output ["BAS data store ~/bincio_data/"]
I[activities/\n*.json *.geojson]
J[index.json\nall summaries]
K[athlete.json\nzones + records]
end
A & B & C --> E
D --> E
E --> F --> G --> H
H --> I & J & K
```
The data store is **immutable extract output** — never edited directly.
---
## Stage 2 — Render
`bincio render` merges any user edits, then runs an Astro build to produce a static site.
```mermaid
flowchart LR
subgraph DataStore ["BAS data store"]
A[activities/*.json]
B[edits/*.md\nsidecar files]
C[edits/images/]
end
subgraph Render ["bincio render (Astro/Node)"]
D[merge_all\napply sidecars]
E[Astro build\nSSG]
end
subgraph Site ["Static site dist/"]
F[HTML pages]
G[JS bundles]
H[data/\nBAS JSON]
end
A & B & C --> D --> E --> F & G & H
```
The rendered site is **fully static**: no server needed to serve it. GitHub Pages, Netlify, nginx — all work.
---
## The edit flow
When `bincio edit` is running locally, an **Edit** button appears in the site. It opens a drawer that writes sidecar files without touching the immutable extract output.
```mermaid
sequenceDiagram
actor User
participant Site as Static site\n(Astro dev server)
participant EditServer as bincio edit server\n(FastAPI, port 4041)
participant DataStore as BAS data store
User->>Site: clicks Edit on activity
Site->>EditServer: GET /api/activity/{id}
EditServer->>DataStore: reads .json + sidecar .md
EditServer-->>Site: current values
User->>Site: edits title / description / sport
Site->>EditServer: POST /api/activity/{id}
EditServer->>DataStore: writes edits/{id}.md
EditServer->>DataStore: runs merge_all()
EditServer-->>Site: ok
Note over Site: feed reloads with merged data
```
The edit server is **never public-facing** — it only binds to `127.0.0.1` and is only enabled when `PUBLIC_EDIT_URL` is set in `site/.env`.
---
## Data sources
There are three ways activities enter the data store:
```mermaid
flowchart TD
subgraph Sources
A[📁 Local files\nGPX / FIT / TCX]
B[🟠 Strava API\nOAuth sync]
C[📱 Convert page\nPyodide in-browser]
end
subgraph EditServer ["bincio edit server\n(when running)"]
D[POST /api/upload]
E[POST /api/strava/sync]
end
subgraph CLI ["bincio extract CLI"]
F[batch extract]
end
subgraph DataStore ["BAS data store"]
G[activities/*.json]
H[index.json]
end
A -->|bulk| F --> G & H
A -->|single file via UI| D --> G & H
B --> E --> G & H
C -->|download JSON| A
C -->|POST to edit server\nif configured| D
```
**Local files → CLI** is the primary path for bulk imports.
**Strava sync** and **file upload** go through the edit server for single activities.
**Convert page** runs the extract pipeline in-browser — output is either downloaded or sent to the edit server.
---
## The convert page
`/convert/` is a page in the static site that runs the full extract pipeline **inside the browser** using Pyodide (Python compiled to WebAssembly).
```mermaid
sequenceDiagram
actor User as User\n(on phone or desktop)
participant Page as /convert/ page\n(browser)
participant Pyodide as Pyodide runtime\n(Python in WASM)
participant EditServer as bincio edit server\n(optional)
User->>Page: opens /convert/
Page->>Pyodide: load Pyodide + packages\n(lxml, fitdecode, bincio wheel)
Note over Page,Pyodide: ~10MB, cached after first visit
User->>Page: selects GPX / FIT / TCX file
Page->>Pyodide: write file to virtual FS\nrun parse → metrics → write
Pyodide-->>Page: BAS JSON + GeoJSON strings
alt Download
Page->>User: download activity.json\n(+ activity.geojson if GPS)
else Save to bincio
Page->>EditServer: POST /api/upload
EditServer-->>Page: {id}
Page->>User: redirect to activity page
end
```
**"Save to bincio"** only appears when the page is served from an instance with `PUBLIC_EDIT_URL` set — the same flag that enables the edit drawer. For anyone else, downloading the JSON is the output.
### Why the convert page belongs to the instance
The convert page is part of the static site build — it uses the same styling, the same Pyodide wheel, and (if enabled) the same edit server. It is a tool **for the people who use this instance**:
- The instance owner uses it to convert files on mobile without needing a computer
- (Future) other users of a multi-user instance use it to upload their own activities
It is not a standalone public tool (though technically anyone with the URL could use the download path, since Pyodide runs locally in their browser).
---
## Federation
Federation is how users follow each other across different bincio instances without any central server.
```mermaid
flowchart TB
subgraph InstanceA ["Instance A — bincio.alice.com"]
A1[BAS data store]
A2[Static site]
A1 --> A2
end
subgraph InstanceB ["Instance B — bincio.bob.com"]
B1[BAS data store]
B2[Static site]
B1 --> B2
end
subgraph Browser ["Bob's browser"]
C[Feed]
end
A2 -->|BAS JSON URL| C
B2 -->|BAS JSON URL| C
%% Bob adds Alice's index.json URL to his followed feeds.
%% No accounts. No central server. Just a URL.
```
Federation is a planned feature — the data format (BAS JSON) is designed for it, but the site UI doesn't yet support adding followed feeds.
---
## Mobile app (Capacitor)
The existing Astro/Svelte site is wrapped in a **Capacitor** native shell to produce iOS and Android apps. No code is rewritten — Capacitor provides the native APIs that a plain browser PWA cannot.
### What Capacitor adds over a browser PWA
| Capability | Browser PWA | Capacitor app |
|---|---|---|
| Background GPS (iOS) | ✗ killed by OS | ✅ native entitlement |
| Background GPS (Android) | ⚠️ limited | ✅ foreground service |
| Filesystem access | ✗ sandboxed | ✅ full device storage |
| Install without App Store | ✅ (Android sideload) | iOS: App Store or TestFlight |
### Architecture
```mermaid
flowchart TD
subgraph App ["Capacitor app (iOS / Android)"]
subgraph WebView ["WebView — existing Astro/Svelte site"]
A[/record/ — GPS recorder]
B[/convert/ — Pyodide converter]
C[/feed/, /activity/, etc.]
end
subgraph Native ["Native layer"]
D[Geolocation plugin\nbackground GPS]
E[Filesystem plugin\nread/write device storage]
end
end
subgraph Cloud ["Cloud instance (optional)"]
F[Static site]
G[bincio edit server]
end
A -->|JS bridge| D
B -->|JS bridge| E
B -->|POST /api/import-bas| G
C --> F
```
### Mobile workflows
**Workflow 1 — Record on phone, save to cloud**
```
Phone app: /record/ → GPS recording (Capacitor Geolocation)
→ export GPX → /convert/
/convert/ → Pyodide runs extract pipeline in WebView
→ POST /api/import-bas to cloud edit server
Cloud instance → saves activity, site updates
```
**Workflow 2 — Record on phone, save locally (offline)**
```
Phone app: /record/ → GPS recording
→ export GPX → /convert/
/convert/ → Pyodide converts in WebView
→ download BAS JSON to device storage
(upload to cloud later when online)
```
**Workflow 3 — Import from OsmAnd / Organic Maps**
```
OsmAnd / Organic Maps → exports GPX to device storage
Phone app: /convert/ → user picks GPX file
→ Pyodide converts in WebView
→ save to cloud or download
```
**Workflow 4 — Fully offline on phone (future)**
```
Phone app: /record/ → GPS recording (native Geolocation)
→ export GPX → /convert/
/convert/ → Pyodide converts (loaded from local cache)
→ saveActivityLocally() writes to IndexedDB
Data loader → loadIndex() / loadActivity(id) read from IndexedDB
WebView → feed + activity pages populated from local data
→ edit drawer writes sidecars (merge logic in JS)
```
See "Fully offline — missing pieces" below for implementation status.
### Setup
```bash
cd site
# First time: initialise native projects
npx cap add android # creates site/android/
npx cap add ios # creates site/ios/ — requires macOS + Xcode
# Daily workflow
npm run cap:sync # build Astro + sync to native projects
npm run cap:android # build + open Android Studio
npm run cap:ios # build + open Xcode
```
### The bincio Python wheel
The `/convert/` page loads the extract pipeline via **Pyodide** (Python compiled to WebAssembly). The pipeline is bundled as `site/public/bincio.whl` — a standard Python wheel that Pyodide loads via `micropip`.
To rebuild the wheel after changing the extract code:
```bash
# from the repo root
uv build --wheel
cp dist/bincio-*.whl site/public/bincio.whl
```
Pyodide loads these packages on first visit (cached by the browser after that):
- `lxml`, `pyyaml` — prebuilt Pyodide packages (~2 MB)
- `fitdecode`, `gpxpy`, `rdp` — pure Python, installed via micropip
- `bincio.whl` — our wheel, ~150 KB
Total cold-start download: ~10 MB. Subsequent visits: instant (all cached).
### Fully offline — missing pieces
#### 1. Data access abstraction *(do this first)*
The prior design asked "how do we serve `/data/*` to the WebView from local storage?" — but that question skips a cleaner solution: eliminate the `fetch('/data/*')` calls from the hot path entirely.
The Svelte components (`ActivityFeed`, `ActivityDetail`, etc.) currently call `fetch(...)` directly. Replacing those with a thin loader module (`loadIndex()`, `loadActivity(id)`) with two implementations decouples the data source from the UI:
```
src/lib/dataloader.ts
loadIndex() → fetch('/data/index.json') [cloud build]
→ idbGet('/data/local-index') [app build]
loadActivity(id) → fetch('/data/activities/...') [cloud build]
→ idbGet('/data/activities/...') [app build]
```
The build variant is selected by an env var (`PUBLIC_DATA_MODE=cloud|local`) at Astro build time. No service worker required. No iOS WKWebView question. Useful regardless of offline support (testability, mocking, future federation transports).
**This is the first thing to build.** The service worker work already done (`sw.js`, `localstore.ts`) provides a working fallback and is useful for the browser, but the abstraction layer is cleaner and should land before investing more in the SW path.
Status: not yet implemented.
#### 2. Pyodide available offline
Currently loads from CDN (~10 MB) on first use. A service worker can cache the CDN assets on first online visit so all subsequent visits work offline. Standard PWA pattern, no app size increase, no code changes to the convert page.
Status: not yet implemented.
#### 3. Sidecar merge in JS
The edit drawer currently calls the edit server's `POST /api/activity/{id}` which triggers the Python `merge_all()`. For offline editing this needs to run locally. Port to JS (~150 lines) — **do not use Pyodide for this**.
Pyodide is lazy-loaded on `/convert/`. It is not warm just because the app is open. A user tapping Edit → change title → Save would trigger a full ~10 MB Pyodide cold start for a trivial one-line edit. The edit drawer must remain decoupled from the convert page's machinery.
Status: not yet implemented.
#### 4. If service workers fail on iOS
If testing in a Capacitor iOS WebView reveals that service workers are blocked, fall back to one of:
1. **Data access abstraction only** (§1 above) — if all `fetch('/data/*')` calls are gone, the SW question is moot.
2. **Native Swift/Kotlin micro-server** — a small HTTP server embedded in the native layer, serving from device storage. No third-party plugins.
3. **Re-run `cap sync` on import** — bundle updated static data files and resync. Crude but reliable for low-frequency imports.
`capacitor-nodejs` is not a fallback option. It embeds a full Node runtime, has spotty iOS support, and carries significant long-term maintenance cost.
#### 5. Open questions — data lifecycle on device
These don't need solutions today but will surface during implementation:
- **Storage limits:** iOS may evict IndexedDB under storage pressure. `@capacitor/filesystem` stores in the app's Documents directory (not evictable) — may be preferable for activity data.
- **Uninstall / reinstall:** IndexedDB is wiped on uninstall. Documents directory survives reinstall on iOS. Strategy TBD.
- **Sync conflicts:** if the same activity exists locally and on a cloud instance (e.g. uploaded via Strava, also recorded in-app), the merge strategy is undefined. Likely: server wins on pull, local wins on push, user resolves conflicts manually.
#### 6. Implementation order
| Step | Effort | Status |
|---|---|---|
| Data access abstraction (`dataloader.ts`) | Medium | Not started — **do first** |
| Sidecar merge in JS | Medium | Not started |
| Pyodide service worker cache | Medium | Not started |
| Test SW in Capacitor Android WebView | Low | Not started |
| Test SW on iOS | Low | Not started |
| Native micro-server (iOS fallback, if needed) | Hard | Contingency only |
---
### The `/api/import-bas` endpoint
The `/convert/` page sends pre-converted BAS JSON directly to the edit server, avoiding the need to re-parse a file on the server side. The endpoint:
1. Validates the `id` field
2. Writes `activities/{id}.json` (and `.geojson` if provided)
3. Rebuilds `index.json`
4. Runs `merge_all()`
---
## Deployment
```mermaid
flowchart LR
subgraph Local ["Local machine"]
A[bincio extract]
B[bincio edit\noptional]
end
subgraph VPS ["VPS or static host"]
C[Static site\ndist/]
D[nginx]
end
subgraph Phone ["Phone"]
E[OsmAnd /\nOrganic Maps]
F[Browser:\n/convert/]
end
A -->|rsync / CI| C
C --> D
E -->|GPX export| F
F -->|upload| B
B --> A
```
The edit server is **always local** in the single-user setup. In a future multi-user deployment on a VPS, it would run as a service alongside nginx.
+60 -16
View File
@@ -13,7 +13,7 @@ Anyone can publish their data as BAS JSON and others can include it.
## Key design decisions ## Key design decisions
- **No database, no server** — everything is static files - **No database, no server** — everything is static files in single-user mode; multi-user VPS mode adds SQLite auth only
- **Python with uv** for the extract stage - **Python with uv** for the extract stage
- **Astro + Svelte + Tailwind + MapLibre GL + Observable Plot** for the site - **Astro + Svelte + Tailwind + MapLibre GL + Observable Plot** for the site
- **Haversine** (not geopy) for distance calculations (10x faster) - **Haversine** (not geopy) for distance calculations (10x faster)
@@ -23,6 +23,11 @@ Anyone can publish their data as BAS JSON and others can include it.
- **BAS activity IDs** always use UTC with Z suffix for URL safety - **BAS activity IDs** always use UTC with Z suffix for URL safety
- **TCX files** from Garmin use both `http://` and `https://` namespace URIs — - **TCX files** from Garmin use both `http://` and `https://` namespace URIs —
parser handles both parser handles both
- **Shard manifest for multi-user** — no activity data duplication; root `index.json`
lists user shard URLs; browser resolves all shards concurrently; same mechanism
handles yearly pagination and remote federation
- **Iterative RDP** implemented inline in `simplify.py` — no `rdp` PyPI package
(not available as a pure-Python wheel for Pyodide)
## Your data ## Your data
@@ -44,7 +49,7 @@ bincio/ Python package
sport.py sport name normalisation sport.py sport name normalisation
metrics.py haversine-based stats computation (single pass) metrics.py haversine-based stats computation (single pass)
timeseries.py downsample to 1Hz, build BAS timeseries object timeseries.py downsample to 1Hz, build BAS timeseries object
simplify.py RDP track simplification → GeoJSON simplify.py RDP track simplification → GeoJSON (iterative, no rdp dep)
dedup.py exact (hash) + near-duplicate detection dedup.py exact (hash) + near-duplicate detection
strava_csv.py Strava activities.csv importer strava_csv.py Strava activities.csv importer
writer.py BAS JSON + GeoJSON writer writer.py BAS JSON + GeoJSON writer
@@ -54,18 +59,29 @@ bincio/ Python package
cli.py `bincio render` CLI (symlinks data, runs astro build/dev) cli.py `bincio render` CLI (symlinks data, runs astro build/dev)
merge.py sidecar edit overlay (produces _merged/) merge.py sidecar edit overlay (produces _merged/)
edit/ edit/
cli.py `bincio edit` CLI cli.py `bincio edit` CLI (single-user local only)
server.py FastAPI write API for the edit drawer server.py FastAPI write API for the edit drawer
serve/
cli.py `bincio serve` CLI (multi-user VPS)
server.py FastAPI: auth, user mgmt, write API (auth-gated)
db.py SQLite data layer (users, sessions, invites)
init_cmd.py `bincio init` CLI: bootstrap instance.db + admin user
schema/ schema/
bas-v1.schema.json JSON Schema for BAS bas-v1.schema.json JSON Schema for BAS
SCHEMA.md Human-readable BAS spec SCHEMA.md Human-readable BAS spec
site/ Astro project site/ Astro project
src/ src/
layouts/Base.astro layouts/Base.astro Reads instancePrivate from index.json; injects auth wall
pages/ pages/
index.astro Activity feed (loads index.json client-side) index.astro Activity feed (loads index.json client-side)
activity/[id].astro Single activity (SSG, loads detail JSON client-side) activity/[id].astro Single activity (SSG, loads detail JSON client-side)
activity/local/ IDB-only activities (converted locally via Pyodide)
stats/index.astro Heatmap + year totals stats/index.astro Heatmap + year totals
u/[handle].astro Per-user profile pages (multi-user)
login/index.astro Login form (public page)
register/index.astro Registration with invite code (public page)
invites/index.astro Invite management
convert/index.astro Local file conversion via Pyodide (browser-only)
components/ components/
ActivityFeed.svelte Card grid, sport filter, pagination ActivityFeed.svelte Card grid, sport filter, pagination
ActivityDetail.svelte Map + stats + charts + photo gallery ActivityDetail.svelte Map + stats + charts + photo gallery
@@ -73,9 +89,12 @@ site/ Astro project
ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence tabs) ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence tabs)
StatsView.svelte Yearly heatmap + totals StatsView.svelte Yearly heatmap + totals
EditDrawer.svelte Slide-in edit panel (visible when PUBLIC_EDIT_URL set) EditDrawer.svelte Slide-in edit panel (visible when PUBLIC_EDIT_URL set)
LocalActivityDetail.svelte Detail view for IDB-only (locally converted) activities
lib/ lib/
types.ts BAS TypeScript types types.ts BAS TypeScript types
format.ts formatDistance, formatDuration, sportIcon, etc. format.ts formatDistance, formatDuration, sportIcon, etc.
localstore.ts IndexedDB store for locally converted activities
dataloader.ts Fetches index.json, resolves shards recursively
``` ```
## How to run ## How to run
@@ -85,16 +104,21 @@ site/ Astro project
cd ~/src/bincio_activity cd ~/src/bincio_activity
uv run bincio extract --input ~/your-activity-data/activities --output /tmp/bincio_test uv run bincio extract --input ~/your-activity-data/activities --output /tmp/bincio_test
# Site dev server # Site dev server (single-user)
cd site uv run bincio render --data-dir /tmp/bincio_test --serve
ln -sf /tmp/bincio_test/_merged public/data # point at merged output # → http://localhost:4321
cp .env.example .env && $EDITOR .env # set BINCIO_DATA_DIR
npm run dev
# Edit server (optional — enables edit drawer in the site) # Edit server (optional — enables edit drawer in the site)
uv run bincio edit --data-dir /tmp/bincio_test uv run bincio edit --data-dir /tmp/bincio_test
# set PUBLIC_EDIT_URL=http://localhost:4041 in site/.env # set PUBLIC_EDIT_URL=http://localhost:4041 in site/.env
# Multi-user local test
uv run bincio init --data-dir /tmp/bincio_test --handle dave --password test
uv run bincio render --data-dir /tmp/bincio_test --site-dir site --serve # terminal 1
uv run bincio serve --data-dir /tmp/bincio_test # terminal 2
# site/.env: BINCIO_DATA_DIR=/tmp/bincio_test, PUBLIC_EDIT_URL= (empty)
# astro.config.mjs Vite proxy forwards /api/* → localhost:4041
# Tests # Tests
uv run pytest uv run pytest
``` ```
@@ -196,28 +220,48 @@ Rode with friends. Legs felt great after the rest week...
- **Unset** → no Edit button, normal static site - **Unset** → no Edit button, normal static site
- **Set** → edit drawer enabled; lives in `site/.env` (gitignored) - **Set** → edit drawer enabled; lives in `site/.env` (gitignored)
## Multi-user VPS architecture
`bincio serve` is a FastAPI app that owns auth and write ops. nginx proxies `/api/*` to it; static files are served by nginx directly. The Vite dev server replicates this proxy for local testing.
Key facts:
- Session cookie: `bincio_session`, httpOnly, SameSite=Lax, 30-day max-age
- Rate limiting: 10 login attempts / 15 min / IP (in-memory, resets on restart)
- Invite limits: admins unlimited, regular users 3 each (`_MAX_USER_INVITES` in `db.py`)
- Instance privacy: `instance.private=true` in root `index.json``Base.astro` injects a
`fetch('/api/me')` auth wall; `/login/` and `/register/` have `public={true}` to skip it
- Incremental rebuild: `POST /api/activity/{id}` triggers `bincio render --handle {user}`
as a fire-and-forget subprocess (only if `--site-dir` was passed to `bincio serve`)
- Write API in `bincio serve` delegates to `bincio.edit.server._apply_sidecar_edit`; the
Strava sync delegates to `bincio.edit.server.strava_sync` with a temporary data_dir swap
## Known issues / next steps ## Known issues / next steps
- `bincio render` Python CLI is functional but `--watch` mode not yet implemented - `bincio render --watch` mode not yet implemented
- Activity IDs in older test data may use `+0000` format (pre-fix); re-run extract to get `Z` format - Activity IDs in older test data may use `+0000` format (pre-fix); re-run extract to get `Z` format
- Some activities appear with both untitled and titled IDs (near-dedup timing race) - Some activities appear with both untitled and titled IDs (near-dedup timing race)
- Federation (remote data sources) not yet implemented in site - Remote federation (remote shard URLs in root manifest) is parsed but not yet displayed with attribution in the UI
- Friends pages (`/friends/{handle}/`) not yet implemented
- The `site/.env` file is gitignored — copy from `site/.env.example` - The `site/.env` file is gitignored — copy from `site/.env.example`
## What "good" looks like (not yet done) ## What "good" looks like (not yet done)
- [ ] `bincio render` Python CLI wraps `astro build` properly - [ ] Friends/federation pages in site (remote shard attribution)
- [ ] Friends/federation pages in site
- [ ] Personal records page - [ ] Personal records page
- [ ] Activity search / full-text filter in feed - [ ] Activity search / full-text filter in feed
- [ ] GitHub Actions template for auto-publish - [ ] GitHub Actions template for auto-publish
- [ ] Karoo/Garmin Connect importers beyond Strava - [ ] Karoo/Garmin Connect importers beyond Strava
- [ ] `bincio render --watch` incremental rebuild on sidecar/data changes
- [ ] Highlight badge in activity feed cards
- [x] `bincio.render.merge` — sidecar parser, `_merged/` output, private filter, highlight sort - [x] `bincio.render.merge` — sidecar parser, `_merged/` output, private filter, highlight sort
- [x] `bincio edit` FastAPI write API (GET/POST activity, image upload/delete, triggers merge) - [x] `bincio edit` FastAPI write API (GET/POST activity, image upload/delete, triggers merge)
- [x] `EditDrawer.svelte` — slide-in edit UI in the Astro site - [x] `EditDrawer.svelte` — slide-in edit UI in the Astro site
- [x] `PUBLIC_EDIT_URL` feature flag - [x] `PUBLIC_EDIT_URL` feature flag
- [x] Markdown rendering in activity description with image path rewriting - [x] Markdown rendering in activity description with image path rewriting
- [x] Photo gallery with lightbox on activity detail page - [x] Photo gallery with lightbox on activity detail page
- [ ] `bincio render --watch` incremental rebuild on sidecar/data changes - [x] `bincio serve` — multi-user VPS server (auth, invites, write API)
- [ ] Highlight badge in activity feed cards - [x] `bincio init` — instance bootstrap (SQLite, admin user, root manifest)
- [x] Login, register, invites pages
- [x] Per-user profile pages (`/u/{handle}/`)
- [x] Instance privacy (auth wall, private-by-default)
- [x] Shard-based combined feed (no duplication, concurrent resolution)
- [x] Local file conversion via Pyodide (`/convert/` page, IDB storage)
+27 -1
View File
@@ -203,7 +203,32 @@ Privacy is enforced at extract time. A `private` activity never enters `index.js
`index.json` is everything the feed page needs — no extra fetches until you open an activity. `{id}.json` contains the full timeseries (elevation, speed, HR, cadence, power at 1 Hz) for charts and the detail map. Both are human-readable and editable with any text editor. `index.json` is everything the feed page needs — no extra fetches until you open an activity. `{id}.json` contains the full timeseries (elevation, speed, HR, cadence, power at 1 Hz) for charts and the detail map. Both are human-readable and editable with any text editor.
See `SCHEMA.md` for the full specification. See [SCHEMA.md](SCHEMA.md) for the full specification.
---
## Multi-user mode (VPS)
Invite friends and run a shared instance where everyone's activities appear in a combined feed.
```bash
# One-time setup on the VPS
uv sync --extra serve
uv run bincio init --data-dir /var/bincio --handle dave --password 'pw' --name "Our Rides"
# Extract your activities into your user shard
uv run bincio extract --input ~/gpx-files --output /var/bincio/dave
# Build the site
uv run bincio render --data-dir /var/bincio --site-dir site
# Start the API server (nginx proxies /api/* to this)
uv run bincio serve --data-dir /var/bincio --site-dir site
```
Invite users: `bincio init` prints a first invite code. Share `https://example.com/register/?code=XXXXXXXX`. Invited users register themselves and upload their own activities via the browser.
See [Multi-user deployment](docs/deployment/multi-user.md) for the full nginx configuration.
--- ---
@@ -231,6 +256,7 @@ At build time the renderer fetches their public data and renders it under `/frie
| Extract | Python 3.12, click, fitdecode, gpxpy, lxml | | Extract | Python 3.12, click, fitdecode, gpxpy, lxml |
| Strava import | requests (optional extra: `uv sync --extra strava`) | | Strava import | requests (optional extra: `uv sync --extra strava`) |
| Edit server | FastAPI + uvicorn (optional extra: `uv sync --extra edit`) | | Edit server | FastAPI + uvicorn (optional extra: `uv sync --extra edit`) |
| Serve (VPS) | FastAPI + uvicorn + bcrypt + SQLite (optional extra: `uv sync --extra serve`) |
| Site framework | Astro 4 (static output) | | Site framework | Astro 4 (static output) |
| UI components | Svelte 5 | | UI components | Svelte 5 |
| Styling | Tailwind CSS v3 | | Styling | Tailwind CSS v3 |
+42
View File
@@ -354,6 +354,48 @@ When a near-duplicate is detected:
--- ---
## Instance manifest (`index.json` — multi-user mode)
In multi-user mode, the root `index.json` is a **shard manifest** rather than a user feed. It lists pointers to per-user BAS feeds. The browser fetches all shards concurrently and merges them.
```json
{
"bas_version": "1.0",
"instance": {
"name": "Our Rides",
"private": true
},
"generated_at": "2026-04-07T10:00:00Z",
"shards": [
{ "handle": "dave", "url": "dave/_merged/index.json" },
{ "handle": "alice", "url": "alice/_merged/index.json" },
{ "handle": "bob", "url": "https://bob.example.com/index.json" }
],
"activities": []
}
```
### Fields
| Field | Type | Description |
|---|---|---|
| `instance.name` | string | Human-readable instance name. |
| `instance.private` | boolean | If `true`, the site redirects unauthenticated visitors to `/login/`. |
| `shards` | array | Per-user shard entries. |
### Shard object (multi-user)
| Field | Type | Description |
|---|---|---|
| `handle` | string | User handle. Used for attribution (activities show `@handle`). |
| `url` | string | Relative or absolute URL to the user's `index.json`. |
The `url` field is relative to the location of the root manifest. Absolute URLs (starting with `http`) are fetched cross-origin — this is the federation mechanism.
Each user's `{handle}/index.json` is a valid standalone BAS feed. It can be used independently or included in another instance's shard manifest (federation).
---
## Versioning ## Versioning
The `bas_version` field allows consumers to handle schema evolution. Consumers The `bas_version` field allows consumers to handle schema evolution. Consumers
+181
View File
@@ -0,0 +1,181 @@
# Architecture
BincioActivity is a two-stage pipeline that produces a self-contained static website from raw activity files.
```
GPX / FIT / TCX files
bincio extract (Python)
BAS data store (plain JSON + GeoJSON files)
bincio render (wraps Astro build)
site/dist/ (static HTML/JS/CSS)
Any static host (GitHub Pages, Netlify, VPS, USB stick, …)
```
The BAS data store is the contract between the two stages. Any tool in any language can produce BAS-compliant JSON. See [SCHEMA.md](../SCHEMA.md) for the format.
---
## Stages
### Stage 1 — Extract (`bincio/extract/`)
Reads raw activity files, computes stats, and writes BAS JSON.
Key modules:
| Module | Role |
|---|---|
| `parsers/` | GPX, FIT, TCX parsers + format detection |
| `metrics.py` | Haversine-based stats computation (single pass) |
| `timeseries.py` | Downsample to 1 Hz, build BAS timeseries object |
| `simplify.py` | RDP track simplification → GeoJSON |
| `dedup.py` | Exact (hash) + near-duplicate detection |
| `strava_csv.py` | Strava activities.csv metadata enrichment |
| `writer.py` | BAS JSON + GeoJSON writer |
| `config.py` | `extract_config.yaml` loader |
Extract is incremental: unchanged files (same SHA-256) are skipped. To force a full re-extract, delete the output directory.
Large data is passed to worker processes once per worker via `initializer=` (not once per task), keeping ProcessPoolExecutor overhead low.
### Stage 2 — Render (`bincio/render/`)
Merges sidecar edits, symlinks data, and runs `astro build`.
```
data_dir/
activities/ ← immutable extract output
edits/ ← user-written sidecar markdown files
_merged/ ← render-time merge output (served to browser)
```
`merge_all()` overlays sidecar fields onto extracted JSON and writes `_merged/`. The browser always reads from `_merged/`.
---
## Site (`site/`)
Astro + Svelte + Tailwind + MapLibre GL + Observable Plot.
All data fetching is client-side — the site is fully static. On page load, the browser fetches `index.json`, resolves shards, and renders the feed.
Key components:
| Component | Role |
|---|---|
| `ActivityFeed.svelte` | Card grid, sport filter, pagination |
| `ActivityDetail.svelte` | Map + stats + charts + photo gallery |
| `ActivityMap.svelte` | MapLibre GL (gradient track, hover marker) |
| `ActivityCharts.svelte` | Observable Plot (elevation/speed/HR/cadence) |
| `StatsView.svelte` | Yearly heatmap + totals |
| `EditDrawer.svelte` | Slide-in edit panel (visible when edit server is running) |
### Data loading
`site/src/lib/dataloader.ts` fetches `index.json` and recursively resolves shard URLs. Shards are fetched concurrently. The same mechanism handles yearly pagination and multi-user federation.
```
index.json
└── shards: [
{ url: "dave/_merged/index.json" }, ← user shard
{ url: "https://alice.example.com/index.json" } ← federated instance
]
```
---
## Deployment modes
### Single-user (static)
No server process required. Run `bincio render`, drop `site/dist/` anywhere. The edit drawer requires `bincio edit` running locally and `PUBLIC_EDIT_URL` set in `site/.env`.
### Multi-user (VPS)
```
internet
nginx / caddy
├── /* → static files (site/dist/)
└── /api/* → proxy → bincio serve (127.0.0.1:4041)
```
`bincio serve` is a FastAPI application that owns auth, user management, and write operations. It never serves static files. nginx handles TLS and static file serving.
Data is partitioned per user:
```
/data/
instance.db ← SQLite: users, sessions, invites
index.json ← root shard manifest (no activity data)
{handle}/
index.json ← user's BAS feed
_merged/ ← sidecar-merged output
activities/
edits/
```
The root `index.json` is a shard manifest that lists user shard URLs. The browser resolves all shards concurrently and merges them into a single feed.
### Instance privacy
When `instance.private = true` in the root `index.json`, the site's `Base.astro` layout injects a client-side auth wall: it calls `GET /api/me` on every page load and redirects to `/login/` on 401/404. The `/login/` and `/register/` pages opt out of this wall via `public={true}`.
This is a best-effort client-side guard. The static files themselves are always readable by anyone with direct URL access. Use nginx-level auth if you need true access control on the static assets.
---
## Edit flow
```
Browser (EditDrawer.svelte)
│ POST /api/activity/{id}
bincio edit / bincio serve
│ writes edits/{id}.md
│ calls merge_all()
_merged/{id}.json updated
```
In multi-user mode, `bincio serve` additionally spawns `bincio render --handle {user}` to rewrite the shard manifest after each save.
---
## Federation
Any BAS-compliant feed can be included in the root `index.json`:
```json
{
"shards": [
{ "handle": "dave", "url": "dave/_merged/index.json" },
{ "handle": "alice", "url": "https://alice.example.com/index.json" }
]
}
```
Remote activities appear in the combined feed with `@alice` attribution. The browser fetches remote shards directly — there is no server-side aggregation.
---
## Key design decisions
- **No database, no server** — everything is static files except in multi-user VPS mode, where `bincio serve` owns only the auth and write API.
- **Haversine (not geopy)** for distance calculations — 10× faster for bulk processing.
- **Iterative RDP** for track simplification — no `rdp` PyPI package dependency (not available as a pure-Python wheel for Pyodide).
- **Worker initializer pattern** — large shared dicts (Strava lookup, known hashes) are sent once per worker process, not once per task.
- **BAS activity IDs always use UTC with Z suffix** — URL-safe, unambiguous, sortable.
- **TCX files** from Garmin use both `http://` and `https://` namespace URIs — the parser handles both.
- **Shard manifest for multi-user** — no activity data duplication; each user's feed is a valid standalone BAS feed; the root manifest just points at them.
+213
View File
@@ -0,0 +1,213 @@
# Multi-user deployment
Multiple users share one bincio instance. Activities are public within the instance by default. The `private` flag hides individual activities. The whole instance requires login to view (private by default).
## Architecture
```
internet
nginx / caddy
├── /* → static files (site/dist/)
└── /api/* → proxy → bincio serve (127.0.0.1:4041)
```
`bincio serve` owns all dynamic behaviour — auth, user management, write operations. nginx serves static files and proxies API routes. `bincio serve` never handles static files.
Sessions are httpOnly cookies (`bincio_session`), stored in SQLite. The Astro site calls `GET /api/me` on page load to detect the logged-in user.
## Data layout
```
/data/ ← BINCIO_DATA_DIR
instance.db ← SQLite: users, sessions, invites
index.json ← shard manifest (no activity data)
{handle}/
index.json ← user's BAS feed (activities)
_merged/ ← sidecar-merged output (served to browser)
activities/
edits/
strava_token.json
```
The root `index.json` is a shard manifest — it lists user shard URLs but contains no activity data. Each user's `{handle}/index.json` is a valid standalone BAS feed (usable for federation). The browser resolves shards concurrently and merges them.
## Step 1 — Initialise the instance
```bash
uv sync --extra serve
uv run bincio init \
--data-dir /var/bincio \
--handle dave \
--password 'your-password' \
--display-name "Dave" \
--name "My Bincio"
```
This creates:
- `/var/bincio/instance.db` — SQLite database
- `/var/bincio/dave/` — admin user data directory
- `/var/bincio/index.json` — root shard manifest (with `"private": true`)
- Prints a first invite code
`bincio init` is idempotent — safe to re-run.
## Step 2 — Extract activities
```bash
uv run bincio extract \
--input ~/activity-files \
--output /var/bincio/dave
```
## Step 3 — Build the site
```bash
cd site && npm install && cd ..
uv run bincio render \
--data-dir /var/bincio \
--site-dir site
# Output: site/dist/
```
In multi-user mode, `bincio render`:
- Runs `merge_all()` for each user's directory
- Rewrites the root `index.json` shard manifest
- Symlinks `site/public/data → /var/bincio`
- Builds the Astro site
Incremental rebuild (one user only):
```bash
uv run bincio render --data-dir /var/bincio --handle dave
# Re-merges dave's shard, rewrites root manifest — does not rebuild the site
```
## Step 4 — Configure nginx
```nginx
server {
listen 443 ssl;
server_name example.com;
root /var/www/bincio; # → site/dist/
location / {
try_files $uri $uri/ $uri.html =404;
}
location /api/ {
proxy_pass http://127.0.0.1:4041;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
Copy `site/dist/` to `/var/www/bincio` after each build.
## Step 5 — Start bincio serve
```bash
uv run bincio serve \
--data-dir /var/bincio \
--site-dir /path/to/site
```
As a systemd service:
```ini
[Unit]
Description=bincio serve
After=network.target
[Service]
Type=simple
User=bincio
WorkingDirectory=/home/bincio/bincio-activity
ExecStart=uv run bincio serve --data-dir /var/bincio --site-dir site
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
## Inviting users
After initialising, `bincio init` prints an invite code. To generate more:
```bash
# From the admin account, via the browser at /invites/
# Or directly in the database:
python3 -c "
from pathlib import Path
from bincio.serve.db import open_db, create_invite
db = open_db(Path('/var/bincio'))
print(create_invite(db, 'dave'))
"
```
Share the invite link: `https://example.com/register/?code=XXXXXXXX`
Invite limits: admins — unlimited. Regular users — 3 invites each (configurable in `bincio/serve/db.py`, `_MAX_USER_INVITES`).
## Instance privacy
By default, `bincio init` sets `"private": true` in the root `index.json`. This means every page (except `/login/` and `/register/`) redirects unauthenticated visitors to `/login/`.
To make the instance public, edit `/var/bincio/index.json` and set `"private": false`. The next `bincio render` will preserve this setting.
## Local testing (before deploying)
```bash
# 1. Initialise a test instance
uv run bincio init --data-dir /tmp/bincio_test --handle dave --password test
# 2. Extract activities into the user's dir
uv run bincio extract --input ~/activity-files --output /tmp/bincio_test/dave
# 3. Build + start the dev server (terminal 1)
uv run bincio render --data-dir /tmp/bincio_test --site-dir site --serve
# 4. Start bincio serve (terminal 2)
uv run bincio serve --data-dir /tmp/bincio_test
```
The Astro dev server proxies `/api/*` to `localhost:4041` (configured in `astro.config.mjs`), so cookies work same-origin. Set `site/.env`:
```
BINCIO_DATA_DIR=/tmp/bincio_test
PUBLIC_EDIT_URL=
```
`PUBLIC_EDIT_URL` empty = edit UI enabled via proxy. The edit/upload button appears when `bincio serve` is running. In production nginx plays the same proxy role.
## Per-user Strava sync
Each user connects their own Strava account. The OAuth token is stored in `/var/bincio/{handle}/strava_token.json`. The "Connect Strava" and "Sync" buttons in the upload modal work per-session — each user syncs only their own activities.
## Federation
To follow another bincio instance, add a shard entry to the root `index.json`:
```json
{
"shards": [
{ "handle": "dave", "url": "dave/_merged/index.json" },
{ "handle": "alice", "url": "https://alice.example.com/index.json" }
]
}
```
The browser fetches and merges remote shards concurrently. Remote activities appear in the combined feed with `@alice` attribution.
## See also
- [CLI reference — bincio init](../reference/cli.md#bincio-init)
- [CLI reference — bincio serve](../reference/cli.md#bincio-serve)
- [API reference](../reference/api.md)
- [BAS schema — instance manifest](../../SCHEMA.md#instance-manifest)
+86
View File
@@ -0,0 +1,86 @@
# Single-user deployment
One person, one machine, all your data stays with you. This is the default and simplest mode.
## GitHub Pages (free, automated)
```bash
uv run bincio render --deploy github
```
This builds `site/dist/` and pushes it to the `gh-pages` branch. Requires `npx gh-pages` (`npm install -g gh-pages`).
Set the repository to serve from the `gh-pages` branch in GitHub → Settings → Pages.
## Static hosting (Netlify, Vercel, Cloudflare Pages, etc.)
Build locally and deploy the `site/dist/` directory. Or set up CI:
```yaml
# .github/workflows/deploy.yml (example)
- run: uv run bincio render
- uses: actions/upload-pages-artifact@v3
with:
path: site/dist
```
## VPS with nginx
Serve `site/dist/` as a static directory. No server process needed for read-only access.
```nginx
server {
listen 80;
server_name example.com;
root /var/www/bincio/dist;
index index.html;
location / { try_files $uri $uri/ $uri.html =404; }
}
```
### Enable the edit UI on a VPS
If you want to edit activities from the browser while on your VPS:
```nginx
server {
listen 443 ssl;
server_name example.com;
root /var/www/bincio/dist;
location / {
try_files $uri $uri/ $uri.html =404;
}
# Proxy /api/* to bincio edit (local-only, never exposed directly)
location /api/ {
proxy_pass http://127.0.0.1:4041;
proxy_set_header Host $host;
}
}
```
Then run `bincio edit` as a background service:
```bash
uv run bincio edit --data-dir ~/bincio_data
```
And set `PUBLIC_EDIT_URL=` (empty — the proxy makes /api/ same-origin) in your environment before building.
## Keeping the site up to date
After extracting new activities or editing sidecars:
```bash
uv run bincio extract # process new files
uv run bincio render # rebuild site/dist/
rsync -av site/dist/ user@server:/var/www/bincio/dist/
```
Or automate with a cron job or GitHub Action.
## Privacy note
Single-user mode has no authentication. The site is public to anyone with the URL. Use `privacy: private` in sidecar files to hide specific activities, or restrict access at the nginx level (HTTP basic auth, IP allowlist, etc.).
+88
View File
@@ -0,0 +1,88 @@
# Getting started
BincioActivity turns a folder of GPX/FIT/TCX files into a static website you host yourself. No database. No cloud dependency. No account.
## Prerequisites
- Python ≥ 3.12 and [uv](https://docs.astral.sh/uv/)
- Node ≥ 20 and npm (for the site)
- Your activity files (Strava export, Garmin export, Karoo, etc.)
## Install
```bash
git clone https://github.com/brutsalvadi/bincio-activity.git
cd bincio-activity
uv sync
```
## Configure
```bash
cp extract_config.example.yaml extract_config.yaml
$EDITOR extract_config.yaml
```
Minimum configuration:
```yaml
owner:
handle: yourname # used in URLs and federation
display_name: Your Name
input:
dirs:
- ~/your-activity-data/activities
output:
dir: ~/bincio_data
```
The config file is gitignored — safe to store Strava credentials here.
## Extract
```bash
uv run bincio extract
```
This reads all GPX/FIT/TCX files (including `.gz` variants), deduplicates them, and writes a BAS data store to `~/bincio_data/`.
Re-running is safe — unchanged files are skipped (hash-based). To force a full re-extract: `rm -rf ~/bincio_data && uv run bincio extract`.
## Build the site
```bash
cd site && npm install && cd ..
cp site/.env.example site/.env
# Edit site/.env: set BINCIO_DATA_DIR=~/bincio_data
uv run bincio render
```
Output is in `site/dist/` — a folder of static files. Drop it anywhere: GitHub Pages, Netlify, a Raspberry Pi, a USB stick.
## Dev mode
```bash
uv run bincio render --serve # → http://localhost:4321
```
## Enable the edit UI
The edit UI lets you rename activities, add descriptions, upload photos, and sync from Strava — all from the browser.
```bash
uv sync --extra edit
uv run bincio edit # starts on http://localhost:4041
# Add to site/.env:
# PUBLIC_EDIT_URL=http://localhost:4041
```
An Edit button and an Upload ↑ button appear in the nav.
## Next steps
- [Single-user deployment](deployment/single-user.md) — serve your site on a VPS or GitHub Pages
- [Multi-user deployment](deployment/multi-user.md) — invite friends, shared feed
- [CLI reference](reference/cli.md) — all commands and options
- [BAS schema](../SCHEMA.md) — the data format and federation protocol
+208
View File
@@ -0,0 +1,208 @@
# API reference
`bincio serve` exposes a JSON API on `/api/*`. In production, nginx proxies these routes from the public domain. In local development, Vite proxies them from `astro dev`.
All request and response bodies are `application/json`. Authentication uses an httpOnly session cookie (`bincio_session`).
---
## Authentication
### `GET /api/me`
Returns the currently authenticated user, or 404 if not logged in.
**Response 200**
```json
{
"handle": "dave",
"display_name": "Dave",
"is_admin": true
}
```
**Response 404** — not authenticated
---
### `POST /api/auth/login`
Rate-limited: 10 attempts per 15 minutes per IP.
**Request**
```json
{ "handle": "dave", "password": "your-password" }
```
**Response 200** — sets `bincio_session` cookie (httpOnly, SameSite=Lax, 30-day max-age)
```json
{ "ok": true, "handle": "dave", "display_name": "Dave" }
```
**Response 401** — invalid credentials
**Response 429** — rate limit exceeded
---
### `POST /api/auth/logout`
Deletes the session from the database and clears the cookie.
**Response 200**
```json
{ "ok": true }
```
---
## Registration
### `POST /api/register`
Creates a new user account using a valid invite code.
**Request**
```json
{
"code": "ABCD1234",
"handle": "alice",
"password": "my-password",
"display_name": "Alice"
}
```
Handle rules: lowercase letters, numbers, `_`, `-`; 130 characters.
Password: minimum 8 characters.
**Response 200** — sets session cookie, logs in immediately
```json
{ "ok": true, "handle": "alice" }
```
**Response 400** — invalid handle, password too short, or invalid/used invite code
**Response 409** — handle already taken
---
## Invites
All invite endpoints require authentication.
### `GET /api/invites`
Lists invite codes created by the current user.
**Response 200**
```json
[
{
"code": "ABCD1234",
"used": false,
"used_by": null,
"created_at": "2026-04-01T10:00:00Z",
"used_at": null
}
]
```
---
### `POST /api/invites`
Generates a new invite code for the current user. Regular users are limited to 3 invites; admins are unlimited.
**Response 200**
```json
{ "ok": true, "code": "EFGH5678" }
```
**Response 400** — invite limit reached
---
## Admin
### `GET /api/admin/users`
Lists all users. Admin only.
**Response 200**
```json
[
{
"handle": "dave",
"display_name": "Dave",
"is_admin": true,
"created_at": "2026-03-01T00:00:00Z"
}
]
```
**Response 403** — not an admin
---
## Write API
All write endpoints require authentication. Users can only read/write their own activities.
### `GET /api/activity/{activity_id}`
Returns the full activity JSON for an activity owned by the current user.
**Response 200** — BAS activity detail object
**Response 404** — activity not found or not owned by user
---
### `POST /api/activity/{activity_id}`
Writes a sidecar edit for an activity. Triggers an incremental shard rebuild if `--site-dir` was passed to `bincio serve`.
**Request**
```json
{
"title": "Epic climb",
"description": "Rode with friends.",
"sport": "cycling",
"private": false,
"highlight": false,
"gear": "Trek Domane"
}
```
All fields are optional. Only provided fields are written to the sidecar.
**Response 200**
```json
{ "ok": true }
```
---
### `POST /api/strava/sync`
Triggers a Strava sync for the current user's data directory. Uses the stored OAuth token in `{handle}/strava_token.json`.
**Response 200**
```json
{ "new_count": 3, "error_count": 0 }
```
---
## Error format
All errors follow FastAPI's default format:
```json
{ "detail": "Invalid credentials" }
```
---
## Notes
- The session cookie is `SameSite=Lax`. The server sets `secure=False` because TLS termination is handled by nginx/caddy. If you serve `bincio serve` directly on HTTPS (not recommended), set `secure=True` in `server.py`.
- There is no CSRF protection — the API relies on the same-origin constraint enforced by `SameSite=Lax` cookies.
- The CORS policy allows `localhost:*` origins for local development only. Cross-origin requests from production domains are blocked — all traffic must go through the nginx proxy.
+160
View File
@@ -0,0 +1,160 @@
# CLI reference
All commands are run via `uv run bincio <command>` from the project root.
---
## bincio extract
Extract GPX/FIT/TCX files into a BAS data store.
```bash
uv run bincio extract [OPTIONS]
```
| Option | Default | Description |
|---|---|---|
| `--config PATH` | `extract_config.yaml` | Path to config file |
| `--input DIR` | from config | Input directory (scanned recursively) |
| `--output DIR` | from config | Output BAS data store directory |
| `--file PATH` | — | Extract a single file, print JSON to stdout |
| `--since DATE` | — | Only process files newer than this date (YYYY-MM-DD) |
| `--dev N` | — | Dev mode: sample N files evenly, output to `/tmp/bincio_dev/` |
Extraction is incremental by default — unchanged files (same hash) are skipped. To force a full re-extract: `rm -rf <output_dir>`.
Supported formats: GPX, FIT, TCX — all with optional `.gz` compression.
---
## bincio render
Merge sidecar edits and build (or serve) the Astro site.
```bash
uv run bincio render [OPTIONS]
```
| Option | Default | Description |
|---|---|---|
| `--data-dir DIR` | auto-detected | BAS data store |
| `--site-dir DIR` | `./site` | Astro project directory |
| `--out DIR` | `site/dist` | Build output directory |
| `--serve` | false | Start dev server instead of building |
| `--deploy TARGET` | — | Deploy after build. Currently: `github` |
| `--handle HANDLE` | — | (Multi-user) Re-merge one user's shard only, then rewrite root manifest |
`bincio render` always:
1. Runs `merge_all()` — applies sidecar edits, produces `_merged/`
2. (Multi-user) Rewrites the root `index.json` shard manifest
3. Symlinks `site/public/data` → data directory
4. Runs `astro build` (or `astro dev` with `--serve`)
Data directory auto-detection order:
1. `--data-dir` flag
2. `output.dir` in `extract_config.yaml` (if found in cwd)
3. `./site/public/data` (symlink)
4. `../bincio_data`
---
## bincio edit
Start the local single-user edit server. For personal use only — no authentication.
```bash
uv sync --extra edit # install dependencies (one-time)
uv run bincio edit [OPTIONS]
```
| Option | Default | Description |
|---|---|---|
| `--data-dir DIR` | auto-detected | BAS data store |
| `--host HOST` | `127.0.0.1` | Bind address |
| `--port PORT` | `4041` | Bind port |
| `--strava-client-id ID` | from config | Strava OAuth client ID |
| `--strava-client-secret SECRET` | from config | Strava OAuth client secret |
Set `PUBLIC_EDIT_URL=http://localhost:4041` in `site/.env` to enable the Edit button and Upload ↑ button in the site.
Credentials resolution: `--strava-client-*` flags → `STRAVA_CLIENT_ID/SECRET` env vars → `import.strava.*` in `extract_config.yaml`.
---
## bincio init
Bootstrap a fresh multi-user instance. Run once per VPS.
```bash
uv sync --extra serve # install dependencies (one-time)
uv run bincio init [OPTIONS]
```
| Option | Required | Description |
|---|---|---|
| `--data-dir DIR` | yes | BAS data directory to initialise |
| `--handle HANDLE` | yes | Admin user handle (lowercase, URL-safe) |
| `--password PASSWORD` | yes | Admin password (prompted if omitted) |
| `--display-name NAME` | no | Admin display name (defaults to handle) |
| `--name NAME` | no | Instance name shown in the feed |
Creates:
- `instance.db` — SQLite database with users/sessions/invites tables
- `{handle}/` — admin user data directory and subdirectories
- `index.json` — root shard manifest with `"private": true`
- Prints a first invite code to stdout
Idempotent — safe to re-run. Skips steps already completed.
---
## bincio serve
Start the multi-user application server (VPS mode).
```bash
uv run bincio serve [OPTIONS]
```
| Option | Default | Description |
|---|---|---|
| `--data-dir DIR` | required | BAS data directory (must contain `instance.db`) |
| `--site-dir DIR` | — | Astro site dir — enables post-write incremental rebuilds |
| `--host HOST` | `127.0.0.1` | Bind address (keep on localhost; nginx proxies from outside) |
| `--port PORT` | `4041` | Bind port |
Requires `bincio init` to have been run first. Handles auth, user management, and write operations. nginx is responsible for serving static files and proxying `/api/*` to this server.
See [multi-user deployment](../deployment/multi-user.md) for nginx configuration.
---
## bincio import strava
Import activities directly from the Strava API.
```bash
uv sync --extra strava
uv run bincio import strava [OPTIONS]
```
| Option | Default | Description |
|---|---|---|
| `--output DIR` | from config | BAS data store output directory |
| `--since DATE` | last sync | Only import activities after this date |
| `--reauth` | false | Force a new OAuth flow even if a token exists |
| `--dev N` | — | Dev mode: import N most recent activities to `/tmp/bincio_dev/` |
Credentials: set `import.strava.client_id` and `import.strava.client_secret` in `extract_config.yaml`. The Authorization Callback Domain in the Strava app settings must be `localhost`.
Tokens are stored in `<data_dir>/strava_token.json` and auto-refreshed.
---
## Global flags
```bash
uv run bincio --version # print version
uv run bincio --help # list commands
uv run bincio <cmd> --help # command-specific help
```