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()]