455 lines
15 KiB
Python
455 lines
15 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_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"
|
|
|
|
|
|
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["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"
|
|
" 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()]
|
|
|
|
|
|
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
|
|
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"])
|
|
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()]
|