Compare commits
24 Commits
0a13fed4a2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95e32b64ac | ||
|
|
0758095046 | ||
|
|
bc365267d0 | ||
| fd230b2c29 | |||
| 41f5aa19a3 | |||
| f8bd5fce32 | |||
| 54da169fe5 | |||
|
|
1fc3cc0e74 | ||
| c17f4ed33f | |||
| 3785518509 | |||
| 27a2ca6689 | |||
| 32f382353e | |||
| 832168e845 | |||
|
|
4138cd64b8 | ||
|
|
66cb98c062 | ||
| 3bc266ce2b | |||
| f82628f168 | |||
| 9cbf95672b | |||
| 720ffca099 | |||
|
|
92883d63f5 | ||
|
|
ff620a23ed | ||
|
|
caf8187252 | ||
|
|
7788c10fde | ||
|
|
197da6754f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -177,3 +177,4 @@ deploy/deploy.conf
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
/.DS_Store
|
||||
|
||||
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.
|
||||
|
||||
@@ -32,3 +32,18 @@ A simple hosted notes app which features:
|
||||
- Cowboy code
|
||||
- Make assumptions
|
||||
- Go YOLO
|
||||
|
||||
# CLI
|
||||
|
||||
snotes TUI app
|
||||
|
||||
- new CLI page on webapp gives instructions on installing the CLI: bash http://sno.tes/[user]/cli, the user allows the script to be customized upon download with both the userspace and the URL, such config is saved to ~/.config/snotes
|
||||
- snotes command alone opens TUI with list, new options, pane with most recent notes, search, like webapp
|
||||
- snotes [template] [date] opens a template dated [date]
|
||||
- templates are defined like tags but using $ notation, so $journal in the note defines a journal template
|
||||
- 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)
|
||||
@@ -223,8 +233,8 @@ async def get_attachment(username: str, filename: str, _: None = Depends(require
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/tags")
|
||||
async def tags(_: None = Depends(require_access)):
|
||||
return await notes.tag_counts()
|
||||
async def tags(request: Request, _: None = Depends(require_access)):
|
||||
return await notes.tag_counts(current_user(request))
|
||||
|
||||
|
||||
@app.get("/api/search")
|
||||
@@ -234,10 +244,58 @@ 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()
|
||||
|
||||
|
||||
@app.get("/static/snotes-capture-firefox.xpi", include_in_schema=False)
|
||||
async def extension_xpi() -> FileResponse:
|
||||
return FileResponse(
|
||||
"static/snotes-capture-firefox.xpi",
|
||||
media_type="application/x-xpinstall",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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")
|
||||
|
||||
240
backend/notes.py
240
backend/notes.py
@@ -4,12 +4,19 @@ 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)
|
||||
|
||||
|
||||
def extract_tags(text: str) -> list[str]:
|
||||
@@ -21,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()
|
||||
@@ -35,6 +52,34 @@ def extract_title(text: str) -> str:
|
||||
return "Untitled"
|
||||
|
||||
|
||||
def note_attachments(body: str) -> tuple[int, str | None]:
|
||||
count = 0
|
||||
preview = None
|
||||
for m in ATTACH_RE.finditer(body):
|
||||
count += 1
|
||||
if preview is None and IMAGE_EXT_RE.search(m.group(2)):
|
||||
preview = f"/api/attachments/{m.group(1)}/{m.group(2)}"
|
||||
return count, preview
|
||||
|
||||
|
||||
def note_snippet(body: str, limit: int = 240) -> str:
|
||||
lines = body.splitlines()
|
||||
start = 0
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.startswith("# "):
|
||||
start = i + 1
|
||||
continue
|
||||
start = i
|
||||
break
|
||||
excerpt = "\n".join(lines[start:]).strip()
|
||||
if len(excerpt) > limit:
|
||||
excerpt = excerpt[:limit].rstrip() + "…"
|
||||
return excerpt
|
||||
|
||||
|
||||
def note_path(username: str, note_id: str) -> str:
|
||||
return f"{username}/{note_id}.md"
|
||||
|
||||
@@ -115,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()
|
||||
@@ -176,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()
|
||||
@@ -222,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
|
||||
@@ -231,15 +279,15 @@ async def list_notes(username: str, limit: int = 100) -> list[dict]:
|
||||
async with aiosqlite.connect(settings.database_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
rows = await db.execute(
|
||||
"SELECT n.id, n.title, n.updated_at, n.created_at, u.name AS username"
|
||||
"SELECT n.id, n.title, n.updated_at, n.created_at, n.path,"
|
||||
" u.name AS username"
|
||||
" FROM notes n JOIN users u ON u.id = n.user_id"
|
||||
" WHERE n.deleted_at IS NULL AND u.name = ?"
|
||||
" ORDER BY n.updated_at DESC LIMIT ?",
|
||||
(username, limit),
|
||||
)
|
||||
notes = [dict(r) for r in await rows.fetchall()]
|
||||
for note in notes:
|
||||
note["tags"] = await get_note_tags(note["id"])
|
||||
await _attach_meta(notes)
|
||||
return notes
|
||||
|
||||
|
||||
@@ -247,15 +295,15 @@ async def list_recent(username: str, limit: int = 30) -> list[dict]:
|
||||
async with aiosqlite.connect(settings.database_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
rows = await db.execute(
|
||||
"SELECT n.id, n.title, n.updated_at, n.created_at, u.name AS username"
|
||||
"SELECT n.id, n.title, n.updated_at, n.created_at, n.path,"
|
||||
" u.name AS username"
|
||||
" FROM notes n JOIN users u ON u.id = n.user_id"
|
||||
" WHERE n.deleted_at IS NULL AND u.name IN (?, ?)"
|
||||
" ORDER BY n.updated_at DESC LIMIT ?",
|
||||
(username, settings.common_space, limit),
|
||||
)
|
||||
notes = [dict(r) for r in await rows.fetchall()]
|
||||
for note in notes:
|
||||
note["tags"] = await get_note_tags(note["id"])
|
||||
await _attach_meta(notes)
|
||||
return notes
|
||||
|
||||
|
||||
@@ -270,14 +318,37 @@ async def get_note_tags(note_id: str) -> list[str]:
|
||||
return [r["name"] for r in await rows.fetchall()]
|
||||
|
||||
|
||||
async def tag_counts() -> list[dict]:
|
||||
async def _attach_meta(notes: list[dict]) -> None:
|
||||
for note in notes:
|
||||
note["tags"] = await get_note_tags(note["id"])
|
||||
try:
|
||||
body = await read_note_file(note["path"])
|
||||
count, preview = note_attachments(body)
|
||||
except FileNotFoundError:
|
||||
count, preview = 0, None
|
||||
note["attachments"] = count
|
||||
note["image"] = preview
|
||||
note["snippet"] = note_snippet(body)
|
||||
note.pop("path", None)
|
||||
|
||||
|
||||
async def tag_counts(username: str) -> list[dict]:
|
||||
async with aiosqlite.connect(settings.database_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
names = (
|
||||
[username, settings.common_space]
|
||||
if username != settings.common_space
|
||||
else [settings.common_space]
|
||||
)
|
||||
placeholders = ",".join("?" for _ in names)
|
||||
rows = await db.execute(
|
||||
"SELECT t.name, COUNT(nt.note_id) AS count"
|
||||
" FROM tags t JOIN note_tags nt ON nt.tag_id = t.id"
|
||||
" JOIN notes n ON n.id = nt.note_id AND n.deleted_at IS NULL"
|
||||
" GROUP BY t.id ORDER BY count DESC, t.name LIMIT 50"
|
||||
" JOIN users u ON u.id = n.user_id"
|
||||
f" WHERE u.name IN ({placeholders})"
|
||||
" GROUP BY t.id ORDER BY count DESC, t.name LIMIT 50",
|
||||
names,
|
||||
)
|
||||
return [dict(r) for r in await rows.fetchall()]
|
||||
|
||||
@@ -285,15 +356,27 @@ async def tag_counts() -> list[dict]:
|
||||
async def search_notes(query: str, limit: int = 50) -> list[dict]:
|
||||
async with aiosqlite.connect(settings.database_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
rows = await db.execute(
|
||||
"SELECT f.note_id AS id, f.title, f.body, n.updated_at,"
|
||||
" n.created_at, u.name AS username, bm25(notes_fts) AS rank"
|
||||
" FROM notes_fts f"
|
||||
" JOIN notes n ON n.id = f.note_id AND n.deleted_at IS NULL"
|
||||
" JOIN users u ON u.id = n.user_id"
|
||||
" WHERE notes_fts MATCH ? ORDER BY rank LIMIT ?",
|
||||
(query, limit),
|
||||
)
|
||||
if query.lstrip().startswith("#"):
|
||||
tag = query.lstrip()[1:].strip()
|
||||
rows = await db.execute(
|
||||
"SELECT n.id, n.title, n.updated_at, n.created_at, u.name AS username"
|
||||
" FROM tags t"
|
||||
" JOIN note_tags nt ON nt.tag_id = t.id"
|
||||
" JOIN notes n ON n.id = nt.note_id AND n.deleted_at IS NULL"
|
||||
" JOIN users u ON u.id = n.user_id"
|
||||
" WHERE t.name = ? ORDER BY n.updated_at DESC LIMIT ?",
|
||||
(tag, limit),
|
||||
)
|
||||
else:
|
||||
rows = await db.execute(
|
||||
"SELECT f.note_id AS id, f.title, f.body, n.updated_at,"
|
||||
" n.created_at, u.name AS username, bm25(notes_fts) AS rank"
|
||||
" FROM notes_fts f"
|
||||
" JOIN notes n ON n.id = f.note_id AND n.deleted_at IS NULL"
|
||||
" JOIN users u ON u.id = n.user_id"
|
||||
" WHERE notes_fts MATCH ? ORDER BY rank LIMIT ?",
|
||||
(query, limit),
|
||||
)
|
||||
notes = [dict(r) for r in await rows.fetchall()]
|
||||
for note in notes:
|
||||
note["tags"] = await get_note_tags(note["id"])
|
||||
@@ -301,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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -318,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,
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"uploadUuid":"4dae4288f9724a00b69f76b77b1da011","channel":"unlisted","xpiCrcHash":"77714bcdd59075c7a0167c21f97f0214fbc0e80e137095ac6f3ba232aa9d08b9"}
|
||||
{"uploadUuid":"c8cf6d23e92348dc9f7088205d5ea7ef","channel":"unlisted","xpiCrcHash":"8b5d82ee53a5ae538c6337ad681b322150f77289e61ef2ec2290c4d7a572a028"}
|
||||
@@ -22,6 +22,10 @@ browser.runtime.onMessage.addListener((msg, sender) => {
|
||||
return listUsers();
|
||||
case 'save_note':
|
||||
return saveNote(msg);
|
||||
case 'upload_attachment':
|
||||
return uploadAttachment(msg);
|
||||
case 'capture_full_page':
|
||||
return captureFullPage(msg);
|
||||
default:
|
||||
return Promise.reject(new Error('Unknown message kind'));
|
||||
}
|
||||
@@ -69,3 +73,87 @@ async function saveNote(msg) {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadAttachment(msg) {
|
||||
const base = await getServerUrl();
|
||||
if (!base) {
|
||||
throw new Error('Server URL not set — open snotes and click "Capture" → "Apply to Extension", or set it in Options');
|
||||
}
|
||||
const blob = dataUrlToBlob(msg.dataUrl);
|
||||
const form = new FormData();
|
||||
form.append('file', blob, msg.filename || 'page.png');
|
||||
if (msg.username) form.append('username', msg.username);
|
||||
const res = await fetch(base + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'HTTP ' + res.status);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const MAX_SLICES = 60;
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
function loadImage(dataUrl) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const img = new Image();
|
||||
img.onload = function () { resolve(img); };
|
||||
img.onerror = function () { reject(new Error('Could not decode screenshot slice')); };
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
async function captureFullPage(msg) {
|
||||
const tabId = msg.tabId;
|
||||
const windowId = msg.windowId;
|
||||
if (tabId == null) throw new Error('No active tab');
|
||||
|
||||
const dims = await browser.tabs.sendMessage(tabId, { kind: 'get_viewport' }, { frameId: 0 });
|
||||
const vw = dims.viewportWidth || 1;
|
||||
const vh = dims.viewportHeight || 1;
|
||||
const ph = dims.pageHeight || vh;
|
||||
const dpr = dims.devicePixelRatio || 1;
|
||||
|
||||
const sliceCount = Math.min(MAX_SLICES, Math.ceil(ph / vh));
|
||||
if (Math.ceil(ph / vh) > MAX_SLICES) {
|
||||
throw new Error('Page too tall to capture (' + Math.ceil(ph / vh) + ' viewports)');
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
for (let i = 0; i < sliceCount; i++) {
|
||||
const y = Math.max(0, Math.min(i * vh, ph - vh));
|
||||
await browser.tabs.sendMessage(tabId, { kind: 'scroll_to', y: y }, { frameId: 0 });
|
||||
await sleep(250);
|
||||
const dataUrl = await browser.tabs.captureVisibleTab(windowId, { format: 'png' });
|
||||
parts.push({ dataUrl: dataUrl, y: y });
|
||||
}
|
||||
|
||||
await browser.tabs.sendMessage(tabId, { kind: 'scroll_to', y: dims.scrollY || 0 }, { frameId: 0 }).catch(() => {});
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(vw * dpr);
|
||||
canvas.height = Math.round(ph * dpr);
|
||||
const ctx = canvas.getContext('2d');
|
||||
for (const p of parts) {
|
||||
const img = await loadImage(p.dataUrl);
|
||||
ctx.drawImage(img, 0, Math.round(p.y * dpr));
|
||||
}
|
||||
|
||||
return { dataUrl: canvas.toDataURL('image/png') };
|
||||
}
|
||||
|
||||
function dataUrlToBlob(dataUrl) {
|
||||
const [meta, b64] = dataUrl.split(',');
|
||||
const mime = (meta.match(/data:(.*?);/) || [])[1] || 'image/png';
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return new Blob([bytes], { type: mime });
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
|
||||
// ── Extension→Page messaging (capture context for the popup) ──────────────
|
||||
browser.runtime.onMessage.addListener(function (msg) {
|
||||
if (msg && msg.kind === 'get_context') {
|
||||
if (!msg || typeof msg.kind !== 'string') return Promise.resolve(undefined);
|
||||
|
||||
if (msg.kind === 'get_context') {
|
||||
var selection = '';
|
||||
try { selection = window.getSelection().toString().trim(); } catch (e) {}
|
||||
return Promise.resolve({
|
||||
@@ -32,6 +34,26 @@
|
||||
url: window.location.href || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (msg.kind === 'get_viewport') {
|
||||
var ph = Math.max(
|
||||
document.documentElement ? document.documentElement.scrollHeight : 0,
|
||||
document.body ? document.body.scrollHeight : 0
|
||||
);
|
||||
return Promise.resolve({
|
||||
viewportWidth: window.innerWidth || document.documentElement.clientWidth || 0,
|
||||
viewportHeight: window.innerHeight || document.documentElement.clientHeight || 0,
|
||||
pageHeight: ph,
|
||||
devicePixelRatio: window.devicePixelRatio || 1,
|
||||
scrollY: window.scrollY || window.pageYOffset || 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (msg.kind === 'scroll_to') {
|
||||
window.scrollTo(0, msg.y);
|
||||
return Promise.resolve({ y: window.scrollY || window.pageYOffset || 0 });
|
||||
}
|
||||
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "quickSnote",
|
||||
"version": "1.0.2",
|
||||
"description": "Quick-capture a note to snotes from any page, with the selected text and page link ready to use",
|
||||
"name": "QUICKsnote",
|
||||
"version": "1.0.7",
|
||||
"description": "QUICKsnote captures a note to snotes from any page, with the selected text and page link ready to use",
|
||||
"permissions": [
|
||||
"storage"
|
||||
"storage",
|
||||
"activeTab"
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src http://* https://*"
|
||||
"extension_pages": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src http: https:"
|
||||
},
|
||||
"host_permissions": [
|
||||
"http://*/*",
|
||||
@@ -30,7 +31,7 @@
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"all_frames": true,
|
||||
"all_frames": false,
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
@@ -41,7 +42,7 @@
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"default_title": "quickSnote",
|
||||
"default_title": "QUICKsnote",
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"48": "icons/icon-48.png",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>quickSnote Options</title>
|
||||
<title>QUICKsnote Options</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
@@ -26,7 +26,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>quickSnote</h1>
|
||||
<h1>QUICKsnote</h1>
|
||||
|
||||
<div class="field">
|
||||
<label for="serverUrl">snotes server URL</label>
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<img src="icons/icon-48.png" alt="" class="logo">
|
||||
<img src="icons/icon-48.png" alt="" class="logo" id="logo">
|
||||
<select id="user" title="User / space"></select>
|
||||
</div>
|
||||
<textarea id="note" placeholder="Type a note…" spellcheck="false"></textarea>
|
||||
<div class="actions">
|
||||
<button id="capture-page" title="Insert page title + link" type="button">⤴ Page</button>
|
||||
<button id="capture-page" title="Bookmark this page: insert title + link, tag #bookmark, and attach a full-page screenshot" type="button">⤴ Bookmark</button>
|
||||
<div class="spacer"></div>
|
||||
<button id="save" class="primary" type="button">Save</button>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
try {
|
||||
const r = await browser.runtime.sendMessage({ kind: 'list_users' });
|
||||
const users = (r && r.users) || [];
|
||||
const saved = await browser.storage.sync.get('username').then(x => x.username);
|
||||
userEl.innerHTML = '';
|
||||
for (const u of users) {
|
||||
const opt = document.createElement('option');
|
||||
@@ -32,6 +33,9 @@
|
||||
opt.textContent = u.name;
|
||||
userEl.appendChild(opt);
|
||||
}
|
||||
if (saved && [...userEl.options].some(o => o.value === saved)) {
|
||||
userEl.value = saved;
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus('Cannot load users: ' + e.message, false);
|
||||
}
|
||||
@@ -41,7 +45,7 @@
|
||||
try {
|
||||
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs.length || tabs[0].id == null) return;
|
||||
const ctx = await browser.tabs.sendMessage(tabs[0].id, { kind: 'get_context' });
|
||||
const ctx = await browser.tabs.sendMessage(tabs[0].id, { kind: 'get_context' }, { frameId: 0 });
|
||||
if (ctx && ctx.selection && !noteEl.value) {
|
||||
noteEl.value = ctx.selection;
|
||||
}
|
||||
@@ -54,14 +58,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
function capturePage() {
|
||||
async function capturePage() {
|
||||
var title = noteEl.dataset.pageTitle || '';
|
||||
var url = noteEl.dataset.pageUrl || '';
|
||||
if (!title && !url) { showStatus('No page to capture', false); return; }
|
||||
var link = '[' + (title || url) + '](' + (url || '') + ')';
|
||||
var v = noteEl.value;
|
||||
if (v && !/\n$/.test(v)) v += '\n';
|
||||
noteEl.value = v + link + '\n';
|
||||
noteEl.value = v + link + ' #bookmark\n';
|
||||
|
||||
captureBtn.disabled = true;
|
||||
showStatus('Capturing full page…', true);
|
||||
try {
|
||||
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs.length || tabs[0].id == null) { showStatus('No active tab ', false); return; }
|
||||
const shot = await browser.runtime.sendMessage({
|
||||
kind: 'capture_full_page',
|
||||
tabId: tabs[0].id,
|
||||
windowId: tabs[0].windowId,
|
||||
});
|
||||
const r = await browser.runtime.sendMessage({
|
||||
kind: 'upload_attachment',
|
||||
dataUrl: shot.dataUrl,
|
||||
filename: 'page.png',
|
||||
username: userEl.value,
|
||||
});
|
||||
noteEl.value += '\n';
|
||||
showStatus('Full-page screenshot attached', true);
|
||||
} catch (e) {
|
||||
showStatus('Screenshot failed: ' + e.message, false);
|
||||
} finally {
|
||||
captureBtn.disabled = false;
|
||||
}
|
||||
|
||||
noteEl.focus();
|
||||
scheduleAutosave();
|
||||
}
|
||||
@@ -99,6 +128,17 @@
|
||||
|
||||
noteEl.addEventListener('input', scheduleAutosave);
|
||||
|
||||
userEl.addEventListener('change', function () {
|
||||
browser.storage.sync.set({ username: userEl.value });
|
||||
});
|
||||
|
||||
document.getElementById('logo').addEventListener('click', function () {
|
||||
browser.storage.sync.get('serverUrl').then(function (r) {
|
||||
var url = (r.serverUrl || '').replace(/\/+$/, '');
|
||||
if (url) browser.tabs.create({ url: url });
|
||||
});
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', function () {
|
||||
if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; }
|
||||
save();
|
||||
|
||||
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]
|
||||
|
||||
150
static/app.css
150
static/app.css
@@ -212,7 +212,7 @@ body {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 18px; }
|
||||
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 18px; cursor: pointer; }
|
||||
.brand-logo { height: 65px; width: auto; object-fit: contain; }
|
||||
|
||||
.search {
|
||||
@@ -230,6 +230,27 @@ body {
|
||||
.topbar-right { display: flex; align-items: center; gap: 10px; margin-left: auto; }
|
||||
.user-select { padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text); }
|
||||
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-toggle span {
|
||||
display: block;
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
background: var(--text);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -246,6 +267,27 @@ body {
|
||||
.btn.danger { color: var(--danger); border-color: var(--danger); }
|
||||
.btn.ghost { background: transparent; border-color: transparent; }
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.back-btn:hover { background: var(--bg-hover); border-color: var(--primary); }
|
||||
.back-icon { display: inline-flex; }
|
||||
.back-icon svg { width: 18px; height: 18px; }
|
||||
|
||||
.results-head { display: flex; align-items: center; gap: 10px; }
|
||||
.results-head h2 { margin: 0; font-size: 20px; }
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
@@ -272,9 +314,30 @@ body {
|
||||
.note-title { font-weight: 600; color: var(--secondary); }
|
||||
.note-meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
.note-tags { color: var(--primary); font-size: 12px; margin-top: 4px; }
|
||||
.note-snippet { color: var(--text); font-size: 13px; line-height: 1.45; margin-top: 6px; overflow: hidden; }
|
||||
.note-snippet p { margin: 0 0 0.3em; }
|
||||
.note-snippet p:last-child { margin-bottom: 0; }
|
||||
.note-snippet ul, .note-snippet ol { margin: 0.2em 0; padding-left: 1.4em; }
|
||||
.note-snippet code { background: var(--code-bg); color: var(--code-fg); padding: 1px 4px; border-radius: 4px; font-size: 0.9em; }
|
||||
.note-snippet a { color: var(--secondary); text-decoration: underline; }
|
||||
.note-snippet img { max-width: 40px; max-height: 40px; object-fit: cover; border-radius: 4px; vertical-align: middle; }
|
||||
.note-snippet h1, .note-snippet h2, .note-snippet h3 { margin: 0.3em 0; font-size: 1em; color: var(--secondary); }
|
||||
.note-snippet blockquote { border-left: 3px solid var(--secondary); margin: 0.3em 0; padding-left: 10px; color: var(--muted); }
|
||||
|
||||
.note-common { border-left: 3px solid var(--secondary); }
|
||||
.common-badge {
|
||||
.note-item { display: flex; flex-direction: column; gap: 10px; }
|
||||
.note-body { flex: 1; min-width: 0; }
|
||||
.note-banner {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.attach-count { color: var(--muted); margin-left: 8px; }
|
||||
|
||||
.note-common { border-left: 3px solid var(--secondary); }.common-badge {
|
||||
display: inline-block;
|
||||
background: color-mix(in srgb, var(--secondary) 18%, transparent);
|
||||
color: var(--secondary);
|
||||
@@ -386,6 +449,12 @@ 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%; }
|
||||
.icon-btn { display: inline-flex; align-items: center; justify-content: center; padding: 8px 10px; }
|
||||
.icon-btn svg { width: 16px; height: 16px; }
|
||||
.icon-btn.ok { color: var(--primary); border-color: var(--primary); }
|
||||
|
||||
.tools-head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||
.tools-head h1 { margin: 0; }
|
||||
.tools-logo { height: 40px; width: auto; }
|
||||
@@ -476,9 +545,76 @@ body {
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Prevent wide content (tables, code, long words) from blowing out the layout */
|
||||
.col-main, .col-side, .editor, .preview { min-width: 0; }
|
||||
.note-snippet, .preview, .editor { overflow-wrap: break-word; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; }
|
||||
.preview table, .note-snippet table { display: block; max-width: 100%; overflow-x: auto; }
|
||||
.note-snippet img, .preview img, .editor img { max-width: 100%; height: auto; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.topbar { flex-wrap: wrap; }
|
||||
.search { max-width: none; order: 3; flex-basis: 100%; }
|
||||
.theme-popover { right: -60px; }
|
||||
.layout { grid-template-columns: 1fr; gap: 12px; margin: 12px auto; padding: 0 12px; }
|
||||
|
||||
.topbar { flex-wrap: wrap; gap: 8px; padding: 10px 12px; }
|
||||
.brand-logo { height: 44px; }
|
||||
.brand { font-size: 16px; }
|
||||
.menu-toggle { display: flex; margin-left: auto; }
|
||||
.search { max-width: none; order: 3; flex-basis: 100%; font-size: 16px; }
|
||||
.topbar-right {
|
||||
order: 4;
|
||||
flex-basis: 100%;
|
||||
margin-left: 0;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.topbar-right.open { display: flex; }
|
||||
.topbar-right .btn { padding: 10px 12px; width: 100%; text-align: left; }
|
||||
.topbar-right .user-select { width: 100%; padding: 10px; font-size: 15px; }
|
||||
.topbar-right .theme-selector { width: 100%; }
|
||||
.topbar-right .theme-btn { width: 100%; justify-content: space-between; padding: 10px 12px; }
|
||||
|
||||
.editor-wrap, .help-card, .tools-row { padding: 0 12px; }
|
||||
.editor-wrap { margin: 12px auto; }
|
||||
|
||||
.editor-topbar { flex-wrap: wrap; gap: 6px; }
|
||||
.editor-topbar .spacer { display: none; }
|
||||
.editor-topbar .btn, .editor-topbar .user-select { padding: 8px 10px; }
|
||||
|
||||
.toolbar { flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.toolbar .sep { flex-shrink: 0; }
|
||||
|
||||
.editor { padding: 12px 14px; min-height: 50vh; }
|
||||
.editor .ProseMirror { min-height: 45vh; }
|
||||
|
||||
.editor-split { grid-template-columns: 1fr; }
|
||||
.editor-split .editor { border-right: none; }
|
||||
.editor-split .preview { min-height: auto; padding: 12px 14px; border-top: 1px solid var(--border); }
|
||||
|
||||
.note-item { padding: 10px 12px; }
|
||||
.note-thumb { width: 48px; height: 48px; }
|
||||
|
||||
.btn, .tool-btn, .tag-chip, .theme-option { min-height: 44px; }
|
||||
.tool-btn { min-height: 36px; }
|
||||
|
||||
.theme-popover {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
min-width: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.modal { width: 94%; padding: 16px; }
|
||||
|
||||
.help-table { font-size: 13px; }
|
||||
.help-table th, .help-table td { padding: 6px 8px; }
|
||||
.help-table code { white-space: normal; word-break: break-word; }
|
||||
}
|
||||
|
||||
176
static/app.js
176
static/app.js
@@ -32,6 +32,7 @@ const THEMES = [
|
||||
{ id: "sunset", label: "Sunset", dark: true, dots: ["#1c1018", "#251420", "#f472b6", "#fb923c"] },
|
||||
];
|
||||
const THEME_CONFIG_KEY = "snotes-theme-config";
|
||||
const PREVIEW_KEY = "snotes-preview";
|
||||
const themeMQ = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
function findTheme(id) { return THEMES.find((t) => t.id === id); }
|
||||
@@ -154,6 +155,25 @@ function escapeHtml(s) {
|
||||
return (s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
// navigator.clipboard only exists in secure contexts (HTTPS/localhost);
|
||||
// fall back to a hidden textarea + execCommand for plain-HTTP LAN use.
|
||||
async function copyText(text) {
|
||||
if (window.isSecureContext && navigator.clipboard) {
|
||||
try { await navigator.clipboard.writeText(text); return true; } catch (e) { /* fall through */ }
|
||||
}
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
let ok = false;
|
||||
try { ok = document.execCommand("copy"); } catch (e) {}
|
||||
ta.remove();
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
const me = await api("GET", "/api/me");
|
||||
@@ -188,18 +208,24 @@ async function doLogin() {
|
||||
}
|
||||
|
||||
function header() {
|
||||
const menu = el("div", { class: "topbar-right", id: "mobile-menu" }, [
|
||||
el("select", { id: "user-select", class: "user-select", onchange: onUserChange }),
|
||||
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") }, [
|
||||
el("span", {}), el("span", {}), el("span", {}),
|
||||
]);
|
||||
return el("header", { class: "topbar" }, [
|
||||
el("div", { class: "brand" }, [
|
||||
el("div", { class: "brand", onclick: showHome, title: "Home", role: "button" }, [
|
||||
el("img", { src: "/static/snotes-logo.png", class: "brand-logo", alt: "snotes" }),
|
||||
]),
|
||||
burger,
|
||||
el("input", { id: "search", class: "search", type: "search", placeholder: "Search notes…", oninput: debounce(onSearch, 250) }),
|
||||
el("div", { class: "topbar-right" }, [
|
||||
el("select", { id: "user-select", class: "user-select", onchange: onUserChange }),
|
||||
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 }),
|
||||
themeSelector(),
|
||||
]),
|
||||
menu,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -208,11 +234,14 @@ function showHelp() {
|
||||
root.innerHTML = "";
|
||||
state.editor?.destroy(); state.editor = null;
|
||||
|
||||
const back = el("button", { class: "btn ghost", text: "← Back", onclick: showHome });
|
||||
const back = backButton(showHome);
|
||||
const wrap = el("div", { class: "editor-wrap" }, [
|
||||
el("div", { class: "editor-topbar" }, [back]),
|
||||
el("div", { class: "help-card" }, [
|
||||
el("h1", { text: "Help" }),
|
||||
el("div", { class: "tools-head" }, [
|
||||
el("img", { src: "/static/snotes-logo.png", class: "tools-logo", alt: "snotes" }),
|
||||
el("h1", { text: "Help" }),
|
||||
]),
|
||||
|
||||
el("h2", { text: "Keyboard shortcuts" }),
|
||||
el("table", { class: "help-table" }, [
|
||||
@@ -243,12 +272,72 @@ 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 CLIP_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path></svg>';
|
||||
const CHECK_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
||||
const copyBtn = el("button", { class: "btn icon-btn", title: "Copy install command", "aria-label": "Copy install command", html: CLIP_ICON });
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
const ok = await copyText(installCmd);
|
||||
if (!ok) { // last resort: select the text so the user can ⌘C
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(cmdEl);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return;
|
||||
}
|
||||
copyBtn.innerHTML = CHECK_ICON;
|
||||
copyBtn.classList.add("ok");
|
||||
setTimeout(() => { copyBtn.innerHTML = CLIP_ICON; copyBtn.classList.remove("ok"); }, 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 = "";
|
||||
state.editor?.destroy(); state.editor = null;
|
||||
|
||||
const back = el("button", { class: "btn ghost", text: "← Back", onclick: showHome });
|
||||
const back = backButton(showHome);
|
||||
|
||||
const extStatus = el("div", { class: "muted", text: "" });
|
||||
const installBtn = el("button", { class: "btn primary", text: "Install Browser Extension" });
|
||||
@@ -257,10 +346,10 @@ function showTools() {
|
||||
|
||||
const card = el("div", { class: "help-card" }, [
|
||||
el("div", { class: "tools-head" }, [
|
||||
el("img", { src: "/static/snotes-logo.png", class: "tools-logo", alt: "quickSnote" }),
|
||||
el("h1", { text: "Quick capture" }),
|
||||
el("img", { src: "/static/snotes-logo.png", class: "tools-logo", alt: "QUICKsnote" }),
|
||||
el("h1", { text: "QUICKsnote" }),
|
||||
]),
|
||||
el("p", { html: 'The <b>quickSnote</b> browser extension lets you jot a note from any page. It prefills the note with the text you\'ve selected, and a <b>Page</b> button inserts the current page\'s title and link.' }),
|
||||
el("p", { html: 'The <b>QUICKsnote</b> browser extension lets you jot a note from any page. It prefills the note with the text you\'ve selected, and a <b>Page</b> button inserts the current page\'s title and link.' }),
|
||||
|
||||
el("h2", { text: "Firefox" }),
|
||||
el("div", { class: "tools-row" }, [installBtn, extStatus]),
|
||||
@@ -294,11 +383,17 @@ function showTools() {
|
||||
if (slowInterval) clearInterval(slowInterval), slowInterval = null;
|
||||
}
|
||||
|
||||
function applySettings() {
|
||||
window.postMessage({ kind: "snotes-settings", serverUrl: window.location.origin }, "*");
|
||||
}
|
||||
|
||||
function onDetected() {
|
||||
const wasDetected = detected;
|
||||
detected = true;
|
||||
stopPing();
|
||||
extStatus.textContent = "✓ Extension detected";
|
||||
installBtn.textContent = "Extension installed";
|
||||
if (!wasDetected) setTimeout(applySettings, 300);
|
||||
}
|
||||
|
||||
function doPing() {
|
||||
@@ -315,7 +410,7 @@ function showTools() {
|
||||
|
||||
installBtn.addEventListener("click", () => {
|
||||
if (detected) return;
|
||||
if (window.InstallTrigger) InstallTrigger.install({ "quickSnote": xpiUrl });
|
||||
if (window.InstallTrigger) InstallTrigger.install({ "QUICKsnote": xpiUrl });
|
||||
else window.location.href = xpiUrl;
|
||||
extStatus.textContent = "Installing… check toolbar for prompt";
|
||||
stopPing();
|
||||
@@ -324,7 +419,7 @@ function showTools() {
|
||||
});
|
||||
|
||||
qsApply.addEventListener("click", () => {
|
||||
window.postMessage({ kind: "snotes-settings", serverUrl: window.location.origin }, "*");
|
||||
applySettings();
|
||||
qsStatus.textContent = "Applying…";
|
||||
setTimeout(() => { if (qsStatus.textContent === "Applying…") qsStatus.textContent = "No response — is the extension installed?"; }, 5000);
|
||||
});
|
||||
@@ -408,11 +503,19 @@ function noteList(notes) {
|
||||
const isCommon = n.username === state.commonSpace;
|
||||
const meta = el("div", { class: "note-meta" });
|
||||
if (isCommon) meta.appendChild(el("span", { class: "common-badge", text: "common" }));
|
||||
meta.appendChild(document.createTextNode(`${n.username} · ${relTime(n.updated_at)}`));
|
||||
ul.appendChild(el("li", { class: isCommon ? "note-item note-common" : "note-item", onclick: () => openNote(n.id) }, [
|
||||
meta.appendChild(document.createTextNode(`${n.username} · edited ${relTime(n.updated_at)} · created ${fmtDate(n.created_at)}`));
|
||||
if (n.attachments) meta.appendChild(el("span", { class: "attach-count", text: n.attachments + " attachment" + (n.attachments === 1 ? "" : "s") }));
|
||||
let snippetHtml = n.snippet ? marked.parse(n.snippet) : "";
|
||||
if (n.image) snippetHtml = snippetHtml.replace(/<img[^>]*>/g, "").trim();
|
||||
const bodyEl = el("div", { class: "note-body" }, [
|
||||
el("div", { class: "note-title", text: n.title || "Untitled" }),
|
||||
meta,
|
||||
el("div", { class: "note-tags", text: tags }),
|
||||
snippetHtml ? el("div", { class: "note-snippet", html: snippetHtml }) : null,
|
||||
]);
|
||||
ul.appendChild(el("li", { class: isCommon ? "note-item note-common" : "note-item", onclick: () => openNote(n.id) }, [
|
||||
n.image ? el("img", { class: "note-banner", src: n.image, alt: "", loading: "lazy" }) : null,
|
||||
bodyEl,
|
||||
]));
|
||||
}
|
||||
return ul;
|
||||
@@ -424,7 +527,7 @@ async function onSearch(e) {
|
||||
const results = await api("GET", "/api/search?q=" + encodeURIComponent(q));
|
||||
const box = document.getElementById("search-results");
|
||||
box.innerHTML = "";
|
||||
box.appendChild(el("h2", { text: `Results for "${q}"` }));
|
||||
box.appendChild(searchHeader(q));
|
||||
box.appendChild(noteList(results));
|
||||
}
|
||||
|
||||
@@ -433,10 +536,30 @@ async function searchFor(q) {
|
||||
const results = await api("GET", "/api/search?q=" + encodeURIComponent(q));
|
||||
const box = document.getElementById("search-results");
|
||||
box.innerHTML = "";
|
||||
box.appendChild(el("h2", { text: `Results for "${q}"` }));
|
||||
box.appendChild(searchHeader(q));
|
||||
box.appendChild(noteList(results));
|
||||
}
|
||||
|
||||
function searchHeader(q) {
|
||||
return el("div", { class: "results-head" }, [
|
||||
backButton(clearSearch),
|
||||
el("h2", { text: `Results for "${q}"` }),
|
||||
]);
|
||||
}
|
||||
|
||||
async function clearSearch() {
|
||||
document.getElementById("search").value = "";
|
||||
document.getElementById("search-results").innerHTML = "";
|
||||
await loadRecent();
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso.replace(" ", "T") + "Z");
|
||||
if (isNaN(d)) return iso;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function relTime(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso.replace(" ", "T") + "Z");
|
||||
@@ -469,7 +592,7 @@ function renderEditor(body, owner) {
|
||||
state.editor?.destroy();
|
||||
|
||||
const isNew = !state.noteId;
|
||||
const back = el("button", { class: "btn ghost", text: "← Back", onclick: exitEditor });
|
||||
const back = backButton(exitEditor);
|
||||
const del = state.noteId ? el("button", { class: "btn danger", text: "Delete", onclick: deleteNote }) : null;
|
||||
const hist = state.noteId ? el("button", { class: "btn ghost", text: "History", onclick: showHistory }) : null;
|
||||
|
||||
@@ -499,7 +622,10 @@ function renderEditor(body, owner) {
|
||||
const editorArea = el("div", { class: "editor" });
|
||||
const previewArea = el("div", { class: "preview" });
|
||||
const split = el("div", { class: "editor-split" }, [editorArea, previewArea]);
|
||||
const previewBtn = el("button", { class: "btn ghost preview-toggle active", text: "Preview", title: "Toggle live preview", onclick: () => { split.classList.toggle("preview-hidden"); previewBtn.classList.toggle("active"); } });
|
||||
let previewOn = true;
|
||||
try { previewOn = localStorage.getItem(PREVIEW_KEY) !== "off"; } catch (e) {}
|
||||
const previewBtn = el("button", { class: "btn ghost preview-toggle" + (previewOn ? " active" : ""), text: "Preview", title: "Toggle live preview", onclick: () => { split.classList.toggle("preview-hidden"); previewBtn.classList.toggle("active"); try { localStorage.setItem(PREVIEW_KEY, previewBtn.classList.contains("active") ? "on" : "off"); } catch (e) {} } });
|
||||
if (!previewOn) split.classList.add("preview-hidden");
|
||||
const status = el("div", { class: "status", text: "" });
|
||||
const hint = el("div", { class: "editor-hint" }, [
|
||||
el("p", { html: 'Tags: type <code>#tag</code> anywhere in your note to add a tag.' }),
|
||||
@@ -542,6 +668,12 @@ function renderEditor(body, owner) {
|
||||
|
||||
function sep() { return el("span", { class: "sep" }); }
|
||||
|
||||
function backButton(onclick) {
|
||||
return el("button", { class: "back-btn", title: "Back", "aria-label": "Back", onclick }, [
|
||||
el("span", { class: "back-icon", html: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>' }),
|
||||
]);
|
||||
}
|
||||
|
||||
function toolbarBtn(label, action) {
|
||||
return el("button", { class: "tool-btn", text: label, onclick: () => runToolbar(action) });
|
||||
}
|
||||
|
||||
BIN
static/snotes-capture-firefox.xpi
Normal file
BIN
static/snotes-capture-firefox.xpi
Normal file
Binary file not shown.
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