From 053da10ab9a02be7ce4a475d0cd3265b0cc64077 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 13:21:31 +0200 Subject: [PATCH] some basic statistics and invite tree, plus watch new data --- CHANGELOG.md | 135 ++++++++++++++++++++++++++++ CLAUDE.md | 28 +++++- bincio/dev.py | 60 +++++++++++++ bincio/serve/db.py | 27 ++++++ bincio/serve/server.py | 22 +++++ site/src/pages/about/ca/index.astro | 94 ++++++++++++++++++- site/src/pages/about/es/index.astro | 94 ++++++++++++++++++- site/src/pages/about/index.astro | 106 +++++++++++++++++++++- site/src/pages/about/it/index.astro | 94 ++++++++++++++++++- 9 files changed, 655 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b6ffc0..16ce436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,140 @@ # Changelog +## [Unreleased] — 2026-04-10 + +### New feature — Per-instance user limit + +Operators can now cap the maximum number of registered users on an instance. + +- **`bincio/serve/db.py`** + - New `settings` table (key/value, upsert-safe via `ON CONFLICT DO UPDATE`). + - `count_users(db)` — returns total number of rows in `users`. + - `get_setting(db, key)` / `set_setting(db, key, value)` — generic persistent settings store. + +- **`bincio/serve/server.py`** — `POST /api/register` now reads the `max_users` setting; if + set to N > 0 and the current user count is already ≥ N, registration is rejected with + HTTP 403 and a clear message. Imports `count_users` and `get_setting`. + +- **`bincio/serve/init_cmd.py`** — new `--max-users N` flag (default 0 = unlimited). Saves + the value to the `settings` table via `set_setting`. Printed in the init summary. + +- **`bincio/serve/cli.py`** — new `--max-users N` flag on `bincio serve`. Writes to the DB + on startup (lets operators change the limit without re-running `bincio init`). Startup + banner now shows `Users: max N` or `Users: unlimited`. + +--- + +### New feature — Original file storage option (upload & Strava sync) + +Users can now choose whether to keep their source files on the server after processing. +Keeping originals allows reprocessing if the pipeline improves; discarding them is the +privacy-conscious choice. Previously, uploaded files were always deleted after processing. + +- **`bincio/serve/db.py`** — `store_originals` is stored as a settings key. `bincio init` + writes `store_originals=true` on first run. + +- **`bincio/serve/server.py`** — `POST /api/upload` accepts a new `store_original: bool` + form field. On success, if true, the staged file is moved to `{user_dir}/originals/` + instead of being deleted. `GET /api/me` now includes `store_originals_default: bool` + (read from the instance setting) so the frontend can pre-populate the checkbox. + `POST /api/strava/sync` checks the `store_originals` instance setting; if true, creates + `{user_dir}/originals/strava/` and passes it as `originals_dir` to `run_strava_sync`. + +- **`bincio/edit/server.py`** — `POST /api/upload` gains the same `store_original` form + field with identical behaviour (originals stored in `{data_dir}/originals/`). + +- **`bincio/edit/ops.py`** — `run_strava_sync` gains an `originals_dir: Optional[Path]` + parameter, passed through to `ingest.strava_sync`. + +- **`bincio/extract/ingest.py`** — `strava_sync` gains `originals_dir: Optional[Path]`. + When set, saves `{"meta": …, "streams": …}` as JSON to + `originals_dir/{activity_id}.json` before processing each activity. This preserves the + raw Strava API response for future reprocessing without needing another API call. + +- **`bincio/serve/init_cmd.py`** — sets `store_originals=true` in the settings table on + first init (skipped if the key already exists, so re-running init doesn't override + an operator's choice). + +- **`site/src/layouts/Base.astro`** — upload modal file view gains a "Keep original file on + server" checkbox. Defaults to unchecked; pre-checked after login if the instance setting + is `true` (read from `store_originals_default` in the `/api/me` response). The checkbox + value is sent as the `store_original` form field. + +- **`bincio/serve/server.py`** and **`bincio/edit/server.py`** — `Form` added to the + FastAPI imports (was missing, causing a startup `NameError`). + +--- + +### New feature — About page (multilingual) + +New static `/about/` page explaining the project, with a Ko-fi donation button, data +storage disclaimer, and early-software caveats. Available in four languages. + +- **`site/src/pages/about/index.astro`** — English +- **`site/src/pages/about/it/index.astro`** — Italian +- **`site/src/pages/about/es/index.astro`** — Spanish +- **`site/src/pages/about/ca/index.astro`** — Catalan + +All four pages share the same structure: +- Language switcher (EN / IT / ES / CA) in the top-right corner. +- Ko-fi donation button (`https://ko-fi.com/brutsalvadi`) at the top. +- **Community stats section** — fetches `GET /api/stats` on load; shown only in + multi-user mode (silently hidden in single-user mode where the endpoint doesn't exist). + Displays total member count and an indented invitation tree: each row shows display name, + `@handle`, membership duration (days / months), and either "founder" or "invited by @X". + UI labels are fully translated per language. +- Sections: What is this · Your data on this server · Early-stage software · Disclaimer · + Open source. +- All pages use `public={true}` so they bypass the instance auth wall. + +"About" link added to the main nav bar (visible when not on a public page). +The upload modal's "Keep original file" checkbox links to `/about/` for context. + +--- + +### New feature — Community stats API + +- **`bincio/serve/db.py`** — `get_member_tree(db)` joins `users` with `invites` (on + `used_by`) to reconstruct the invitation graph. Returns a list ordered oldest-first with + `handle`, `display_name`, `created_at`, and `invited_by` (inviter handle or `None` for + the founder/admin). + +- **`bincio/serve/server.py`** — new public `GET /api/stats` endpoint (no auth required). + Returns `user_count` and a `members` array where each entry includes `handle`, + `display_name`, `member_since` (Unix timestamp), `member_for_days`, and `invited_by`. + +--- + +### Fix — `bincio dev` now watches data directory for live re-merge + +Previously, editing a sidecar or running `bincio extract` while `bincio dev` was running +required a manual restart to pick up changes. Now a background watcher thread re-merges +automatically. + +- **`bincio/dev.py`** — new `_watch_data(data)` function, started as a daemon thread + alongside `bincio serve`. Uses `watchfiles` (already bundled with `uvicorn[standard]`, + no new dependency) for OS-level file event watching — no polling. + - Watches every `{user_dir}/edits/` and `{user_dir}/activities/` directory. + - On any change, identifies which users were affected and calls `merge_all(user_dir)` + for each. + - Skips churn files written by merge itself (`.timeseries.json`, `.geojson`, + `index.json`) to avoid re-triggering. + - Prints `↺ {handle}: merged` on each successful re-merge; warns on failure. + - Astro dev picks up the result automatically since `public/data` is a symlink into + the live data directory. + +--- + +### Tests + +- **`tests/test_server_imports.py`** (new) — smoke tests that import `bincio.serve.server` + and `bincio.edit.server` at module level, catching `NameError`, missing imports, and + syntax errors before they reach the runtime. Also asserts that key routes (`/api/me`, + `/api/upload`, `/api/strava/sync`, `/api/register`, `/api/activity/{activity_id}`) are + registered on each app. + +--- + ## [Unreleased] — 2026-04-06 ### New feature — Strava sync from UI diff --git a/CLAUDE.md b/CLAUDE.md index feb1c2b..f8249e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,9 +233,31 @@ Key facts: - 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 +## Instance settings (stored in `instance.db` `settings` table) + +| Key | Default | Set by | Description | +|-----|---------|--------|-------------| +| `max_users` | — (unlimited) | `bincio init --max-users N` or `bincio serve --max-users N` | Cap on registered users; 0 or absent = unlimited | +| `store_originals` | `true` | `bincio init` (first run only) | Whether uploaded source files and raw Strava API data are kept in `{user_dir}/originals/` | + +`get_setting` / `set_setting` in `db.py` are the read/write accessors. Any new instance-wide flag should use this table rather than a new column. + +## Original file storage + +When a user uploads a FIT/GPX/TCX file the server may keep the source in `{user_dir}/originals/{filename}` rather than always deleting it after extraction. The per-upload `store_original` form field controls the behaviour for a single upload (sent by the UI checkbox). The instance-level `store_originals` setting provides the default that pre-populates the checkbox (read from `GET /api/me` → `store_originals_default`). + +For Strava sync, `store_originals=true` causes `POST /api/strava/sync` to save `{"meta":…,"streams":…}` JSON per activity to `{user_dir}/originals/strava/{activity_id}.json`. + +## About pages + +Static public pages at `/about/` (EN), `/about/it/` (IT), `/about/es/` (ES), `/about/ca/` (CA). All use `public={true}` to bypass the auth wall. Each page: +- Shows a Ko-fi donation button at the top. +- Fetches `GET /api/stats` on load and renders a **community/invitation tree** (member count, each user's display name, membership duration, and who invited them). Hidden silently in single-user mode. +- Contains project description, data storage explanation, early-software caveat, and liability disclaimer. + ## Known issues / next steps -- `bincio render --watch` mode not yet implemented +- `bincio render --watch` mode not yet implemented as a standalone command, but `bincio dev` now watches the data directory via `watchfiles` (bundled with uvicorn) and re-runs `merge_all` automatically when sidecars or activity files change - 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) - Remote federation (remote shard URLs in root manifest) is parsed but not yet displayed with attribution in the UI @@ -250,6 +272,10 @@ Key facts: - [ ] Karoo/Garmin Connect importers beyond Strava - [ ] `bincio render --watch` incremental rebuild on sidecar/data changes - [ ] Highlight badge in activity feed cards +- [x] Per-instance user limit (`max_users` setting, enforced at registration) +- [x] Original file storage option (per-upload checkbox + `store_originals` instance setting) +- [x] About page — multilingual (EN/IT/ES/CA), Ko-fi button, community invitation tree +- [x] `GET /api/stats` — public endpoint with member count and invitation tree - [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 diff --git a/bincio/dev.py b/bincio/dev.py index 24b5292..b40d441 100644 --- a/bincio/dev.py +++ b/bincio/dev.py @@ -86,6 +86,62 @@ def _start_serve(data: Path, api_port: int, site: Path) -> None: server.run() +def _watch_data(data: Path) -> None: + """Watch the data directory for sidecar/activity changes and re-merge. + + Monitors every user's edits/ and activities/ subdirectories. When any file + changes (new activity extracted, sidecar saved), re-runs merge_all for that + user so the _merged/ symlink tree stays current. Astro dev picks up the + result automatically because public/data is a symlink into the live data dir. + + Uses watchfiles (bundled with uvicorn[standard]) for efficient OS-level + file watching — no polling. + """ + from watchfiles import watch, Change + + watch_paths = [] + for user_dir in _user_dirs(data): + for sub in ("edits", "activities"): + p = user_dir / sub + p.mkdir(exist_ok=True) + watch_paths.append(p) + + if not watch_paths: + return + + console.print(f" [dim]Watching {len(watch_paths)} director{'y' if len(watch_paths) == 1 else 'ies'} for changes…[/dim]") + + # Build a map from path prefix → user dir for targeted merge + prefix_to_user: dict[str, Path] = {} + for user_dir in _user_dirs(data): + for sub in ("edits", "activities"): + prefix_to_user[str(user_dir / sub)] = user_dir + + for changes in watch(*watch_paths, yield_on_timeout=False): + # Find which users were affected + affected: set[Path] = set() + for change_type, path in changes: + # Skip timeseries / geojson / index churn written by merge itself + if any(path.endswith(s) for s in (".timeseries.json", ".geojson", "index.json")): + continue + for prefix, user_dir in prefix_to_user.items(): + if path.startswith(prefix): + affected.add(user_dir) + break + + if not affected: + continue + + for user_dir in affected: + handle = user_dir.name + try: + from bincio.render.merge import merge_all + merge_all(user_dir) + console.print(f" [dim]↺ {handle}: merged[/dim]") + except Exception as exc: + console.print(f" [yellow]⚠ {handle}: merge failed — {exc}[/yellow]") + + @click.command("dev") @click.option("--data-dir", default=None, help="BAS data directory (must contain instance.db)") @click.option("--site-dir", default=None, help="Astro project directory (default: ./site)") @@ -144,6 +200,10 @@ def dev( t = threading.Thread(target=_start_serve, args=(data, api_port, site), daemon=True) t.start() + # Watch data dir for sidecar/activity changes → auto-merge + watcher = threading.Thread(target=_watch_data, args=(data,), daemon=True) + watcher.start() + # Build env for astro dev env = { **os.environ, diff --git a/bincio/serve/db.py b/bincio/serve/db.py index d939ad4..4485a01 100644 --- a/bincio/serve/db.py +++ b/bincio/serve/db.py @@ -154,6 +154,33 @@ def delete_user(db: sqlite3.Connection, handle: str) -> None: db.commit() +def get_member_tree(db: sqlite3.Connection) -> list[dict]: + """Return users with their inviter handle and join timestamp. + + Each entry: {handle, display_name, created_at, invited_by (handle or None)}. + Ordered oldest-first so callers can build the tree top-down. + """ + users = {r["handle"]: r for r in db.execute( + "SELECT handle, display_name, created_at FROM users ORDER BY created_at" + ).fetchall()} + # Map invitee → inviter from the used invites + invited_by: dict[str, str] = {} + for row in db.execute( + "SELECT created_by, used_by FROM invites WHERE used_by IS NOT NULL" + ).fetchall(): + invited_by[row["used_by"]] = row["created_by"] + + return [ + { + "handle": r["handle"], + "display_name": r["display_name"], + "created_at": r["created_at"], + "invited_by": invited_by.get(r["handle"]), + } + for r in users.values() + ] + + def count_users(db: sqlite3.Connection) -> int: """Return the total number of registered users.""" row = db.execute("SELECT COUNT(*) FROM users").fetchone() diff --git a/bincio/serve/server.py b/bincio/serve/server.py index d6f3f95..0b07c71 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -29,6 +29,7 @@ from bincio.serve.db import ( create_user, delete_session, get_invite, + get_member_tree, get_session, get_setting, get_user, @@ -173,6 +174,27 @@ async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRespon }) +@app.get("/api/stats") +async def stats() -> JSONResponse: + """Public endpoint: member count, join dates, and invitation tree.""" + import time as _time + now = int(_time.time()) + members = get_member_tree(_get_db()) + return JSONResponse({ + "user_count": len(members), + "members": [ + { + "handle": m["handle"], + "display_name": m["display_name"], + "member_since": m["created_at"], + "member_for_days": (now - m["created_at"]) // 86400, + "invited_by": m["invited_by"], + } + for m in members + ], + }) + + @app.post("/api/auth/login") async def login(request: Request) -> JSONResponse: ip = request.client.host if request.client else "unknown" diff --git a/site/src/pages/about/ca/index.astro b/site/src/pages/about/ca/index.astro index 5452a76..3d29722 100644 --- a/site/src/pages/about/ca/index.astro +++ b/site/src/pages/about/ca/index.astro @@ -1,6 +1,15 @@ --- import Base from '../../../layouts/Base.astro'; const baseUrl = import.meta.env.BASE_URL ?? '/'; +const labels = { + community: 'Comunitat', + members: 'membre', + members_pl: 'membres', + day: 'dia', + days: 'dies', + invited_by: 'convidat per', + founder: 'fundador', +}; ---
@@ -26,6 +35,12 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
+ +

