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

View File

@@ -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,

View File

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

View File

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