8.5 KiB
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
- Auth model: the LAN gate (in
auth.is_lan) matches the client IP againstLAN_CIDRS. When remote, a signedsnotes_authcookie (itsdangerous) is required. User selection is a separate plainsnotes_usercookie. - Client IP: read from
X-Forwarded-Forfirst, elserequest.client.host— set the latter in tests viaASGITransport(..., client=(ip, port)). - Markdown round-trip: the editor works on HTML. On load,
markedconverts markdown → HTML into TipTap; on save,turndownconverts TipTap HTML → markdown. The stored.mdfile is the source of truth for search/tags. - 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). - Soft delete: deletes set
notes.deleted_at, remove the.mdfile, and write adeletehistory row. - FTS sync: every save deletes + re-inserts the note's FTS row (title+body);
tags are normalized into
tags/note_tagsjoin tables.
Gotchas
- The frontend loads TipTap via
esm.shand marked/turndown via jsDelivr — the app needs internet access on first editor load (or vendor these locally later). notes.pyopens a newaiosqliteconnection per operation (same pattern astasky); 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
- Content script runs on all pages. It answers
snotes-ping→snotes-pongandsnotes-settings→ writesserverUrltobrowser.storage.sync, plus aget_contextmessage returning the page's selected text, title, and URL. - Popup prefills the note from the active tab's selection, lists users via
list_users, and Save creates (POST) then updates (PUT) viasave_note. A Page button inserts[title](url); the note autosaves 8s after the last keystroke. - Helper page — the SPA's "Capture" view (
showTools()instatic/app.js) pings the extension, offers one-click install from/static/snotes-capture-firefox.xpi, and sendssnotes-settingswithlocation.originto auto-configure.
Install / release
# 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
fetchusescredentials:'include', so on remote hosts it only works if the user has already logged into snotes in that browser (thesnotes_authcookie is reused). On LAN no auth is needed. dist/is gitignored; committed artifact lives instatic/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.shis rendered byGET /{username}/cliwith__SNOTES_URL__/__SNOTES_USER__substituted from the request Host header. It downloadscli/snotes.py(served at/cli/snotes.py) to~/.local/bin/snotesand writes~/.config/snotes/config.json(0600) with{url, user}. - Runtime: PEP 723 script (
#!/usr/bin/env -S uv run --script) —uvresolves textual + httpx on first run. Auth = the same cookies as the webapp:snotes_useralways,snotes_authaftersnotes login(needed only off-LAN). - Commands: bare
snotes(Textual TUI: recent pane, list,/search,nnew, enter edit,ddelete),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 thenote_templatestable (synced on save/delete like tags; one-time backfill at startup guarded byPRAGMA user_version).snotes <template> [date]renders%date%/%time%/%datetime%/%user%, strips standalone$markerlines (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
$EDITORround-trip: temp.mdfile, save back via PUT. - Tests:
tests/test_cli.py(pure functions; textual imported lazily insidemake_appso the module imports without it) andtests/test_tui.py(headless Textual pilot with a fake Api).
Gotchas
/api/notes/lookupis declared BEFORE/api/notes/{note_id}— FastAPI matches routes in order, otherwise "lookup" would be treated as a note id.note_templatesordering usesupdated_at DESC, rowid DESC—datetime('now')has second precision, so same-second inserts need the rowid tiebreak.