diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 71d991d..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 683fec3..f1cacd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ Anyone can publish their data as BAS JSON and others can include it. ## 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 - **Astro + Svelte + Tailwind + MapLibre GL + Observable Plot** for the site - **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 - **TCX files** from Garmin use both `http://` and `https://` namespace URIs — 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 @@ -44,7 +49,7 @@ bincio/ Python package sport.py sport name normalisation metrics.py haversine-based stats computation (single pass) 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 strava_csv.py Strava activities.csv importer writer.py BAS JSON + GeoJSON writer @@ -54,18 +59,29 @@ bincio/ Python package cli.py `bincio render` CLI (symlinks data, runs astro build/dev) merge.py sidecar edit overlay (produces _merged/) edit/ - cli.py `bincio edit` CLI + cli.py `bincio edit` CLI (single-user local only) 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/ bas-v1.schema.json JSON Schema for BAS SCHEMA.md Human-readable BAS spec site/ Astro project src/ - layouts/Base.astro + layouts/Base.astro Reads instancePrivate from index.json; injects auth wall pages/ index.astro Activity feed (loads index.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 + 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/ ActivityFeed.svelte Card grid, sport filter, pagination ActivityDetail.svelte Map + stats + charts + photo gallery @@ -73,9 +89,12 @@ site/ Astro project ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence tabs) StatsView.svelte Yearly heatmap + totals EditDrawer.svelte Slide-in edit panel (visible when PUBLIC_EDIT_URL set) + LocalActivityDetail.svelte Detail view for IDB-only (locally converted) activities lib/ types.ts BAS TypeScript types 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 @@ -85,16 +104,21 @@ site/ Astro project cd ~/src/bincio_activity uv run bincio extract --input ~/your-activity-data/activities --output /tmp/bincio_test -# Site dev server -cd site -ln -sf /tmp/bincio_test/_merged public/data # point at merged output -cp .env.example .env && $EDITOR .env # set BINCIO_DATA_DIR -npm run dev +# Site dev server (single-user) +uv run bincio render --data-dir /tmp/bincio_test --serve +# → http://localhost:4321 # Edit server (optional — enables edit drawer in the site) uv run bincio edit --data-dir /tmp/bincio_test # 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 uv run pytest ``` @@ -196,28 +220,48 @@ Rode with friends. Legs felt great after the rest week... - **Unset** → no Edit button, normal static site - **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 -- `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 - Some activities appear with both untitled and titled IDs (near-dedup timing race) -- Federation (remote data sources) not yet implemented in site -- Friends pages (`/friends/{handle}/`) not yet implemented +- Remote federation (remote shard URLs in root manifest) is parsed but not yet displayed with attribution in the UI - The `site/.env` file is gitignored — copy from `site/.env.example` ## What "good" looks like (not yet done) -- [ ] `bincio render` Python CLI wraps `astro build` properly -- [ ] Friends/federation pages in site +- [ ] Friends/federation pages in site (remote shard attribution) - [ ] Personal records page - [ ] Activity search / full-text filter in feed - [ ] GitHub Actions template for auto-publish - [ ] 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 edit` FastAPI write API (GET/POST activity, image upload/delete, triggers merge) - [x] `EditDrawer.svelte` — slide-in edit UI in the Astro site - [x] `PUBLIC_EDIT_URL` feature flag - [x] Markdown rendering in activity description with image path rewriting - [x] Photo gallery with lightbox on activity detail page -- [ ] `bincio render --watch` incremental rebuild on sidecar/data changes -- [ ] Highlight badge in activity feed cards +- [x] `bincio serve` — multi-user VPS server (auth, invites, write API) +- [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) diff --git a/README.md b/README.md index d4b1b16..6160ab4 100644 --- a/README.md +++ b/README.md @@ -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. -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 | | Strava import | requests (optional extra: `uv sync --extra strava`) | | 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) | | UI components | Svelte 5 | | Styling | Tailwind CSS v3 | diff --git a/SCHEMA.md b/SCHEMA.md index 6235587..0960341 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -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 The `bas_version` field allows consumers to handle schema evolution. Consumers diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5687a43 --- /dev/null +++ b/docs/architecture.md @@ -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. diff --git a/docs/deployment/multi-user.md b/docs/deployment/multi-user.md new file mode 100644 index 0000000..725cbfb --- /dev/null +++ b/docs/deployment/multi-user.md @@ -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) diff --git a/docs/deployment/single-user.md b/docs/deployment/single-user.md new file mode 100644 index 0000000..6313ed5 --- /dev/null +++ b/docs/deployment/single-user.md @@ -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.). diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..07c9ea9 --- /dev/null +++ b/docs/getting-started.md @@ -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 diff --git a/docs/reference/api.md b/docs/reference/api.md new file mode 100644 index 0000000..708ab6f --- /dev/null +++ b/docs/reference/api.md @@ -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, `_`, `-`; 1–30 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. diff --git a/docs/reference/cli.md b/docs/reference/cli.md new file mode 100644 index 0000000..ad4cc47 --- /dev/null +++ b/docs/reference/cli.md @@ -0,0 +1,160 @@ +# CLI reference + +All commands are run via `uv run bincio ` 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 `. + +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 `/strava_token.json` and auto-refreshed. + +--- + +## Global flags + +```bash +uv run bincio --version # print version +uv run bincio --help # list commands +uv run bincio --help # command-specific help +```