CLI
This commit is contained in:
42
AGENTS.md
42
AGENTS.md
@@ -34,6 +34,9 @@ snotes/
|
||||
│ ├── 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
|
||||
@@ -54,9 +57,14 @@ snotes/
|
||||
| `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
|
||||
|
||||
@@ -131,3 +139,37 @@ and `npm install -g web-ext`.
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
- snotes recent shows a list of notes along with search
|
||||
- snotes find (term) opens the search interface
|
||||
- other TUI apps for ref: ~/git/mounter ~/git/setmeup ~/git/m
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,13 @@ CREATE TABLE IF NOT EXISTS note_tags (
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
|
||||
@@ -17,6 +18,7 @@ logger = structlog.get_logger()
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await database.init_db()
|
||||
await notes.backfill_templates()
|
||||
yield
|
||||
|
||||
|
||||
@@ -145,6 +147,14 @@ async def recent(request: Request, _: None = Depends(require_access)):
|
||||
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}")
|
||||
async def get_note(note_id: str, _: None = Depends(require_access)):
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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")
|
||||
async def homepage_stats(_: None = Depends(require_access)):
|
||||
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
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
140
backend/notes.py
140
backend/notes.py
@@ -4,12 +4,16 @@ import re
|
||||
import uuid
|
||||
|
||||
import aiosqlite
|
||||
import structlog
|
||||
|
||||
from backend import actions
|
||||
from backend.config import settings
|
||||
from backend.database import get_user_id
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
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._-]+)")
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
@@ -146,6 +160,7 @@ async def create_note(
|
||||
(note_id, user_id, title, rel),
|
||||
)
|
||||
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 _record_history(db, note_id, user_id, "create", ip, ua)
|
||||
await db.commit()
|
||||
@@ -207,6 +222,7 @@ async def update_note(
|
||||
)
|
||||
|
||||
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 _record_history(db, note_id, user_id, "update", ip, ua)
|
||||
await db.commit()
|
||||
@@ -253,6 +269,7 @@ async def delete_note(
|
||||
"UPDATE notes SET deleted_at = datetime('now') WHERE 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 db.commit()
|
||||
return True
|
||||
@@ -367,6 +384,120 @@ async def search_notes(query: str, limit: int = 50) -> list[dict]:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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(
|
||||
db: aiosqlite.Connection,
|
||||
note_id: str,
|
||||
|
||||
37
cli/install.sh
Normal file
37
cli/install.sh
Normal 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
568
cli/snotes.py
Normal 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)
|
||||
@@ -28,6 +28,7 @@ dev = [
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.27.0",
|
||||
"ruff>=0.4.0",
|
||||
"textual>=1.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -449,6 +449,9 @@ body {
|
||||
.tools-row { display: flex; align-items: center; gap: 12px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.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 h1 { margin: 0; }
|
||||
.tools-logo { height: 40px; width: auto; }
|
||||
|
||||
@@ -194,6 +194,7 @@ function header() {
|
||||
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: "QUICKsnote", onclick: showTools }),
|
||||
el("button", { class: "btn ghost", text: "CLI", onclick: showCli }),
|
||||
themeSelector(),
|
||||
]);
|
||||
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 })]);
|
||||
}
|
||||
|
||||
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() {
|
||||
state.view = "tools";
|
||||
root.innerHTML = "";
|
||||
|
||||
131
tests/test_cli.py
Normal file
131
tests/test_cli.py
Normal 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
169
tests/test_templates.py
Normal 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
107
tests/test_tui.py
Normal 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
88
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "packaging"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pluggy"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "ruff"
|
||||
version = "0.16.8"
|
||||
@@ -506,6 +575,7 @@ dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
{ name = "textual" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -531,6 +601,7 @@ dev = [
|
||||
{ name = "pytest", specifier = ">=8.2.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
|
||||
{ name = "ruff", specifier = ">=0.4.0" },
|
||||
{ name = "textual", specifier = ">=1.0.0" },
|
||||
]
|
||||
|
||||
[[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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
|
||||
Reference in New Issue
Block a user