Files
snotes/AGENTS.md
schmeeve 0758095046 CLI
2026-09-26 22:06:28 -07:00

176 lines
8.5 KiB
Markdown

# 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/<user>/<uuid>.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 <server>/<user>/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 <term>`, `new`,
`templates`, `login`, and `<template> [date]`.
- **Templates**: any note containing `$name` (regex `\$[A-Za-z][A-Za-z0-9_-]*`)
defines a template, tracked in the `note_templates` table (synced on save/delete
like tags; one-time backfill at startup guarded by `PRAGMA user_version`).
`snotes <template> [date]` renders `%date%`/`%time%`/`%datetime%`/`%user%`,
strips standalone `$marker` lines (otherwise rendered notes would redefine the
template), and reopens an existing note with the same rendered title instead of
duplicating. Dates: today (default), yesterday, tomorrow, YYYY-MM-DD, +/-N.
- **Editing** is an `$EDITOR` round-trip: temp `.md` file, save back via PUT.
- **Tests**: `tests/test_cli.py` (pure functions; textual imported lazily inside
`make_app` so the module imports without it) and `tests/test_tui.py` (headless
Textual pilot with a fake Api).
### Gotchas
- `/api/notes/lookup` is declared BEFORE `/api/notes/{note_id}` — FastAPI matches
routes in order, otherwise "lookup" would be treated as a note id.
- `note_templates` ordering uses `updated_at DESC, rowid DESC` — `datetime('now')`
has second precision, so same-second inserts need the rowid tiebreak.