This commit is contained in:
schmeeve
2026-09-26 22:06:28 -07:00
parent bc365267d0
commit 0758095046
14 changed files with 1392 additions and 0 deletions

View File

@@ -34,6 +34,9 @@ snotes/
│ ├── app.js # SPA client + TipTap editor (esm.sh CDN) │ ├── app.js # SPA client + TipTap editor (esm.sh CDN)
│ ├── app.css # Hyper-inspired stylesheet │ ├── app.css # Hyper-inspired stylesheet
│ └── snotes-main.png # logo + favicon (copied from assets/) │ └── 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/ ├── tests/
│ ├── conftest.py # LAN + remote ASGI client fixtures (temp DB + data dir) │ ├── conftest.py # LAN + remote ASGI client fixtures (temp DB + data dir)
│ └── test_main.py │ └── test_main.py
@@ -54,9 +57,14 @@ snotes/
| `GET/POST` | `/api/notes` | List (current user) / create | | `GET/POST` | `/api/notes` | List (current user) / create |
| `GET` | `/api/notes/recent` | Recent notes across all users | | `GET` | `/api/notes/recent` | Recent notes across all users |
| `GET/PUT/DELETE` | `/api/notes/{id}` | Read / update / soft-delete | | `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/notes/{id}/history` | Version history |
| `GET` | `/api/tags` | Tag frequency | | `GET` | `/api/tags` | Tag frequency |
| `GET` | `/api/search?q=` | FTS5 search | | `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 ## Non-obvious Behaviors
@@ -131,3 +139,37 @@ and `npm install -g web-ext`.
only works if the user has already logged into snotes in that browser (the 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. `snotes_auth` cookie is reused). On LAN no auth is needed.
- `dist/` is gitignored; committed artifact lives in `static/` instead. - `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.

View File

@@ -44,5 +44,6 @@ snotes TUI app
- Template will have variable replacements, so if the first like is something like # Journal for %date%, the date [date] is inserted - Template will have variable replacements, so if the first like is something like # Journal for %date%, the date [date] is inserted
- snotes recent shows a list of notes along with search - snotes recent shows a list of notes along with search
- snotes find (term) opens the search interface - snotes find (term) opens the search interface
- other TUI apps for ref: ~/git/mounter ~/git/setmeup ~/git/m

View File

@@ -38,6 +38,13 @@ CREATE TABLE IF NOT EXISTS note_tags (
FOREIGN KEY(tag_id) REFERENCES tags(id) FOREIGN KEY(tag_id) REFERENCES tags(id)
); );
CREATE TABLE IF NOT EXISTS note_templates (
note_id TEXT NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (note_id, name),
FOREIGN KEY(note_id) REFERENCES notes(id)
);
CREATE TABLE IF NOT EXISTS history ( CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id TEXT NOT NULL, note_id TEXT NOT NULL,

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
import structlog import structlog
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
@@ -17,6 +18,7 @@ logger = structlog.get_logger()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await database.init_db() await database.init_db()
await notes.backfill_templates()
yield yield
@@ -145,6 +147,14 @@ async def recent(request: Request, _: None = Depends(require_access)):
return await notes.list_recent(current_user(request)) return await notes.list_recent(current_user(request))
@app.get("/api/notes/lookup")
async def lookup_note(request: Request, title: str, _: None = Depends(require_access)):
note = await notes.lookup_by_title(current_user(request), title)
if note is None:
raise HTTPException(status_code=404, detail="note not found")
return note
@app.get("/api/notes/{note_id}") @app.get("/api/notes/{note_id}")
async def get_note(note_id: str, _: None = Depends(require_access)): async def get_note(note_id: str, _: None = Depends(require_access)):
note = await notes.get_note(note_id) note = await notes.get_note(note_id)
@@ -234,6 +244,24 @@ async def search(q: str, _: None = Depends(require_access)):
return await notes.search_notes(q) return await notes.search_notes(q)
# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------
@app.get("/api/templates")
async def list_templates(request: Request, _: None = Depends(require_access)):
return await notes.list_templates(current_user(request))
@app.get("/api/templates/{name}")
async def get_template(name: str, request: Request, _: None = Depends(require_access)):
template = await notes.get_template(current_user(request), name)
if template is None:
raise HTTPException(status_code=404, detail="template not found")
return template
@app.get("/api/homepage") @app.get("/api/homepage")
async def homepage_stats(_: None = Depends(require_access)): async def homepage_stats(_: None = Depends(require_access)):
return await notes.get_homepage_stats() return await notes.get_homepage_stats()
@@ -247,5 +275,27 @@ async def extension_xpi() -> FileResponse:
) )
# ---------------------------------------------------------------------------
# CLI distribution
# ---------------------------------------------------------------------------
@app.get("/cli/snotes.py", include_in_schema=False)
async def cli_script() -> FileResponse:
return FileResponse("cli/snotes.py", media_type="text/x-python")
@app.get("/{username}/cli", include_in_schema=False)
async def cli_install(username: str, request: Request) -> Response:
if username not in settings.user_list and username != settings.common_space:
raise HTTPException(status_code=404, detail="unknown user")
script = Path("cli/install.sh").read_text(encoding="utf-8")
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("x-forwarded-host", request.headers.get("host", "localhost"))
url = f"{scheme}://{host}"
body = script.replace("__SNOTES_URL__", url).replace("__SNOTES_USER__", username)
return Response(content=body, media_type="text/plain")
# Mount static assets last so /api routes take precedence # Mount static assets last so /api routes take precedence
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")

View File

