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