diff --git a/.gitignore b/.gitignore index ade4cbc..d98680e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,5 +34,8 @@ extract_config.yaml site/android/ site/ios/ +# Data +data/* + # OS .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index f1cacd0..feb1c2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,8 @@ Anyone can publish their data as BAS JSON and others can include it. ## Key design decisions -- **No database, no server** — everything is static files in single-user mode; multi-user VPS mode adds SQLite auth only +- **Unified data layout** — single-user and multi-user share the same structure: activities always live in `{data-root}/{handle}/`. The only difference is the presence of `instance.db` (auth). No mode switching, no migration. +- **No database, no server** — everything is static files; 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) @@ -100,24 +101,21 @@ site/ Astro project ## How to run ```bash -# Extract +# Single-user (no login) cd ~/src/bincio_activity -uv run bincio extract --input ~/your-activity-data/activities --output /tmp/bincio_test +uv run bincio extract --output /tmp/bincio_test # → /tmp/bincio_test/{handle}/ +uv run bincio dev --data-dir /tmp/bincio_test +# → http://localhost:4321/u/{handle}/ -# Site dev server (single-user) -uv run bincio render --data-dir /tmp/bincio_test --serve -# → http://localhost:4321 +# Multi-user (with login) +uv run bincio init --data-dir /tmp/bincio_test --handle dave +uv run bincio extract --output /tmp/bincio_test # → /tmp/bincio_test/dave/ +uv run bincio dev --data-dir /tmp/bincio_test +# → http://localhost:4321 (login required) -# 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 +# bincio dev does everything: merges sidecars, writes manifest, +# symlinks public/data, starts bincio serve (if instance.db exists), +# starts astro dev. Ctrl+C stops all. # Tests uv run pytest diff --git a/bincio/cli.py b/bincio/cli.py index cb877a9..3d73efc 100644 --- a/bincio/cli.py +++ b/bincio/cli.py @@ -17,6 +17,7 @@ from bincio.edit.cli import edit # noqa: E402 from bincio.import_.cli import import_group # noqa: E402 from bincio.serve.init_cmd import init # noqa: E402 from bincio.serve.cli import serve # noqa: E402 +from bincio.dev import dev # noqa: E402 main.add_command(extract) main.add_command(render) @@ -24,3 +25,4 @@ main.add_command(edit) main.add_command(import_group) main.add_command(init) main.add_command(serve) +main.add_command(dev) diff --git a/bincio/dev.py b/bincio/dev.py new file mode 100644 index 0000000..8bf87fd --- /dev/null +++ b/bincio/dev.py @@ -0,0 +1,165 @@ +"""bincio dev — start the full local development environment. + +Runs bincio serve (API) in a background thread and astro dev in the +foreground. One command replaces the two-terminal setup. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import threading +from pathlib import Path +from typing import Optional + +import click +from rich.console import Console + +console = Console() + + +def _find_site_dir(explicit: Optional[str]) -> Path: + if explicit: + p = Path(explicit).expanduser().resolve() + if not (p / "package.json").exists(): + raise click.UsageError(f"No package.json in --site-dir {p}") + return p + for candidate in [Path.cwd() / "site", Path.cwd().parent / "site"]: + if (candidate / "package.json").exists(): + return candidate + raise click.UsageError( + "Could not find the Astro site directory. Pass --site-dir." + ) + + +def _find_data_dir(explicit: Optional[str]) -> Path: + if explicit: + return Path(explicit).expanduser().resolve() + auto_config = Path.cwd() / "extract_config.yaml" + if auto_config.exists(): + import yaml + raw = yaml.safe_load(auto_config.read_text()) or {} + out = raw.get("output", {}).get("dir") + if out: + return Path(out).expanduser().resolve() + for candidate in [Path.cwd() / "bincio_data", Path.cwd().parent / "bincio_data"]: + if candidate.exists() and _user_dirs(candidate): + return candidate + raise click.UsageError( + "Could not find a data directory with user subdirectories. " + "Run `bincio extract` first, or pass --data-dir." + ) + + +def _ensure_npm(site: Path) -> None: + if not (site / "node_modules").exists(): + console.print("Running [cyan]npm install[/cyan]…") + subprocess.run(["npm", "install"], cwd=site, check=True) + + +def _user_dirs(data: Path) -> list[Path]: + return sorted(p for p in data.iterdir() if p.is_dir() and (p / "activities").exists()) + + +def _merge_all_users(data: Path) -> None: + from bincio.render.cli import _merge_edits, _write_root_manifest + _merge_edits(data) + _write_root_manifest(data) + + +def _start_serve(data: Path, api_port: int, site: Path) -> None: + """Start bincio serve in a background thread.""" + import uvicorn + import bincio.serve.server as srv + + srv.data_dir = data + srv.site_dir = site + + config = uvicorn.Config( + srv.app, + host="127.0.0.1", + port=api_port, + log_level="warning", # quiet — astro dev output takes priority + ) + server = uvicorn.Server(config) + server.run() + + +@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)") +@click.option("--port", default=4321, show_default=True, help="Astro dev server port") +@click.option("--api-port", default=4041, show_default=True, help="bincio serve API port") +def dev( + data_dir: Optional[str], + site_dir: Optional[str], + port: int, + api_port: int, +) -> None: + """Start the local dev environment: bincio serve + astro dev. + + Equivalent to running both servers manually in two terminals. + Requires `bincio init` to have been run first. + + \b + Quick start: + uv run bincio init --data-dir ./data --handle you --password secret + uv run bincio extract --output ./data/you + uv run bincio dev --data-dir ./data + """ + data = _find_data_dir(data_dir) + site = _find_site_dir(site_dir) + + has_auth = (data / "instance.db").exists() + + console.print(f"[bold]bincio dev[/bold]") + console.print(f" Data: [cyan]{data}[/cyan]") + console.print(f" Site: [cyan]{site}[/cyan]") + if has_auth: + console.print(f" API: [cyan]http://127.0.0.1:{api_port}[/cyan]") + else: + console.print(f" Auth: [yellow]none[/yellow] (single-user, no instance.db)") + console.print(f" Browser: [cyan]http://localhost:{port}[/cyan]") + console.print() + + _ensure_npm(site) + + console.print("Merging sidecars…") + _merge_all_users(data) + + # Symlink site/public/data → data dir + public_data = site / "public" / "data" + public_data.parent.mkdir(parents=True, exist_ok=True) + if public_data.is_symlink(): + if public_data.resolve() != data.resolve(): + public_data.unlink() + public_data.symlink_to(data) + elif not public_data.exists(): + public_data.symlink_to(data) + + # Start bincio serve only when instance.db exists (auth / write API) + if has_auth: + console.print(f"Starting [cyan]bincio serve[/cyan] on port {api_port}…") + t = threading.Thread(target=_start_serve, args=(data, api_port, site), daemon=True) + t.start() + + # Build env for astro dev + env = { + **os.environ, + "BINCIO_DATA_DIR": str(data), + "PUBLIC_EDIT_URL": "", # empty = proxy /api/* to bincio serve + "VITE_API_PORT": str(api_port), # picked up by astro.config.mjs if needed + } + + # Start astro dev in foreground (Ctrl+C stops everything) + console.print(f"Starting [cyan]astro dev[/cyan] on port {port}…") + console.print() + try: + subprocess.run( + ["npm", "run", "dev", "--", "--port", str(port)], + cwd=site, + env=env, + ) + except KeyboardInterrupt: + pass diff --git a/bincio/extract/cli.py b/bincio/extract/cli.py index 9150e8d..b95ec37 100644 --- a/bincio/extract/cli.py +++ b/bincio/extract/cli.py @@ -342,6 +342,10 @@ def _resolve_config( cfg.input_dirs = [Path(input_dir).expanduser()] if output_dir: cfg.output_dir = Path(output_dir).expanduser() + # Always write into {data_root}/{handle}/ so the data dir is always + # instance-rooted and single/multi-user share the same layout. + if cfg.output_dir.name != cfg.owner_handle: + cfg.output_dir = cfg.output_dir / cfg.owner_handle return cfg diff --git a/bincio/render/cli.py b/bincio/render/cli.py index 517ec5f..f7d18ad 100644 --- a/bincio/render/cli.py +++ b/bincio/render/cli.py @@ -70,10 +70,6 @@ def _ensure_npm(site: Path) -> None: subprocess.run(["npm", "install"], cwd=site, check=True) -def _is_multiuser(data: Path) -> bool: - return (data / "instance.db").exists() - - def _user_dirs(data: Path) -> list[Path]: """Return all per-user subdirectories (contain an activities/ dir).""" return sorted( @@ -86,21 +82,14 @@ def _merge_edits(data: Path, handle: str | None = None) -> None: """Run the sidecar merge step for one user or all users.""" from bincio.render.merge import merge_all - if _is_multiuser(data): - targets = [data / handle] if handle else _user_dirs(data) - total = 0 - for user_dir in targets: - n = merge_all(user_dir) - total += n - console.print(f" [cyan]{user_dir.name}[/cyan]: {n} sidecar(s) merged") - if not total: - console.print("No sidecars found — _merged/ dirs mirror extracted data.") - else: - n = merge_all(data) - if n: - console.print(f"Merged [cyan]{n}[/cyan] sidecar edit(s) into _merged/") - else: - console.print("No sidecars found — _merged/ mirrors extracted data.") + targets = [data / handle] if handle else _user_dirs(data) + total = 0 + for user_dir in targets: + n = merge_all(user_dir) + total += n + console.print(f" [cyan]{user_dir.name}[/cyan]: {n} sidecar(s) merged") + if not total: + console.print("No sidecars found — _merged/ dirs mirror extracted data.") def _write_root_manifest(data: Path) -> None: @@ -138,13 +127,8 @@ def _write_root_manifest(data: Path) -> None: def _link_data(site: Path, data: Path) -> None: - """Symlink site/public/data → data (multi-user) or data/_merged/ (single-user).""" - if _is_multiuser(data): - # Multi-user: link to data root directly (each user has their own _merged/) - target = data - else: - merged = data / "_merged" - target = merged if merged.exists() else data + """Symlink site/public/data → data root (each user has their own _merged/).""" + target = data public_data = site / "public" / "data" public_data.parent.mkdir(parents=True, exist_ok=True) if public_data.is_symlink(): @@ -193,14 +177,9 @@ def render( console.print(f"Site: [cyan]{site}[/cyan]") console.print(f"Data: [cyan]{data}[/cyan]") - multiuser = _is_multiuser(data) - if multiuser: - console.print("[cyan]Multi-user mode[/cyan]") - _ensure_npm(site) _merge_edits(data, handle=handle) - if multiuser: - _write_root_manifest(data) + _write_root_manifest(data) _link_data(site, data) env = {**os.environ, "BINCIO_DATA_DIR": str(data)} diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 5e689ca..58364a2 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -152,7 +152,7 @@ async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRespon @app.post("/api/auth/login") -async def login(request: Request, response: Response) -> JSONResponse: +async def login(request: Request) -> JSONResponse: ip = request.client.host if request.client else "unknown" _check_rate_limit(ip) @@ -165,22 +165,24 @@ async def login(request: Request, response: Response) -> JSONResponse: raise HTTPException(401, "Invalid credentials") token = create_session(_get_db(), handle) - _set_session_cookie(response, token) - return JSONResponse({"ok": True, "handle": user.handle, "display_name": user.display_name}) + resp = JSONResponse({"ok": True, "handle": user.handle, "display_name": user.display_name}) + _set_session_cookie(resp, token) + return resp @app.post("/api/auth/logout") -async def logout(response: Response, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: +async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: if bincio_session: delete_session(_get_db(), bincio_session) - response.delete_cookie(_SESSION_COOKIE) - return JSONResponse({"ok": True}) + resp = JSONResponse({"ok": True}) + resp.delete_cookie(_SESSION_COOKIE) + return resp # ── Registration ────────────────────────────────────────────────────────────── @app.post("/api/register") -async def register(request: Request, response: Response) -> JSONResponse: +async def register(request: Request) -> JSONResponse: body = await request.json() code = body.get("code", "").strip().upper() handle = body.get("handle", "").strip().lower() @@ -207,8 +209,9 @@ async def register(request: Request, response: Response) -> JSONResponse: (dd / handle / "edits").mkdir(parents=True, exist_ok=True) token = create_session(_get_db(), handle) - _set_session_cookie(response, token) - return JSONResponse({"ok": True, "handle": handle}) + resp = JSONResponse({"ok": True, "handle": handle}) + _set_session_cookie(resp, token) + return resp # ── Invites ─────────────────────────────────────────────────────────────────── diff --git a/docs/architecture.md b/docs/architecture.md index 5687a43..d90a3e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,9 +96,27 @@ index.json ## Deployment modes +Single-user and multi-user share the same data layout. The only difference is whether `instance.db` exists (which enables auth). + +### Data layout (always) + +``` +{data-root}/ + index.json ← shard manifest (always; one shard for single-user) + instance.db ← SQLite auth (only in multi-user, created by bincio init) + {handle}/ + index.json ← user's BAS feed + _merged/ ← sidecar-merged output + activities/ + edits/ + athlete.json +``` + ### 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`. +No login, no server. Run `bincio dev --data-dir {root}` or `bincio render`, drop `site/dist/` anywhere. The site opens directly at `/u/{handle}/`. The "Feed" tab (combined feed) is hidden — there's only one user. + +The edit drawer requires `bincio edit` running locally and `PUBLIC_EDIT_URL` set in `site/.env`. ### Multi-user (VPS) @@ -113,20 +131,7 @@ nginx / caddy `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. +The root `index.json` shard manifest lists all user shard URLs. The browser resolves them concurrently and merges activities into a combined feed at `/`. ### Instance privacy diff --git a/docs/deployment/multi-user.md b/docs/deployment/multi-user.md index 725cbfb..3ddae04 100644 --- a/docs/deployment/multi-user.md +++ b/docs/deployment/multi-user.md @@ -1,6 +1,6 @@ # 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). +Multiple users share one bincio instance. The whole instance requires login to view (private by default). Activities are visible to all logged-in users; the `private` flag hides individual activities. ## Architecture @@ -15,12 +15,12 @@ internet `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. +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 and update nav links. ## Data layout ``` -/data/ ← BINCIO_DATA_DIR +/data/ ← instance root instance.db ← SQLite: users, sessions, invites index.json ← shard manifest (no activity data) {handle}/ @@ -28,10 +28,13 @@ Sessions are httpOnly cookies (`bincio_session`), stored in SQLite. The Astro si _merged/ ← sidecar-merged output (served to browser) activities/ edits/ + athlete.json 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. +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. The browser resolves all shards concurrently and merges them into a combined feed. + +This is the same layout used for single-user deployments — the only addition is `instance.db`. ## Step 1 — Initialise the instance @@ -41,9 +44,9 @@ uv sync --extra serve uv run bincio init \ --data-dir /var/bincio \ --handle dave \ - --password 'your-password' \ --display-name "Dave" \ --name "My Bincio" +# prompted for password ``` This creates: @@ -56,10 +59,11 @@ This creates: ## Step 2 — Extract activities +Pass the **instance root** to `--output`. The handle is appended automatically: + ```bash -uv run bincio extract \ - --input ~/activity-files \ - --output /var/bincio/dave +uv run bincio extract --output /var/bincio +# → writes to /var/bincio/dave/ ``` ## Step 3 — Build the site @@ -74,17 +78,16 @@ uv run bincio render \ # 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 +`bincio render` always: +1. Runs `merge_all()` for each user's directory +2. Rewrites the root `index.json` shard manifest +3. Symlinks `site/public/data → /var/bincio` +4. Builds the Astro site -Incremental rebuild (one user only): +Incremental rebuild (one user only, no full site rebuild): ```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 @@ -136,14 +139,29 @@ Restart=on-failure WantedBy=multi-user.target ``` -## Inviting users - -After initialising, `bincio init` prints an invite code. To generate more: +## Local testing (before deploying) ```bash -# From the admin account, via the browser at /invites/ -# Or directly in the database: -python3 -c " +# 1. Initialise the instance +uv run bincio init --data-dir /tmp/bincio_test --handle dave + +# 2. Extract activities (pass instance root, not user dir) +uv run bincio extract --output /tmp/bincio_test +# → writes to /tmp/bincio_test/dave/ + +# 3. Start everything with one command +uv run bincio dev --data-dir /tmp/bincio_test +# → http://localhost:4321 +``` + +`bincio dev` detects `instance.db`, starts `bincio serve` (port 4041) in the background and `astro dev` (port 4321) in the foreground. No `.env` file needed. Ctrl+C stops both. + +## Inviting users + +After initialising, `bincio init` prints a first invite code. Generate more from the browser at `/invites/`, or directly: + +```bash +uv run python -c " from pathlib import Path from bincio.serve.db import open_db, create_invite db = open_db(Path('/var/bincio')) @@ -153,42 +171,17 @@ 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`). +Invite limits: admins — unlimited. Regular users — 3 each (configurable via `_MAX_USER_INVITES` in `bincio/serve/db.py`). ## 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/`. +`bincio init` sets `"private": true` in the root `index.json` by default. 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. +To make the instance public, edit the root `index.json` and set `"private": false`. The next `bincio render` preserves this setting. ## 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. +Each user connects their own Strava account. The OAuth token is stored in `{handle}/strava_token.json`. The "Sync from Strava" button in the upload modal works per-session — each user syncs only their own activities. ## Federation @@ -209,5 +202,6 @@ The browser fetches and merges remote shards concurrently. Remote activities app - [CLI reference — bincio init](../reference/cli.md#bincio-init) - [CLI reference — bincio serve](../reference/cli.md#bincio-serve) +- [CLI reference — bincio dev](../reference/cli.md#bincio-dev) - [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 index 6313ed5..4e6bfae 100644 --- a/docs/deployment/single-user.md +++ b/docs/deployment/single-user.md @@ -1,32 +1,65 @@ # Single-user deployment -One person, one machine, all your data stays with you. This is the default and simplest mode. +One person, one machine, all your data stays with you. No login, no server process. + +## Data layout + +All data lives under your instance root in a per-user subdirectory: + +``` +~/bincio_data/ ← instance root (output.dir in config) + index.json ← shard manifest (generated by bincio render/dev) + yourname/ + index.json ← your BAS feed + _merged/ ← sidecar-merged output (served to browser) + activities/ + edits/ + athlete.json +``` + +`bincio extract` writes into `yourname/` automatically — pass the instance root to `--output`, not the user directory. + +## Local development + +```bash +uv run bincio dev --data-dir ~/bincio_data +# → http://localhost:4321/u/yourname/ +``` + +`bincio dev` without an `instance.db` runs in single-user mode: no login, no API server, just `astro dev`. ## GitHub Pages (free, automated) ```bash -uv run bincio render --deploy github +uv run bincio render --data-dir ~/bincio_data --deploy github ``` -This builds `site/dist/` and pushes it to the `gh-pages` branch. Requires `npx gh-pages` (`npm install -g gh-pages`). +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: +Build locally and deploy `site/dist/`: + +```bash +uv run bincio render --data-dir ~/bincio_data +# upload site/dist/ to your host +``` + +Or set up CI: ```yaml # .github/workflows/deploy.yml (example) -- run: uv run bincio render +- run: uv run bincio render --data-dir ~/bincio_data - uses: actions/upload-pages-artifact@v3 with: path: site/dist ``` -## VPS with nginx +## VPS with nginx (read-only) -Serve `site/dist/` as a static directory. No server process needed for read-only access. +Serve `site/dist/` as a static directory: ```nginx server { @@ -40,7 +73,7 @@ server { ### Enable the edit UI on a VPS -If you want to edit activities from the browser while on your VPS: +To edit activities from the browser on your VPS, run `bincio edit` and proxy `/api/*` to it: ```nginx server { @@ -53,7 +86,6 @@ server { 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; @@ -61,21 +93,20 @@ server { } ``` -Then run `bincio edit` as a background service: - ```bash +uv sync --extra edit 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. +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/ +uv run bincio extract --data-dir ~/bincio_data # process new files +uv run bincio render --data-dir ~/bincio_data # rebuild site/dist/ rsync -av site/dist/ user@server:/var/www/bincio/dist/ ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 07c9ea9..6f2d7b2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -23,11 +23,11 @@ cp extract_config.example.yaml extract_config.yaml $EDITOR extract_config.yaml ``` -Minimum configuration: +Set your handle and input directory at minimum: ```yaml owner: - handle: yourname # used in URLs and federation + handle: yourname # used in URLs: /u/yourname/ display_name: Your Name input: @@ -35,54 +35,86 @@ input: - ~/your-activity-data/activities output: - dir: ~/bincio_data + dir: ~/bincio_data # instance root; activities go into ~/bincio_data/yourname/ ``` 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/`. +Reads all GPX/FIT/TCX files and writes a BAS data store to `~/bincio_data/yourname/`. Re-running is safe — unchanged files are skipped (hash-based). -Re-running is safe — unchanged files are skipped (hash-based). To force a full re-extract: `rm -rf ~/bincio_data && uv run bincio extract`. +> `--output` overrides `output.dir` from the config and is the **instance root**, +> not the user directory. The handle is always appended automatically: +> `bincio extract --output ~/bincio_data` → writes to `~/bincio_data/yourname/`. -## Build the site +--- + +## Single-user — no login, static site ```bash +# Build and preview cd site && npm install && cd .. -cp site/.env.example site/.env -# Edit site/.env: set BINCIO_DATA_DIR=~/bincio_data -uv run bincio render +uv run bincio dev --data-dir ~/bincio_data +# → http://localhost:4321 ``` -Output is in `site/dist/` — a folder of static files. Drop it anywhere: GitHub Pages, Netlify, a Raspberry Pi, a USB stick. +`bincio dev` merges edits, builds the shard manifest, and starts `astro dev`. No login required — the site opens directly at `/u/yourname/`. -## Dev mode +To build for deployment (no live server): ```bash -uv run bincio render --serve # → http://localhost:4321 +uv run bincio render --data-dir ~/bincio_data +# output: site/dist/ ``` -## Enable the edit UI +See [Single-user deployment](deployment/single-user.md). -The edit UI lets you rename activities, add descriptions, upload photos, and sync from Strava — all from the browser. +--- + +## Multi-user — shared instance, login required + +```bash +uv sync --extra serve + +# One-time: create the instance database and admin account +uv run bincio init --data-dir ~/bincio_data --handle yourname + +# Start everything +uv run bincio dev --data-dir ~/bincio_data +# → http://localhost:4321 (login with the password set during init) +``` + +`bincio dev` detects the `instance.db` and automatically starts `bincio serve` alongside `astro dev`. Ctrl+C stops both. + +See [Multi-user deployment](deployment/multi-user.md). + +--- + +## Enable the edit UI (single-user) + +The edit UI lets you rename activities, add descriptions, upload photos, and sync from Strava — from the browser. ```bash uv sync --extra edit -uv run bincio edit # starts on http://localhost:4041 +uv run bincio edit --data-dir ~/bincio_data # Add to site/.env: # PUBLIC_EDIT_URL=http://localhost:4041 ``` -An Edit button and an Upload ↑ button appear in the nav. +In multi-user mode the edit UI is always available via `bincio serve` — no extra step needed. + +--- ## 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 +- [Single-user deployment](deployment/single-user.md) — GitHub Pages, Netlify, VPS +- [Multi-user deployment](deployment/multi-user.md) — VPS with nginx, inviting users - [CLI reference](reference/cli.md) — all commands and options - [BAS schema](../SCHEMA.md) — the data format and federation protocol diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ad4cc47..f737a1d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -4,6 +4,39 @@ All commands are run via `uv run bincio ` from the project root. --- +## bincio dev + +Start the full local development environment. One command replaces the two-terminal setup. + +```bash +uv sync --extra serve +uv run bincio dev [OPTIONS] +``` + +| Option | Default | Description | +|---|---|---| +| `--data-dir DIR` | auto-detected | BAS data directory (must contain `instance.db`) | +| `--site-dir DIR` | `./site` | Astro project directory | +| `--port PORT` | `4321` | Astro dev server port | +| `--api-port PORT` | `4041` | bincio serve API port | + +`bincio dev` runs the following steps automatically: +1. Merges sidecar edits for all users (`merge_all()`) +2. Rewrites the root `index.json` shard manifest +3. Symlinks `site/public/data` → data directory +4. Starts `bincio serve` on `--api-port` in a background thread (**only if `instance.db` exists**) +5. Starts `astro dev` on `--port` in the foreground + +No `.env` file needed — `BINCIO_DATA_DIR` and `PUBLIC_EDIT_URL` are set automatically. + +Works in both modes: +- **Single-user** (no `instance.db`): no login, no API server, just `astro dev` +- **Multi-user** (`instance.db` present): starts `bincio serve` alongside `astro dev` + +Ctrl+C stops everything. + +--- + ## bincio extract Extract GPX/FIT/TCX files into a BAS data store. @@ -16,12 +49,21 @@ uv run bincio extract [OPTIONS] |---|---|---| | `--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 | +| `--output DIR` | from config | Instance root 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` (and `output.dir` in config) is the **instance root**, not the user directory. The handle from `owner.handle` in `extract_config.yaml` is always appended automatically: + +``` +bincio extract --output ~/bincio_data +# → writes to ~/bincio_data/{handle}/ +``` + +This applies to both single-user and multi-user setups — the data layout is always the same. + +Extraction is incremental by default — unchanged files (same hash) are skipped. To force a full re-extract, delete the user directory: `rm -rf ~/bincio_data/{handle}`. Supported formats: GPX, FIT, TCX — all with optional `.gz` compression. @@ -46,7 +88,7 @@ uv run bincio render [OPTIONS] `bincio render` always: 1. Runs `merge_all()` — applies sidecar edits, produces `_merged/` -2. (Multi-user) Rewrites the root `index.json` shard manifest +2. Rewrites the root `index.json` shard manifest 3. Symlinks `site/public/data` → data directory 4. Runs `astro build` (or `astro dev` with `--serve`) diff --git a/site/astro.config.mjs b/site/astro.config.mjs index ef14228..d2c0249 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -4,7 +4,10 @@ import svelte from "@astrojs/svelte"; import tailwind from "@astrojs/tailwind"; const env = loadEnv(process.env.NODE_ENV ?? 'development', process.cwd(), ''); -const serveTarget = env.PUBLIC_EDIT_URL || 'http://localhost:4041'; +// PUBLIC_EDIT_URL: non-empty → bincio edit URL; empty → proxy to bincio serve. +// VITE_API_PORT lets `bincio dev` override the serve port without touching .env. +const apiPort = process.env.VITE_API_PORT || '4041'; +const serveTarget = env.PUBLIC_EDIT_URL || `http://localhost:${apiPort}`; export default defineConfig({ integrations: [svelte(), tailwind()], diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index 102e5e1..f85f3c8 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -7,6 +7,10 @@ import { loadIndex, loadAthlete } from '../lib/dataloader'; export let base: string = '/'; + /** Explicit index URL for multi-user per-user pages (user's shard). */ + export let indexUrl: string = ''; + /** Explicit athlete.json URL for multi-user per-user pages. */ + export let athleteUrl: string = ''; let athlete: AthleteJson | null = null; let activities: ActivitySummary[] = []; @@ -34,8 +38,8 @@ mounted = true; try { const [athleteData, index] = await Promise.all([ - loadAthlete(import.meta.env.BASE_URL), - loadIndex(import.meta.env.BASE_URL), + loadAthlete(import.meta.env.BASE_URL, athleteUrl || undefined), + loadIndex(import.meta.env.BASE_URL, indexUrl || undefined), ]); if (!athleteData) throw new Error('athlete.json not found — run bincio extract first'); athlete = athleteData; diff --git a/site/src/components/StatsView.svelte b/site/src/components/StatsView.svelte index be226a0..5bde982 100644 --- a/site/src/components/StatsView.svelte +++ b/site/src/components/StatsView.svelte @@ -4,6 +4,9 @@ import { formatDistance, formatDuration, sportIcon, sportColor, sportLabel } from '../lib/format'; import { loadIndex } from '../lib/dataloader'; + /** Explicit index URL — use for per-user stats pages in multi-user mode. */ + export let indexUrl: string = ''; + const PAGE_YEARS = 4; let all: ActivitySummary[] = []; @@ -31,7 +34,7 @@ page = parseInt(params.get('page') ?? '0', 10) || 0; mounted = true; try { - const index = await loadIndex(import.meta.env.BASE_URL); + const index = await loadIndex(import.meta.env.BASE_URL, indexUrl || undefined); all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m); } catch (e: any) { error = e.message; diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 4547def..a80ffa1 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -12,8 +12,9 @@ const { title = 'BincioActivity', description = 'Your personal activity stats', const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; const baseUrl = import.meta.env.BASE_URL ?? '/'; -// Detect whether this instance is private (multi-user, requires login to view). +// Read root index.json at build time to detect instance configuration. let instancePrivate = false; +let singleHandle: string | null = null; // set when there is exactly one shard try { const candidates = [ process.env.BINCIO_DATA_DIR, @@ -24,6 +25,9 @@ try { if (dataDir) { const root = JSON.parse(readFileSync(join(dataDir, 'index.json'), 'utf-8')); instancePrivate = root?.instance?.private === true; + const shards: Array<{ handle?: string }> = root?.shards ?? []; + const handles = shards.map(s => s.handle).filter(Boolean); + if (handles.length === 1) singleHandle = handles[0] as string; } } catch { /* non-fatal */ } --- @@ -137,13 +141,29 @@ try { BincioActivity - Feed - Stats - Athlete - Record + + {!singleHandle && ( + Feed + )} + + {singleHandle + ? @{singleHandle} + : + } + + Stats + Athlete + Record Convert
+ + {editUrl && (