Què és això?

@@ -108,7 +123,84 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';

-
+ + diff --git a/site/src/pages/about/es/index.astro b/site/src/pages/about/es/index.astro index 8633e72..ea950b6 100644 --- a/site/src/pages/about/es/index.astro +++ b/site/src/pages/about/es/index.astro @@ -1,6 +1,15 @@ --- import Base from '../../../layouts/Base.astro'; const baseUrl = import.meta.env.BASE_URL ?? '/'; +const labels = { + community: 'Comunidad', + members: 'miembro', + members_pl: 'miembros', + day: 'día', + days: 'días', + invited_by: 'invitado por', + founder: 'fundador', +}; ---
@@ -26,6 +35,12 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
+ +

¿Qué es esto?

@@ -107,7 +122,84 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';

-
+ + diff --git a/site/src/pages/about/index.astro b/site/src/pages/about/index.astro index 883e64b..c3aa740 100644 --- a/site/src/pages/about/index.astro +++ b/site/src/pages/about/index.astro @@ -1,6 +1,16 @@ --- import Base from '../../layouts/Base.astro'; const baseUrl = import.meta.env.BASE_URL ?? '/'; +const labels = { + community: 'Community', + members: 'member', + members_pl: 'members', + day: 'day', + days: 'days', + invited_by: 'invited by', + founder: 'founder', + loading: 'Loading…', +}; ---
@@ -26,6 +36,13 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
+ + +

What is this?

@@ -103,7 +120,94 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';

-
+ + diff --git a/site/src/pages/about/it/index.astro b/site/src/pages/about/it/index.astro index 3488fef..783318b 100644 --- a/site/src/pages/about/it/index.astro +++ b/site/src/pages/about/it/index.astro @@ -1,6 +1,15 @@ --- import Base from '../../../layouts/Base.astro'; const baseUrl = import.meta.env.BASE_URL ?? '/'; +const labels = { + community: 'Comunità', + members: 'membro', + members_pl: 'membri', + day: 'giorno', + days: 'giorni', + invited_by: 'invitato da', + founder: 'fondatore', +}; ---
@@ -26,6 +35,12 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
+ +

Cos'è?

@@ -107,7 +122,84 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';

-
+ +