Files
snotes/backend/notes.py
2026-09-23 19:10:01 -07:00

415 lines
13 KiB
Python

from __future__ import annotations
import re
import uuid
import aiosqlite
from backend import actions
from backend.config import settings
from backend.database import get_user_id
TAG_RE = re.compile(r"#[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]:
seen: list[str] = []
for match in TAG_RE.finditer(text):
tag = match.group(0)[1:].lower()
if tag not in seen:
seen.append(tag)
return seen
def extract_title(text: str) -> str:
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("# "):
title = stripped[2:].strip()
else:
title = stripped
if title:
return title[:200]
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_path(username: str, note_id: str) -> str:
return f"{username}/{note_id}.md"
async def ensure_user_dir(username: str) -> None:
import os
os.makedirs(os.path.join(settings.data_dir, username), exist_ok=True)
async def write_note_file(username: str, note_id: str, body: str) -> str:
from pathlib import Path
await ensure_user_dir(username)
rel = note_path(username, note_id)
full = Path(settings.data_dir) / rel
full.write_text(body, encoding="utf-8")
return rel
async def read_note_file(rel_path: str) -> str:
from pathlib import Path
full = Path(settings.data_dir) / rel_path
if not full.is_file():
raise FileNotFoundError(rel_path)
return full.read_text(encoding="utf-8")
async def delete_note_file(rel_path: str) -> None:
from pathlib import Path
full = Path(settings.data_dir) / rel_path
if full.is_file():
full.unlink()
def new_note_id() -> str:
return uuid.uuid4().hex
async def store_attachment(username: str, original_name: str, data: bytes) -> str:
from pathlib import Path
ext = re.sub(r"[^A-Za-z0-9.]", "", Path(original_name or "").suffix)[:16]
filename = new_note_id() + ext
full = Path(settings.data_dir) / username / "attachments" / filename
full.parent.mkdir(parents=True, exist_ok=True)
full.write_bytes(data)
return filename
# ---------------------------------------------------------------------------
# Note CRUD (metadata + tags + FTS)
# ---------------------------------------------------------------------------
async def create_note(
username: str,
body: str,
ip: str | None = None,
ua: str | None = None,
) -> dict:
body = await actions.process_actions(body)
title = extract_title(body)
tags = extract_tags(body)
note_id = new_note_id()
rel = await write_note_file(username, note_id, body)
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
user_id = await get_user_id(db, username)
if user_id is None:
raise ValueError(f"unknown user: {username}")
await db.execute(
"INSERT INTO notes (id, user_id, title, path) VALUES (?, ?, ?, ?)",
(note_id, user_id, title, rel),
)
await _set_tags(db, note_id, tags)
await _index_note(db, note_id, user_id, title, body)
await _record_history(db, note_id, user_id, "create", ip, ua)
await db.commit()
row = await db.execute(
"SELECT n.id, n.title, n.path, n.created_at, n.updated_at,"
" u.name AS username"
" FROM notes n JOIN users u ON u.id = n.user_id WHERE n.id = ?",
(note_id,),
)
note = dict(await row.fetchone())
note["tags"] = tags
note["body"] = body
return note
async def update_note(
note_id: str,
body: str,
ip: str | None = None,
ua: str | None = None,
username: str | None = None,
) -> dict | None:
body = await actions.process_actions(body)
title = extract_title(body)
tags = extract_tags(body)
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
row = await db.execute(
"SELECT n.id, n.user_id, n.path, u.name AS username"
" FROM notes n JOIN users u ON u.id = n.user_id WHERE n.id = ?",
(note_id,),
)
row = await row.fetchone()
if row is None:
return None
new_username = username or row["username"]
user_id = row["user_id"]
rel = row["path"]
if new_username != row["username"]:
new_user_id = await get_user_id(db, new_username)
if new_user_id is None:
raise ValueError(f"unknown user: {new_username}")
await delete_note_file(row["path"])
rel = await write_note_file(new_username, note_id, body)
user_id = new_user_id
await db.execute(
"UPDATE notes SET title = ?, path = ?, user_id = ?,"
" updated_at = datetime('now') WHERE id = ?",
(title, rel, user_id, note_id),
)
else:
await write_note_file(row["username"], note_id, body)
await db.execute(
"UPDATE notes SET title = ?, updated_at = datetime('now') WHERE id = ?",
(title, note_id),
)
await _set_tags(db, note_id, tags)
await _index_note(db, note_id, user_id, title, body)
await _record_history(db, note_id, user_id, "update", ip, ua)
await db.commit()
return await get_note(note_id)
async def get_note(note_id: str) -> dict | None:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
row = await db.execute(
"SELECT n.id, n.title, n.path, n.created_at, n.updated_at,"
" u.name AS username"
" FROM notes n JOIN users u ON u.id = n.user_id"
" WHERE n.id = ? AND n.deleted_at IS NULL",
(note_id,),
)
row = await row.fetchone()
if row is None:
return None
note = dict(row)
note["tags"] = await get_note_tags(note_id)
note["body"] = await read_note_file(note["path"])
return note
async def delete_note(
note_id: str,
ip: str | None = None,
ua: str | None = None,
) -> bool:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
row = await db.execute(
"SELECT user_id, path FROM notes WHERE id = ? AND deleted_at IS NULL",
(note_id,),
)
row = await row.fetchone()
if row is None:
return False
await delete_note_file(row["path"])
await db.execute(
"UPDATE notes SET deleted_at = datetime('now') WHERE id = ?",
(note_id,),
)
await _record_history(db, note_id, row["user_id"], "delete", ip, ua)
await db.commit()
return True
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, 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()]
await _attach_meta(notes)
return notes
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, 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()]
await _attach_meta(notes)
return notes
async def get_note_tags(note_id: str) -> list[str]:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT t.name FROM tags t JOIN note_tags nt ON nt.tag_id = t.id"
" WHERE nt.note_id = ? ORDER BY t.name",
(note_id,),
)
return [r["name"] for r in await rows.fetchall()]
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.pop("path", None)
async def tag_counts() -> list[dict]:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
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"
)
return [dict(r) for r in await rows.fetchall()]
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),
)
notes = [dict(r) for r in await rows.fetchall()]
for note in notes:
note["tags"] = await get_note_tags(note["id"])
note.pop("body", None)
return notes
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _set_tags(db: aiosqlite.Connection, note_id: str, tags: list[str]) -> None:
await db.execute("DELETE FROM note_tags WHERE note_id = ?", (note_id,))
for tag in tags:
await db.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,))
row = await db.execute("SELECT id FROM tags WHERE name = ?", (tag,))
row = await row.fetchone()
await db.execute(
"INSERT OR IGNORE INTO note_tags (note_id, tag_id) VALUES (?, ?)",
(note_id, row["id"]),
)
async def _index_note(
db: aiosqlite.Connection,
note_id: str,
user_id: int,
title: str,
body: str,
) -> None:
await db.execute("DELETE FROM notes_fts WHERE note_id = ?", (note_id,))
await db.execute(
"INSERT INTO notes_fts (note_id, user_id, title, body) VALUES (?, ?, ?, ?)",
(note_id, user_id, title, body),
)
async def _record_history(
db: aiosqlite.Connection,
note_id: str,
user_id: int,
action: str,
ip: str | None,
ua: str | None,
) -> None:
await db.execute(
"INSERT INTO history (note_id, user_id, action, ip, ua) VALUES (?, ?, ?, ?, ?)",
(note_id, user_id, action, ip, ua),
)
async def get_homepage_stats() -> dict:
from pathlib import Path
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
async def count(query: str) -> int:
row = await db.execute(query)
row = await row.fetchone()
return row[0]
total_notes = await count("SELECT COUNT(*) FROM notes WHERE deleted_at IS NULL")
actions = await count("SELECT COUNT(*) FROM history")
users = await count("SELECT COUNT(*) FROM users")
attachments = 0
data_root = Path(settings.data_dir)
if data_root.is_dir():
for attach_dir in data_root.rglob("attachments"):
if attach_dir.is_dir():
attachments += sum(1 for f in attach_dir.iterdir() if f.is_file())
return {
"total_notes": total_notes,
"attachments": attachments,
"actions": actions,
"users": users,
}
async def get_history(note_id: str) -> list[dict]:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT h.id, h.action, h.at, h.ip, h.ua, u.name AS username"
" FROM history h JOIN users u ON u.id = h.user_id"
" WHERE h.note_id = ? ORDER BY h.at DESC",
(note_id,),
)
return [dict(r) for r in await rows.fetchall()]