@@ -4,12 +4,16 @@ import re
import uuid import uuid
import aiosqlite import aiosqlite
import structlog
from backend import actions from backend import actions
from backend.config import settings from backend.config import settings
from backend.database import get_user_id from backend.database import get_user_id
logger = structlog.get_logger()
TAG_RE = re.compile(r"#[A-Za-z0-9_-]+") TAG_RE = re.compile(r"#[A-Za-z0-9_-]+")
TEMPLATE_RE = re.compile(r"\$[A-Za-z][A-Za-z0-9_-]*")
ATTACH_RE = re.compile(r"/api/attachments/([^/\s]+)/([A-Za-z0-9._-]+)") ATTACH_RE = re.compile(r"/api/attachments/([^/\s]+)/([A-Za-z0-9._-]+)")
IMAGE_EXT_RE = re.compile(r"\.(png|jpe?g|gif|webp|svg|avif|bmp|ico|heic)$", re.IGNORECASE) IMAGE_EXT_RE = re.compile(r"\.(png|jpe?g|gif|webp|svg|avif|bmp|ico|heic)$", re.IGNORECASE)
@@ -24,6 +28,16 @@ def extract_tags(text: str) -> list[str]:
return seen return seen
def extract_templates(text: str) -> list[str]:
"""Notes containing `$name` define a template named `name` (like tags)."""
seen: list[str] = []
for match in TEMPLATE_RE.finditer(text):
name = match.group(0)[1:].lower()
if name not in seen:
seen.append(name)
return seen
def extract_title(text: str) -> str: def extract_title(text: str) -> str:
for line in text.splitlines(): for line in text.splitlines():
stripped = line.strip() stripped = line.strip()
@@ -146,6 +160,7 @@ async def create_note(
(note_id, user_id, title, rel), (note_id, user_id, title, rel),
) )
await _set_tags(db, note_id, tags) await _set_tags(db, note_id, tags)
await _set_templates(db, note_id, extract_templates(body))
await _index_note(db, note_id, user_id, title, body) await _index_note(db, note_id, user_id, title, body)
await _record_history(db, note_id, user_id, "create", ip, ua) await _record_history(db, note_id, user_id, "create", ip, ua)
await db.commit() await db.commit()
@@ -207,6 +222,7 @@ async def update_note(
) )
await _set_tags(db, note_id, tags) await _set_tags(db, note_id, tags)
await _set_templates(db, note_id, extract_templates(body))
await _index_note(db, note_id, user_id, title, body) await _index_note(db, note_id, user_id, title, body)
await _record_history(db, note_id, user_id, "update", ip, ua) await _record_history(db, note_id, user_id, "update", ip, ua)
await db.commit() await db.commit()
@@ -253,6 +269,7 @@ async def delete_note(
"UPDATE notes SET deleted_at = datetime('now') WHERE id = ?", "UPDATE notes SET deleted_at = datetime('now') WHERE id = ?",
(note_id,), (note_id,),
) )
await db.execute("DELETE FROM note_templates WHERE note_id = ?", (note_id,))
await _record_history(db, note_id, row["user_id"], "delete", ip, ua) await _record_history(db, note_id, row["user_id"], "delete", ip, ua)
await db.commit() await db.commit()
return True return True
@@ -367,6 +384,120 @@ async def search_notes(query: str, limit: int = 50) -> list[dict]:
return notes return notes
# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------
def _template_spaces(username: str) -> list[str]:
if username == settings.common_space:
return [settings.common_space]
return [username, settings.common_space]
def _pick_template(rows: list[dict], username: str) -> dict | None:
"""Newest row wins, but the user's own space has priority over common."""
own = [r for r in rows if r["username"] == username]
return own[0] if own else (rows[0] if rows else None)
async def list_templates(username: str) -> list[dict]:
names = _template_spaces(username)
placeholders = ",".join("?" for _ in names)
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT nt.name, n.id, n.title, n.updated_at, u.name AS username"
" FROM note_templates nt"
" JOIN notes n ON n.id = nt.note_id AND n.deleted_at IS NULL"
" JOIN users u ON u.id = n.user_id"
f" WHERE u.name IN ({placeholders})"
" ORDER BY n.updated_at DESC, n.rowid DESC",
names,
)
by_name: dict[str, list[dict]] = {}
for r in await rows.fetchall():
by_name.setdefault(r["name"], []).append(dict(r))
out = []
for name, candidates in by_name.items():
best = _pick_template(candidates, username)
out.append(
{
"name": name,
"id": best["id"],
"title": best["title"],
"updated_at": best["updated_at"],
"username": best["username"],
}
)
return sorted(out, key=lambda t: t["name"])
async def get_template(username: str, name: str) -> dict | None:
name = name.lower().lstrip("$")
names = _template_spaces(username)
placeholders = ",".join("?" for _ in names)
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT n.id, n.title, n.path, n.updated_at, u.name AS username"
" FROM note_templates nt"
" JOIN notes n ON n.id = nt.note_id AND n.deleted_at IS NULL"
" JOIN users u ON u.id = n.user_id"
f" WHERE nt.name = ? AND u.name IN ({placeholders})"
" ORDER BY n.updated_at DESC, n.rowid DESC",
[name, *names],
)
best = _pick_template([dict(r) for r in await rows.fetchall()], username)
if best is None:
return None
best["name"] = name
best["body"] = await read_note_file(best.pop("path"))
return best
async def lookup_by_title(username: str, title: str) -> dict | None:
"""Find a note by exact title in the user's own space (for idempotent opens)."""
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT n.id FROM notes n JOIN users u ON u.id = n.user_id"
" WHERE n.deleted_at IS NULL AND u.name = ? AND n.title = ?"
" ORDER BY n.updated_at DESC LIMIT 1",
(username, title),
)
row = await rows.fetchone()
if row is None:
return None
return await get_note(row["id"])
async def backfill_templates() -> None:
"""One-time scan of existing notes to populate note_templates.
Guarded by PRAGMA user_version (0 → 1) so it runs once on upgraded
databases and is instant on fresh ones.
"""
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
row = await db.execute("PRAGMA user_version")
if (await row.fetchone())[0] >= 1:
return
rows = await db.execute("SELECT id, path FROM notes WHERE deleted_at IS NULL")
count = 0
for r in await rows.fetchall():
try:
body = await read_note_file(r["path"])
except FileNotFoundError:
continue
await _set_templates(db, r["id"], extract_templates(body))
count += 1
await db.execute("PRAGMA user_version = 1")
await db.commit()
if count:
logger.info("templates_backfilled", notes=count)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -384,6 +515,15 @@ async def _set_tags(db: aiosqlite.Connection, note_id: str, tags: list[str]) ->
) )
async def _set_templates(db: aiosqlite.Connection, note_id: str, names: list[str]) -> None:
await db.execute("DELETE FROM note_templates WHERE note_id = ?", (note_id,))
for name in names:
await db.execute(
"INSERT OR IGNORE INTO note_templates (note_id, name) VALUES (?, ?)",
(note_id, name),
)
async def _index_note( async def _index_note(
db: aiosqlite.Connection, db: aiosqlite.Connection,
note_id: str, note_id: str,

37
cli/install.sh Normal file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# snotes CLI installer — rendered by the server for user "__SNOTES_USER__".
# Usage: curl -fsSL __SNOTES_URL__/__SNOTES_USER__/cli | bash
set -euo pipefail
SNOTES_URL="__SNOTES_URL__"
SNOTES_USER="__SNOTES_USER__"
BIN_DIR="$HOME/.local/bin"
CFG_DIR="$HOME/.config/snotes"
if ! command -v uv >/dev/null 2>&1; then
echo "snotes needs 'uv' to run (https://docs.astral.sh/uv/)." >&2
echo "Install it first: curl -LsSf https://astral.sh/uv/install.sh | sh" >&2
exit 1
fi
mkdir -p "$BIN_DIR" "$CFG_DIR"
echo "Downloading snotes CLI from $SNOTES_URL ..."
curl -fsSL "$SNOTES_URL/cli/snotes.py" -o "$BIN_DIR/snotes"
chmod +x "$BIN_DIR/snotes"
umask 077
cat > "$CFG_DIR/config.json" <<EOF
{
"url": "$SNOTES_URL",
"user": "$SNOTES_USER"
}
EOF
echo "✓ installed to $BIN_DIR/snotes"
echo "✓ config written to $CFG_DIR/config.json (user: $SNOTES_USER)"
case ":$PATH:" in
*":$BIN_DIR:"*) ;;
*) echo "note: $BIN_DIR is not on your PATH — add it, or run: $BIN_DIR/snotes" ;;
esac
echo "Try it: snotes --help"

