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

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

  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

# 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.