# 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/) ├── 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/{id}/history` | Version history | | `GET` | `/api/tags` | Tag frequency | | `GET` | `/api/search?q=` | FTS5 search | ## 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.