568
cli/snotes.py Normal file
View File

@@ -0,0 +1,568 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["httpx>=0.27.0", "textual>=1.0.0"]
# ///
"""snotes CLI — TUI + template launcher for the snotes notes app.
Install: curl -fsSL <server>/<user>/cli | bash
Config: ~/.config/snotes/config.json
"""
from __future__ import annotations
import json
import os
import re
import shlex
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from pathlib import Path
import httpx
USAGE = """\
snotes — command-line client for the snotes notes app
usage:
snotes open the TUI (recent notes, list, search)
snotes recent [-n N] print recent notes (default 20)
snotes find <term> open the TUI with a search for <term>
snotes new create a note in $EDITOR
snotes templates list templates (a note containing $name defines one)
snotes <template> [date] open/create a note from a template
date: today (default), yesterday, tomorrow,
YYYY-MM-DD, or +/-N (days from today)
snotes login authenticate for remote (non-LAN) access
snotes help this text
template variables: %date% %time% %datetime% %user%
config: ~/.config/snotes/config.json
"""
CONFIG_PATH = Path(
os.environ.get("SNOTES_CONFIG", Path.home() / ".config" / "snotes" / "config.json")
)
class CliError(Exception):
"""Fatal user-facing error."""
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
@dataclass
class Config:
url: str
user: str
token: str | None = None
def load_config() -> Config:
try:
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
return Config(url=data["url"].rstrip("/"), user=data["user"], token=data.get("token"))
except FileNotFoundError:
raise CliError(
f"no config at {CONFIG_PATH}\ninstall with: curl -fsSL <server>/<user>/cli | bash"
) from None
except (json.JSONDecodeError, KeyError) as exc:
raise CliError(f"bad config at {CONFIG_PATH}: {exc}") from None
def save_config(cfg: Config) -> None:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = CONFIG_PATH.with_suffix(".tmp")
tmp.write_text(
json.dumps({"url": cfg.url, "user": cfg.user, "token": cfg.token}, indent=2) + "\n",
encoding="utf-8",
)
tmp.chmod(0o600)
tmp.replace(CONFIG_PATH)
# ---------------------------------------------------------------------------
# API client
# ---------------------------------------------------------------------------
class Api:
def __init__(self, cfg: Config):
self.cfg = cfg
self.client = httpx.Client(base_url=cfg.url, timeout=15.0)
self.client.cookies.set("snotes_user", cfg.user)
if cfg.token:
self.client.cookies.set("snotes_auth", cfg.token)
def _check(self, resp: httpx.Response) -> httpx.Response:
if resp.status_code == 401:
raise CliError("authentication required — run `snotes login`")
if resp.status_code == 404:
raise CliError("not found")
if not resp.is_success:
detail = resp.json().get("detail", resp.text) if resp.content else resp.reason_phrase
raise CliError(f"server error {resp.status_code}: {detail}")
return resp
def login(self, password: str) -> str:
resp = self.client.post("/api/auth/login", json={"password": password})
if resp.status_code == 401:
raise CliError("invalid password")
resp.raise_for_status()
token = self.client.cookies.get("snotes_auth")
if not token:
raise CliError("server did not return an auth cookie")
return token
def recent(self, limit: int = 30) -> list[dict]:
return self._check(self.client.get("/api/notes/recent")).json()[:limit]
def search(self, q: str) -> list[dict]:
return self._check(self.client.get("/api/search", params={"q": q})).json()
def get_note(self, note_id: str) -> dict:
return self._check(self.client.get(f"/api/notes/{note_id}")).json()
def create(self, body: str) -> dict:
return self._check(self.client.post("/api/notes", json={"body": body})).json()
def update(self, note_id: str, body: str) -> dict:
return self._check(self.client.put(f"/api/notes/{note_id}", json={"body": body})).json()
def delete(self, note_id: str) -> None:
self._check(self.client.delete(f"/api/notes/{note_id}"))
def templates(self) -> list[dict]:
return self._check(self.client.get("/api/templates")).json()
def template(self, name: str) -> dict | None:
resp = self.client.get(f"/api/templates/{name}")
if resp.status_code == 404:
return None
return self._check(resp).json()
def lookup(self, title: str) -> dict | None:
resp = self.client.get("/api/notes/lookup", params={"title": title})
if resp.status_code == 404:
return None
return self._check(resp).json()
# ---------------------------------------------------------------------------
# Dates / template rendering (pure, testable)
# ---------------------------------------------------------------------------
MARKER_LINE_RE = re.compile(r"^\s*\$[A-Za-z][A-Za-z0-9_-]*\s*$")
def parse_date(s: str | None) -> date:
today = date.today()
if not s or s == "today":
return today
if s == "yesterday":
return today - timedelta(days=1)
if s == "tomorrow":
return today + timedelta(days=1)
if re.fullmatch(r"[+-]?\d+", s):
return today + timedelta(days=int(s))
try:
return date.fromisoformat(s)
except ValueError:
raise CliError(
f"bad date {s!r} — use today, yesterday, tomorrow, YYYY-MM-DD, or +/-N"
) from None
def render_template(body: str, d: date, user: str, now: datetime | None = None) -> str:
"""Substitute variables and strip `$marker` template-definition lines."""
now = now or datetime.now()
out = body.replace("%datetime%", f"{d.isoformat()} {now:%H:%M}")
out = out.replace("%date%", d.isoformat())
out = out.replace("%time%", f"{now:%H:%M}")
out = out.replace("%user%", user)
lines = [ln for ln in out.splitlines() if not MARKER_LINE_RE.match(ln)]
text = "\n".join(lines).strip("\n")
return text + "\n" if text else ""
def extract_title(text: str) -> str:
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
title = stripped[2:].strip() if stripped.startswith("# ") else stripped
if title:
return title[:200]
return "Untitled"
def tags_str(note: dict) -> str:
tags = note.get("tags") or []
return " ".join(f"#{t}" for t in tags)
# ---------------------------------------------------------------------------
# $EDITOR round-trip
# ---------------------------------------------------------------------------
def edit_roundtrip(initial: str) -> str | None:
"""Open `initial` in $EDITOR; return the edited text, or None on failure/abort."""
editor = shlex.split(os.environ.get("EDITOR", "vim"))
fd, path = tempfile.mkstemp(prefix="snotes-", suffix=".md")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(initial)
rc = subprocess.call([*editor, path])
if rc != 0:
return None
return Path(path).read_text(encoding="utf-8")
finally:
Path(path).unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_recent(api: Api, limit: int) -> int:
for n in api.recent(limit):
updated = (n.get("updated_at") or "")[:16]
tags = tags_str(n)
line = f"{updated:16} {n['title']}"
if tags:
line += f" {tags}"
print(line)
return 0
def cmd_templates(api: Api) -> int:
rows = api.templates()
if not rows:
print("no templates — add e.g. $journal to a note to define one")
return 0
for t in rows:
print(f"${t['name']:20} {t['title']} ({t['username']})")
return 0
def cmd_new(api: Api) -> int:
text = edit_roundtrip("")
if text and text.strip():
note = api.create(text)
print(f"created: {note['title']}")
else:
print("aborted — empty note")
return 0
def cmd_template(api: Api, cfg: Config, name: str, datestr: str | None) -> int:
d = parse_date(datestr)
tpl = api.template(name)
if tpl is None:
known = ", ".join(f"${t['name']}" for t in api.templates()) or "(none)"
raise CliError(f"no template ${name!s} — known: {known}")
body = render_template(tpl["body"], d, cfg.user)
existing = api.lookup(extract_title(body))
if existing:
edited = edit_roundtrip(existing["body"])
if edited is not None and edited != existing["body"] and edited.strip():
api.update(existing["id"], edited)
print(f"updated: {extract_title(edited)}")
else:
print("no changes")
else:
edited = edit_roundtrip(body)
if edited and edited.strip():
note = api.create(edited)
print(f"created: {note['title']}")
else:
print("aborted — empty note")
return 0
def cmd_login(api: Api, cfg: Config) -> int:
import getpass
cfg.token = api.login(getpass.getpass("snotes password: "))
save_config(cfg)
print(f"✓ logged in — token saved to {CONFIG_PATH}")
return 0
# ---------------------------------------------------------------------------
# TUI
# ---------------------------------------------------------------------------
def make_app(api: Api, initial_query: str | None):
"""Build the Textual app (imports deferred so the CLI module stays importable
without textual installed)."""
import asyncio
from rich.text import Text
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Footer, Input, Label, ListItem, ListView, Markdown
class Confirm(ModalScreen[bool]):
BINDINGS = [
Binding("y", "confirm", "Yes"),
Binding("n", "cancel", "No"),
Binding("escape", "cancel"),
]
def __init__(self, prompt: str):
super().__init__()
self._prompt = prompt
def compose(self) -> ComposeResult:
yield Vertical(
Label(self._prompt),
Label("[y] yes [n/esc] no", id="confirm-hint"),
id="confirm-box",
)
def action_confirm(self) -> None:
self.dismiss(True)
def action_cancel(self) -> None:
self.dismiss(False)
class NotesApp(App):
CSS = """
Screen { background: #1a1b26; }
#search { margin: 1 2 0 2; border: solid #3b4261; }
#search:focus { border: solid #7aa2f7; }
#main { height: 1fr; }
#notes { width: 42; border: solid #3b4261; margin: 1 0 1 2; }
#notes:focus { border: solid #7aa2f7; }
#preview { border: solid #3b4261; margin: 1 2 1 1; padding: 0 2;
background: #24283b; }
ListItem { color: #c0caf5; }
ListView { background: #1f2335; }
Footer { background: #1f2335; color: #565f89; }
#confirm-box { width: 50; height: auto; border: solid #f7768e;
background: #24283b; padding: 1 2; margin: 10 0 0 4; }
#confirm-hint { color: #565f89; margin-top: 1; }
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("n", "new_note", "New"),
Binding("d", "delete_note", "Delete"),
Binding("r", "refresh", "Refresh"),
Binding("slash", "focus_search", "Search"),
]
def __init__(self) -> None:
super().__init__()
self._cache: dict[str, str] = {}
self._gen = 0
def compose(self) -> ComposeResult:
yield Input(placeholder="Search notes… (#tag works too)", id="search")
with Horizontal(id="main"):
yield ListView(id="notes")
yield Markdown("", id="preview")
yield Footer()
async def on_mount(self) -> None:
self.title = "snotes"
self.sub_title = f"{api.cfg.user} @ {api.cfg.url}"
if initial_query:
self.query_one("#search", Input).value = initial_query
self.query_one("#search", Input).focus()
else:
await self.reload()
self.query_one("#notes", ListView).focus()
# -- data ----------------------------------------------------------
async def reload(self) -> None:
q = self.query_one("#search", Input).value.strip()
try:
if q:
rows = await asyncio.to_thread(api.search, q)
else:
rows = await asyncio.to_thread(api.recent, 30)
except CliError as exc:
self.notify(str(exc), severity="error")
return
lv = self.query_one("#notes", ListView)
await lv.clear()
for n in rows:
label = n["title"] or "Untitled"
tags = tags_str(n)
text = Text(f"{label}\n", overflow="ellipsis")
text.append(
f"{(n.get('updated_at') or '')[:10]}" + (f" {tags}" if tags else ""),
style="dim",
)
item = ListItem(Label(text))
item.note_id = n["id"]
await lv.append(item)
self.query_one("#preview", Markdown).update("")
if lv.children:
lv.index = 0
# -- events --------------------------------------------------------
async def on_input_changed(self, event: Input.Changed) -> None:
self._gen += 1
gen = self._gen
await asyncio.sleep(0.25)
if gen == self._gen:
await self.reload()
async def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
item = event.item
note_id = getattr(item, "note_id", None)
if not note_id:
return
body = self._cache.get(note_id)
if body is None:
try:
note = await asyncio.to_thread(api.get_note, note_id)
except CliError:
return
body = note["body"]
self._cache[note_id] = body
self.query_one("#preview", Markdown).update(body)
async def on_list_view_selected(self, event: ListView.Selected) -> None:
note_id = getattr(event.item, "note_id", None)
if not note_id:
return
try:
note = await asyncio.to_thread(api.get_note, note_id)
except CliError as exc:
self.notify(str(exc), severity="error")
return
with self.suspend():
edited = edit_roundtrip(note["body"])
if edited is not None and edited != note["body"] and edited.strip():
try:
await asyncio.to_thread(api.update, note_id, edited)
except CliError as exc:
self.notify(str(exc), severity="error")
self._cache.pop(note_id, None)
await self.reload()
# -- actions -------------------------------------------------------
async def action_new_note(self) -> None:
with self.suspend():
text = edit_roundtrip("")
if text and text.strip():
try:
await asyncio.to_thread(api.create, text)
except CliError as exc:
self.notify(str(exc), severity="error")
await self.reload()
async def action_refresh(self) -> None:
self._cache.clear()
await self.reload()
def action_focus_search(self) -> None:
self.query_one("#search", Input).focus()
def action_delete_note(self) -> None:
lv = self.query_one("#notes", ListView)
item = lv.highlighted_child
note_id = getattr(item, "note_id", None)
if not note_id:
return
self.push_screen(
Confirm("Delete this note?"),
lambda ok: self._delete(ok, note_id),
)
@work
async def _delete(self, ok: bool, note_id: str) -> None:
if not ok:
return
try:
await asyncio.to_thread(api.delete, note_id)
except CliError as exc:
self.notify(str(exc), severity="error")
return
self._cache.pop(note_id, None)
await self.reload()
return NotesApp()
def cmd_tui(api: Api, initial_query: str | None) -> int:
make_app(api, initial_query).run()
return 0
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main(argv: list[str]) -> int:
if argv and argv[0] in ("-h", "--help", "help"):
print(USAGE)
return 0
cfg = load_config()
api = Api(cfg)
if not argv:
return cmd_tui(api, None)
cmd, rest = argv[0], argv[1:]
if cmd == "tui":
return cmd_tui(api, None)
if cmd == "recent":
limit = 20
if rest:
if len(rest) == 2 and rest[0] == "-n" and rest[1].isdigit():
limit = int(rest[1])
else:
raise CliError("usage: snotes recent [-n N]")
return cmd_recent(api, limit)
if cmd == "find":
term = " ".join(rest).strip()
if not term:
raise CliError("usage: snotes find <term>")
return cmd_tui(api, term)
if cmd == "new":
return cmd_new(api)
if cmd == "templates":
return cmd_templates(api)
if cmd == "login":
return cmd_login(api, cfg)
if cmd.startswith("-"):
raise CliError(f"unknown option {cmd!r}\n\n{USAGE}")
if len(rest) > 1:
raise CliError(f"usage: snotes {cmd} [date]")
return cmd_template(api, cfg, cmd, rest[0] if rest else None)
if __name__ == "__main__":
try:
sys.exit(main(sys.argv[1:]))
except CliError as exc:
print(f"snotes: {exc}", file=sys.stderr)
sys.exit(1)
except httpx.ConnectError:
print(f"snotes: cannot reach the server — check the url in {CONFIG_PATH}", file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
sys.exit(130)

View File

@@ -28,6 +28,7 @@ dev = [
"pytest-asyncio>=0.23.0", "pytest-asyncio>=0.23.0",
"httpx>=0.27.0", "httpx>=0.27.0",
"ruff>=0.4.0", "ruff>=0.4.0",
"textual>=1.0.0",
] ]
[build-system] [build-system]

View File

@@ -449,6 +449,9 @@ body {
.tools-row { display: flex; align-items: center; gap: 12px; margin-top: 8px; flex-wrap: wrap; } .tools-row { display: flex; align-items: center; gap: 12px; margin-top: 8px; flex-wrap: wrap; }
.tools-row .muted { min-height: 1.2em; } .tools-row .muted { min-height: 1.2em; }
.cli-install-row { align-items: stretch; }
.cli-install-cmd { display: inline-block; padding: 8px 12px; border-radius: 6px; background: var(--bg); border: 1px solid var(--border); font-size: 14px; user-select: all; overflow-x: auto; max-width: 100%; }
.tools-head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } .tools-head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.tools-head h1 { margin: 0; } .tools-head h1 { margin: 0; }
.tools-logo { height: 40px; width: auto; } .tools-logo { height: 40px; width: auto; }

View File

@@ -194,6 +194,7 @@ function header() {
el("button", { class: "btn primary", text: "+ New note", onclick: () => openNote(null) }), el("button", { class: "btn primary", text: "+ New note", onclick: () => openNote(null) }),
el("button", { class: "btn ghost", text: "Help", onclick: showHelp }), el("button", { class: "btn ghost", text: "Help", onclick: showHelp }),
el("button", { class: "btn ghost", text: "QUICKsnote", onclick: showTools }), el("button", { class: "btn ghost", text: "QUICKsnote", onclick: showTools }),
el("button", { class: "btn ghost", text: "CLI", onclick: showCli }),
themeSelector(), themeSelector(),
]); ]);
const burger = el("button", { class: "menu-toggle", title: "Menu", "aria-label": "Menu", onclick: () => menu.classList.toggle("open") }, [ const burger = el("button", { class: "menu-toggle", title: "Menu", "aria-label": "Menu", onclick: () => menu.classList.toggle("open") }, [
@@ -252,6 +253,53 @@ function helpRow(keys, action) {
return el("tr", {}, [el("td", { html: "<code>" + keys + "</code>" }), el("td", { text: action })]); return el("tr", {}, [el("td", { html: "<code>" + keys + "</code>" }), el("td", { text: action })]);
} }
function showCli() {
state.view = "cli";
root.innerHTML = "";
state.editor?.destroy(); state.editor = null;
const back = backButton(showHome);
const installCmd = "curl -fsSL " + window.location.origin + "/" + state.user + "/cli | bash";
const cmdEl = el("code", { class: "cli-install-cmd", text: installCmd });
const copyBtn = el("button", {
class: "btn", text: "Copy",
onclick: () => { navigator.clipboard?.writeText(installCmd); copyBtn.textContent = "Copied!"; setTimeout(() => { copyBtn.textContent = "Copy"; }, 2000); },
});
const card = el("div", { class: "help-card" }, [
el("div", { class: "tools-head" }, [
el("img", { src: "/static/snotes-logo.png", class: "tools-logo", alt: "snotes CLI" }),
el("h1", { text: "snotes CLI" }),
]),
el("p", { html: "A terminal client for snotes: TUI browser, quick capture, and <b>templates</b>." }),
el("p", { html: "Requires <a href=\"https://docs.astral.sh/uv/\" target=\"_blank\">uv</a>. Install or update for user <b>" + escapeHtml(state.user) + "</b> with:" }),
el("div", { class: "tools-row cli-install-row" }, [cmdEl, copyBtn]),
el("p", { class: "muted", text: "The script downloads the CLI to ~/.local/bin/snotes and saves this server's URL and your userspace to ~/.config/snotes/config.json." }),
el("h2", { text: "Commands" }),
el("table", { class: "help-table" }, [
el("tr", {}, [el("th", { text: "Command" }), el("th", { text: "Action" })]),
helpRow("snotes", "Open the TUI: recent notes, list, search (n = new, enter = edit, d = delete)"),
helpRow("snotes recent [-n N]", "Print recent notes"),
helpRow("snotes find <term>", "Open the TUI with a search for <term>"),
helpRow("snotes new", "Create a note in $EDITOR"),
helpRow("snotes <template> [date]", "Open/create a note from a template (today, yesterday, YYYY-MM-DD, +/-N)"),
helpRow("snotes templates", "List available templates"),
helpRow("snotes login", "Authenticate for remote (non-LAN) access"),
]),
el("h2", { text: "Templates" }),
el("p", { html: "Any note containing <code>$name</code> defines a template — e.g. put <code>$journal</code> in a note titled <code># Journal for %date%</code> and <code>snotes journal</code> opens today's journal (existing entries are reopened, not duplicated)." }),
el("p", { html: "Template variables: <code>%date%</code>, <code>%time%</code>, <code>%datetime%</code>, <code>%user%</code>." }),
]);
const wrap = el("div", { class: "editor-wrap" }, [
el("div", { class: "editor-topbar" }, [back]),
card,
]);
root.appendChild(wrap);
}
function showTools() { function showTools() {
state.view = "tools"; state.view = "tools";
root.innerHTML = ""; root.innerHTML = "";

131
tests/test_cli.py Normal file
View File

@@ -0,0 +1,131 @@
from __future__ import annotations
import importlib.util
import sys
from datetime import date, datetime, timedelta
from pathlib import Path
import pytest
def _load_cli():
path = Path(__file__).parent.parent / "cli" / "snotes.py"
spec = importlib.util.spec_from_file_location("snotes_cli", path)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod
cli = _load_cli()
# -- parse_date --------------------------------------------------------------
def test_parse_date_defaults_to_today():
assert cli.parse_date(None) == date.today()
assert cli.parse_date("today") == date.today()
def test_parse_date_relative_words():
assert cli.parse_date("yesterday") == date.today() - timedelta(days=1)
assert cli.parse_date("tomorrow") == date.today() + timedelta(days=1)
def test_parse_date_offsets():
assert cli.parse_date("+3") == date.today() + timedelta(days=3)
assert cli.parse_date("-7") == date.today() - timedelta(days=7)
assert cli.parse_date("0") == date.today()
def test_parse_date_iso():
assert cli.parse_date("2026-09-26") == date(2026, 9, 26)
def test_parse_date_invalid():
with pytest.raises(cli.CliError, match="bad date"):
cli.parse_date("next friday-ish")
# -- render_template ---------------------------------------------------------
def test_render_template_date():
body = "# Journal for %date%\n$journal\n\n- \n"
out = cli.render_template(body, date(2026, 9, 26), "schmeeve")
assert out.startswith("# Journal for 2026-09-26\n")
def test_render_template_strips_marker_lines():
body = "# T\n$journal\nmiddle\n $other \nend\n"
out = cli.render_template(body, date(2026, 1, 1), "u")
assert "$journal" not in out
assert "$other" not in out
assert "middle" in out
assert "end" in out
def test_render_template_keeps_inline_markers():
# only standalone marker lines are stripped
body = "# T\nmentions $journal inline\n"
out = cli.render_template(body, date(2026, 1, 1), "u")
assert "$journal inline" in out
def test_render_template_vars():
now = datetime(2026, 9, 26, 14, 30)
body = "%date% | %time% | %datetime% | %user%"
out = cli.render_template(body, date(2026, 1, 2), "ilya", now=now)
assert out == "2026-01-02 | 14:30 | 2026-01-02 14:30 | ilya\n"
def test_render_template_collapses_blank_edges():
body = "$journal\n\n# Title\n\n"
out = cli.render_template(body, date(2026, 1, 1), "u")
assert out == "# Title\n"
# -- extract_title -----------------------------------------------------------
def test_extract_title_heading():
assert cli.extract_title("# Hello\n\nbody") == "Hello"
def test_extract_title_first_line():
assert cli.extract_title("plain line\nmore") == "plain line"
def test_extract_title_untitled():
assert cli.extract_title(" \n\n") == "Untitled"
# -- config ------------------------------------------------------------------
def test_config_round_trip(tmp_path, monkeypatch):
path = tmp_path / "cfg" / "config.json"
monkeypatch.setattr(cli, "CONFIG_PATH", path)
cfg = cli.Config(url="http://sno.tes/", user="schmeeve", token="tok123")
cli.save_config(cfg)
loaded = cli.load_config()
assert loaded.url == "http://sno.tes" # trailing slash stripped
assert loaded.user == "schmeeve"
assert loaded.token == "tok123"
assert (path.stat().st_mode & 0o777) == 0o600
def test_load_config_missing(tmp_path, monkeypatch):
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / "nope.json")
with pytest.raises(cli.CliError, match="no config"):
cli.load_config()
def test_load_config_bad_json(tmp_path, monkeypatch):
path = tmp_path / "config.json"
path.write_text("{not json")
monkeypatch.setattr(cli, "CONFIG_PATH", path)
with pytest.raises(cli.CliError, match="bad config"):
cli.load_config()

169
tests/test_templates.py Normal file
View File

@@ -0,0 +1,169 @@
from __future__ import annotations
import aiosqlite
from backend import notes
from backend.config import settings
def test_extract_templates():
assert notes.extract_templates("Daily log\n$journal\n# Journal for %date%") == ["journal"]
def test_extract_templates_dedup_and_case():
assert notes.extract_templates("$Journal $journal $JOURNAL") == ["journal"]
def test_extract_templates_multiple():
assert notes.extract_templates("$journal and $standup") == ["journal", "standup"]
def test_extract_templates_ignores_prices():
# first char must be a letter — "$5" is not a template marker
assert notes.extract_templates("Lunch was $5, coffee $3") == []
def test_templates_are_not_tags():
body = "$journal #diary"
assert notes.extract_templates(body) == ["journal"]
assert notes.extract_tags(body) == ["diary"]
async def test_templates_endpoint_lists_definitions(client):
r = await client.post("/api/notes", json={"body": "# Journal for %date%\n\n$journal\n\n- "})
assert r.status_code == 200
r = await client.get("/api/templates")
assert r.status_code == 200
names = [t["name"] for t in r.json()]
assert "journal" in names
tpl = next(t for t in r.json() if t["name"] == "journal")
assert tpl["username"] == "schmeeve"
assert tpl["title"] == "Journal for %date%"
async def test_get_template_returns_body(client):
await client.post("/api/notes", json={"body": "# Journal for %date%\n\n$journal\n"})
r = await client.get("/api/templates/journal")
assert r.status_code == 200
assert r.json()["body"].startswith("# Journal for %date%")
assert r.json()["name"] == "journal"
async def test_get_template_404(client):
r = await client.get("/api/templates/nope")
assert r.status_code == 404
async def test_get_template_name_normalization(client):
await client.post("/api/notes", json={"body": "# T\n$Journal\n"})
r = await client.get("/api/templates/$JOURNAL")
assert r.status_code == 200
assert r.json()["name"] == "journal"
async def test_newest_note_wins_for_duplicate_template(client):
await client.post("/api/notes", json={"body": "# Old journal\n$journal\nold"})
r = await client.post("/api/notes", json={"body": "# New journal\n$journal\nnew"})
new_id = r.json()["id"]
r = await client.get("/api/templates/journal")
assert r.json()["id"] == new_id
assert "new" in r.json()["body"]
async def test_user_space_beats_common_for_templates(client):
await client.post(
"/api/notes", json={"body": "# Common journal\n$journal\ncommon", "username": "common"}
)
r = await client.post("/api/notes", json={"body": "# My journal\n$journal\nmine"})
own_id = r.json()["id"]
r = await client.get("/api/templates/journal")
assert r.json()["id"] == own_id
assert r.json()["username"] == "schmeeve"
async def test_removing_marker_removes_template(client):
r = await client.post("/api/notes", json={"body": "# J\n$journal\n"})
note_id = r.json()["id"]
await client.put(f"/api/notes/{note_id}", json={"body": "# J\nno marker\n"})
r = await client.get("/api/templates/journal")
assert r.status_code == 404
async def test_deleting_note_removes_template(client):
r = await client.post("/api/notes", json={"body": "# J\n$journal\n"})
note_id = r.json()["id"]
await client.delete(f"/api/notes/{note_id}")
r = await client.get("/api/templates/journal")
assert r.status_code == 404
async def test_lookup_by_title(client):
await client.post("/api/notes", json={"body": "# Journal for 2026-09-26\nentry\n"})
r = await client.get("/api/notes/lookup", params={"title": "Journal for 2026-09-26"})
assert r.status_code == 200
assert "entry" in r.json()["body"]
r = await client.get("/api/notes/lookup", params={"title": "Journal for 1999-01-01"})
assert r.status_code == 404
async def test_lookup_scoped_to_user(client):
await client.post("/api/notes", json={"body": "# Shared title\n", "username": "common"})
r = await client.get("/api/notes/lookup", params={"title": "Shared title"})
assert r.status_code == 404 # schmeeve's space only
async def test_lookup_requires_auth(remote_client):
r = await remote_client.get("/api/notes/lookup", params={"title": "x"})
assert r.status_code == 401
async def test_templates_require_auth(remote_client):
assert (await remote_client.get("/api/templates")).status_code == 401
assert (await remote_client.get("/api/templates/x")).status_code == 401
async def test_backfill_templates(client):
r = await client.post("/api/notes", json={"body": "# J\n$journal\n"})
note_id = r.json()["id"]
async with aiosqlite.connect(settings.database_path) as db:
await db.execute("DELETE FROM note_templates WHERE note_id = ?", (note_id,))
await db.commit()
await notes.backfill_templates()
r = await client.get("/api/templates/journal")
assert r.status_code == 200
assert r.json()["id"] == note_id
async def test_cli_install_script(client):
r = await client.get("/schmeeve/cli")
assert r.status_code == 200
assert "__SNOTES_URL__" not in r.text
assert "__SNOTES_USER__" not in r.text
assert 'SNOTES_USER="schmeeve"' in r.text
assert 'SNOTES_URL="http://test"' in r.text
assert "curl -fsSL" in r.text
async def test_cli_install_script_unknown_user(client):
r = await client.get("/ghost/cli")
assert r.status_code == 404
async def test_cli_install_script_is_public(remote_client):
r = await remote_client.get("/schmeeve/cli")
assert r.status_code == 200
assert 'SNOTES_USER="schmeeve"' in r.text
async def test_cli_script_served(client):
r = await client.get("/cli/snotes.py")
assert r.status_code == 200
assert "# /// script" in r.text
assert "textual" in r.text

107
tests/test_tui.py Normal file
View File

@@ -0,0 +1,107 @@
"""Headless smoke tests for the snotes TUI (Textual pilot)."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
textual = pytest.importorskip("textual") # noqa: F841
def _load_cli():
path = Path(__file__).parent.parent / "cli" / "snotes.py"
spec = importlib.util.spec_from_file_location("snotes_cli", path)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod
cli = _load_cli()
NOTES = [
{"id": "a1", "title": "Shopping list", "updated_at": "2026-09-26 09:00:00", "tags": ["home"]},
{
"id": "b2",
"title": "Journal for 2026-09-26",
"updated_at": "2026-09-25 22:00:00",
"tags": ["journal"],
},
]
BODIES = {
"a1": "# Shopping list\n\n- milk\n- eggs\n",
"b2": "# Journal for 2026-09-26\n\nA fine day.\n",
}
class FakeApi:
def __init__(self):
self.cfg = cli.Config(url="http://test", user="schmeeve")
self.deleted: list[str] = []
def recent(self, limit: int = 30) -> list[dict]:
return NOTES[:limit]
def search(self, q: str) -> list[dict]:
return [n for n in NOTES if q.lower() in n["title"].lower()]
def get_note(self, note_id: str) -> dict:
return {"id": note_id, "title": "t", "body": BODIES[note_id]}
def create(self, body: str) -> dict: # pragma: no cover - not exercised
return {"id": "new", "title": body}
def update(self, note_id: str, body: str) -> dict: # pragma: no cover
return {"id": note_id}
def delete(self, note_id: str) -> None:
self.deleted.append(note_id)
async def test_tui_boots_with_recent_notes():
app = cli.make_app(FakeApi(), None)
async with app.run_test() as pilot:
await pilot.pause()
from textual.widgets import ListView
lv = app.query_one("#notes", ListView)
assert len(lv.children) == 2
assert app.title == "snotes"
async def test_tui_initial_query_filters():
app = cli.make_app(FakeApi(), "journal")
async with app.run_test() as pilot:
await pilot.pause()
await pilot.pause(0.4) # debounce
from textual.widgets import Input, ListView
assert app.query_one("#search", Input).value == "journal"
assert len(app.query_one("#notes", ListView).children) == 1
async def test_tui_preview_shows_note_body():
app = cli.make_app(FakeApi(), None)
async with app.run_test() as pilot:
await pilot.pause()
from textual.widgets import Markdown
preview = app.query_one("#preview", Markdown)
await pilot.pause(0.2)
assert "Shopping list" in preview.source or "milk" in preview.source
async def test_tui_delete_flow():
fake = FakeApi()
app = cli.make_app(fake, None)
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("d")
await pilot.pause()
await pilot.press("y")
await pilot.pause(0.3)
assert fake.deleted == ["a1"]

