# snotes — Agent Guide ## Project Overview A multi-user hosted markdown notes app. Notes are plain `.md` files on a mounted filesystem (NAS at `/mnt/aura/snotes`), while metadata, tags, history, and a full-text search index live in SQLite. Built with FastAPI + a vanilla-JS SPA that embeds TipTap (ProseMirror) for the editor. ## Essential Commands | Action | Command | |---|---| | Dev server | `./run-local` (or `uv run uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000`) | | Run tests | `uv run pytest` | | Lint | `uv run ruff check .` | | Format | `uv run ruff format .` | | Install deps | `uv sync` | | Build Docker | `docker compose build` | ## Project Structure ``` snotes/ ├── backend/ │ ├── main.py # FastAPI app, routes, lifespan, auth dependency │ ├── config.py # pydantic-settings, reads .env │ ├── database.py # aiosqlite: schema, seed users, list_users │ ├── notes.py # note CRUD, tag extraction, FTS index, history │ ├── auth.py # LAN vs remote gate + signed session cookie │ └── models.py # Pydantic request models ├── static/ │ ├── index.html # SPA shell; loads marked, turndown, app.js (ESM) │ ├── app.js # SPA client + TipTap editor (esm.sh CDN) │ ├── app.css # Hyper-inspired stylesheet │ └── snotes-main.png # logo + favicon (copied from assets/) ├── cli/ │ ├── snotes.py # single-file CLI/TUI (PEP 723: uv run, deps textual+httpx) │ └── install.sh # install template served at /{user}/cli (placeholders substituted) ├── tests/ │ ├── conftest.py # LAN + remote ASGI client fixtures (temp DB + data dir) │ └── test_main.py ├── Dockerfile ├── docker-compose.yml # port 8019, mounts /mnt/aura/snotes ├── pyproject.toml └── .env.example ``` ## API Routes | Method | Path | Purpose | |---|---|---| | `GET` | `/api/me` | Current user + auth mode | | `GET` | `/api/users` | List users | | `POST` | `/api/users/select` | Set user cookie | | `POST` | `/api/auth/login` | Remote password → auth cookie | | `GET/POST` | `/api/notes` | List (current user) / create | | `GET` | `/api/notes/recent` | Recent notes across all users | | `GET/PUT/DELETE` | `/api/notes/{id}` | Read / update / soft-delete | | `GET` | `/api/notes/lookup?title=` | Exact-title match in current user's space | | `GET` | `/api/notes/{id}/history` | Version history | | `GET` | `/api/tags` | Tag frequency | | `GET` | `/api/search?q=` | FTS5 search | | `GET` | `/api/templates` | `$name` templates for current user + common | | `GET` | `/api/templates/{name}` | Raw template body (own space beats common, newest wins) | | `GET` | `/cli/snotes.py` | The CLI file itself (public, for the installer) | | `GET` | `/{username}/cli` | Personalized bash install script (public) | ## Non-obvious Behaviors 1. **Auth model**: the LAN gate (in `auth.is_lan`) matches the client IP against `LAN_CIDRS`. When remote, a signed `snotes_auth` cookie (itsdangerous) is required. User selection is a separate plain `snotes_user` cookie. 2. **Client IP**: read from `X-Forwarded-For` first, else `request.client.host` — set the latter in tests via `ASGITransport(..., client=(ip, port))`. 3. **Markdown round-trip**: the editor works on HTML. On load, `marked` converts markdown → HTML into TipTap; on save, `turndown` converts TipTap HTML → markdown. The stored `.md` file is the source of truth for search/tags. 4. **Storage split**: note *content* lives in `DATA_DIR//.md`; all metadata (title, tags, timestamps, history) lives in SQLite. The filename is a random uuid hex, never the title (titles change freely). 5. **Soft delete**: deletes set `notes.deleted_at`, remove the `.md` file, and write a `delete` history row. 6. **FTS sync**: every save deletes + re-inserts the note's FTS row (title+body); tags are normalized into `tags`/`note_tags` join tables. ## Gotchas - The frontend loads TipTap via `esm.sh` and marked/turndown via jsDelivr — the app needs internet access on first editor load (or vendor these locally later). - `notes.py` opens a new `aiosqlite` connection per operation (same pattern as `tasky`); fine for a small personal corpus, not for hot paths. ## Browser extension (browser-extension/) A Firefox MV3 "quick capture" extension — mirrors the pve-max extension pattern. No backend changes: it reuses `GET /api/users` and `POST/PUT /api/notes`. ``` browser-extension/ manifest.json # MV3, gecko id snotes-capture@schmeeve.com background.js # Relays fetch() to snotes API (credentials:'include') content.js # postMessage ping/pong + settings handshake; get_context msg popup.html/js/css # Capture form: textarea, user select, Save, Page button options.html/js # Server URL config (auto-filled via ping/pong) release-firefox.sh # Package + --sign; copies .xpi to static/ icons/ # 48px + 96px PNGs (from assets/snotes-favicon.png) ``` ### How it works 1. **Content script** runs on all pages. It answers `snotes-ping` → `snotes-pong` and `snotes-settings` → writes `serverUrl` to `browser.storage.sync`, plus a `get_context` message returning the page's selected text, title, and URL. 2. **Popup** prefills the note from the active tab's selection, lists users via `list_users`, and Save creates (`POST`) then updates (`PUT`) via `save_note`. A **Page** button inserts `[title](url)`; the note **autosaves 8s** after the last keystroke. 3. **Helper page** — the SPA's "Capture" view (`showTools()` in `static/app.js`) pings the extension, offers one-click install from `/static/snotes-capture-firefox.xpi`, and sends `snotes-settings` with `location.origin` to auto-configure. ### Install / release ```bash # Manual dev install: about:debugging → Load Temporary Add-on → manifest.json bash browser-extension/release-firefox.sh # package .zip only bash browser-extension/release-firefox.sh --sign # sign via AMO (auto-bumps version) ``` The signed `.xpi` is copied to `static/snotes-capture-firefox.xpi` (git-tracked) so the helper page serves it. Signing needs `AMO_JWT_ISSUER` / `AMO_JWT_SECRET` and `npm install -g web-ext`. ### Gotchas - The extension's `fetch` uses `credentials:'include'`, so on remote hosts it only works if the user has already logged into snotes in that browser (the `snotes_auth` cookie is reused). On LAN no auth is needed. - `dist/` is gitignored; committed artifact lives in `static/` instead. ## CLI (cli/) A single-file Python TUI/CLI installed via `curl -fsSL //cli | bash` (the SPA's "CLI" view shows the exact line for the current user). - **Distribution**: `cli/install.sh` is rendered by `GET /{username}/cli` with `__SNOTES_URL__` / `__SNOTES_USER__` substituted from the request Host header. It downloads `cli/snotes.py` (served at `/cli/snotes.py`) to `~/.local/bin/snotes` and writes `~/.config/snotes/config.json` (0600) with `{url, user}`. - **Runtime**: PEP 723 script (`#!/usr/bin/env -S uv run --script`) — `uv` resolves textual + httpx on first run. Auth = the same cookies as the webapp: `snotes_user` always, `snotes_auth` after `snotes login` (needed only off-LAN). - **Commands**: bare `snotes` (Textual TUI: recent pane, list, `/` search, `n` new, enter edit, `d` delete), `recent` (plain list), `find `, `new`, `templates`, `login`, and `