88
uv.lock generated
View File

@@ -192,6 +192,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
] ]
[[package]]
name = "linkify-it-py"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.3" version = "26.3"
@@ -201,6 +248,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
] ]
[[package]]
name = "platformdirs"
version = "4.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/4d/e78afe1b449720c481884ca0a2f960f85f9ffdaa34b2d127b5427422c564/platformdirs-4.12.0.tar.gz", hash = "sha256:095be5c143382b1bee917c4f3e9987a0d8d6a582261f1d061ad0c403b7695b5b", size = 58964, upload-time = "2026-09-26T15:26:00.937Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/3a/723ad221b7fef91ccd947f9fddbe84d38fa573e2723317aaa67bfde41015/platformdirs-4.12.0-py3-none-any.whl", hash = "sha256:f6fb2960f2f2eb0870f820f7e49e49e6c0f0589c637f34d33b260b07591327c9", size = 32965, upload-time = "2026-09-26T15:25:59.496Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@@ -452,6 +508,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.16.8" version = "0.16.8"
@@ -506,6 +575,7 @@ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "ruff" }, { name = "ruff" },
{ name = "textual" },
] ]
[package.metadata] [package.metadata]
@@ -531,6 +601,7 @@ dev = [
{ name = "pytest", specifier = ">=8.2.0" }, { name = "pytest", specifier = ">=8.2.0" },
{ name = "pytest-asyncio", specifier = ">=0.23.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" },
{ name = "ruff", specifier = ">=0.4.0" }, { name = "ruff", specifier = ">=0.4.0" },
{ name = "textual", specifier = ">=1.0.0" },
] ]
[[package]] [[package]]
@@ -555,6 +626,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" },
] ]
[[package]]
name = "textual"
version = "8.2.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.16.0" version = "4.16.0"