first pass?
This commit is contained in:
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
41
backend/auth.py
Normal file
41
backend/auth.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
|
||||
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
MAX_AGE = 60 * 60 * 24 * 30 # 30 days
|
||||
|
||||
|
||||
def _serializer() -> URLSafeTimedSerializer:
|
||||
return URLSafeTimedSerializer(settings.secret_key, salt="snotes-auth")
|
||||
|
||||
|
||||
def is_lan(ip: str | None) -> bool:
|
||||
if not ip:
|
||||
return False
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
for cidr in settings.cidr_list:
|
||||
if addr in ipaddress.ip_network(cidr, strict=False):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def remote_password_ok(password: str) -> bool:
|
||||
return bool(settings.remote_pass) and password == settings.remote_pass
|
||||
|
||||
|
||||
def issue_auth_cookie() -> str:
|
||||
return _serializer().dumps("authed")
|
||||
|
||||
|
||||
def check_auth_cookie(token: str) -> bool:
|
||||
try:
|
||||
return _serializer().loads(token, max_age=MAX_AGE) == "authed"
|
||||
except BadSignature:
|
||||
return False
|
||||
48
backend/config.py
Normal file
48
backend/config.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# Filesystem mount where notes live (e.g. /mnt/aura/snotes)
|
||||
data_dir: str = "data/notes"
|
||||
|
||||
# SQLite metadata + search index (local disk)
|
||||
database_path: str = "snotes.db"
|
||||
|
||||
# Fixed list of usernames, comma-separated (seeded at startup)
|
||||
users: str = "schmeeve,ilya"
|
||||
|
||||
# Name of the shared space everyone can see
|
||||
common_space: str = "common"
|
||||
|
||||
# Remote-access password (empty = no remote auth)
|
||||
remote_pass: str = ""
|
||||
|
||||
# Secret used to sign session cookies
|
||||
secret_key: str = "change-me-in-production"
|
||||
|
||||
# Subnet(s) considered "local" and allowed in without a password
|
||||
lan_cidrs: str = "192.168.1.0/24"
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
|
||||
log_level: str = "INFO"
|
||||
|
||||
@property
|
||||
def user_list(self) -> list[str]:
|
||||
return [u.strip() for u in self.users.split(",") if u.strip()]
|
||||
|
||||
@property
|
||||
def cidr_list(self) -> list[str]:
|
||||
return [c.strip() for c in self.lan_cidrs.split(",") if c.strip()]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
88
backend/database.py
Normal file
88
backend/database.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import aiosqlite
|
||||
import structlog
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT DEFAULT '',
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_tags (
|
||||
note_id TEXT NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (note_id, tag_id),
|
||||
FOREIGN KEY(note_id) REFERENCES notes(id),
|
||||
FOREIGN KEY(tag_id) REFERENCES tags(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
at TEXT DEFAULT (datetime('now')),
|
||||
ip TEXT,
|
||||
ua TEXT,
|
||||
FOREIGN KEY(note_id) REFERENCES notes(id),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
||||
note_id UNINDEXED,
|
||||
user_id UNINDEXED,
|
||||
title,
|
||||
body
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
async with aiosqlite.connect(settings.database_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.executescript(SCHEMA)
|
||||
await db.commit()
|
||||
await seed_users()
|
||||
logger.info("database_initialized", path=settings.database_path)
|
||||
|
||||
|
||||
async def seed_users() -> None:
|
||||
async with aiosqlite.connect(settings.database_path) as db:
|
||||
for name in settings.user_list:
|
||||
await db.execute("INSERT OR IGNORE INTO users (name) VALUES (?)", (name,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_user_id(db: aiosqlite.Connection, name: str) -> int | None:
|
||||
row = await db.execute("SELECT id FROM users WHERE name = ?", (name,))
|
||||
row = await row.fetchone()
|
||||
return row["id"] if row else None
|
||||
|
||||
|
||||
async def list_users() -> list[dict]:
|
||||
async with aiosqlite.connect(settings.database_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
rows = await db.execute("SELECT id, name FROM users ORDER BY name")
|
||||
return [dict(r) for r in await rows.fetchall()]
|
||||
188
backend/main.py
Normal file
188
backend/main.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import structlog
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from backend import auth, database, notes
|
||||
from backend.config import settings
|
||||
from backend.models import LoginRequest, NoteCreate, NoteUpdate, UserSelect
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await database.init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="snotes", lifespan=lifespan)
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str | None:
|
||||
fwd = request.headers.get("x-forwarded-for")
|
||||
if fwd:
|
||||
return fwd.split(",")[0].strip()
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
def client_ua(request: Request) -> str | None:
|
||||
return request.headers.get("user-agent")
|
||||
|
||||
|
||||
def require_access(request: Request) -> None:
|
||||
ip = client_ip(request)
|
||||
if auth.is_lan(ip):
|
||||
return
|
||||
token = request.cookies.get("snotes_auth")
|
||||
if token and auth.check_auth_cookie(token):
|
||||
return
|
||||
raise HTTPException(status_code=401, detail="authentication required")
|
||||
|
||||
|
||||
def current_user(request: Request) -> str:
|
||||
username = request.cookies.get("snotes_user")
|
||||
return username or settings.user_list[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPA + static
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def index() -> FileResponse:
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def favicon() -> FileResponse:
|
||||
return FileResponse("static/snotes-main.png")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth / users
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/me")
|
||||
async def me(request: Request) -> dict:
|
||||
ip = client_ip(request)
|
||||
return {
|
||||
"user": current_user(request),
|
||||
"local": auth.is_lan(ip),
|
||||
"authed": not auth.is_lan(ip)
|
||||
and bool(
|
||||
request.cookies.get("snotes_auth")
|
||||
and auth.check_auth_cookie(request.cookies["snotes_auth"])
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/users")
|
||||
async def users(_: None = Depends(require_access)) -> list[dict]:
|
||||
return await database.list_users()
|
||||
|
||||
|
||||
@app.post("/api/users/select")
|
||||
async def select_user(body: UserSelect, _: None = Depends(require_access)) -> Response:
|
||||
if body.username not in settings.user_list and body.username != settings.common_space:
|
||||
raise HTTPException(status_code=404, detail="unknown user")
|
||||
resp = JSONResponse({"username": body.username})
|
||||
resp.set_cookie("snotes_user", body.username, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def login(body: LoginRequest) -> Response:
|
||||
if not auth.remote_password_ok(body.password):
|
||||
raise HTTPException(status_code=401, detail="invalid password")
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.set_cookie("snotes_auth", auth.issue_auth_cookie(), httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.post("/api/notes")
|
||||
async def create_note(body: NoteCreate, request: Request, _: None = Depends(require_access)):
|
||||
username = current_user(request)
|
||||
if not body.body.strip():
|
||||
raise HTTPException(status_code=400, detail="empty note")
|
||||
try:
|
||||
note = await notes.create_note(
|
||||
username, body.body, ip=client_ip(request), ua=client_ua(request)
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return note
|
||||
|
||||
|
||||
@app.get("/api/notes")
|
||||
async def list_notes(request: Request, _: None = Depends(require_access)):
|
||||
username = current_user(request)
|
||||
return await notes.list_notes(username)
|
||||
|
||||
|
||||
@app.get("/api/notes/recent")
|
||||
async def recent(_: None = Depends(require_access)):
|
||||
return await notes.list_recent()
|
||||
|
||||
|
||||
@app.get("/api/notes/{note_id}")
|
||||
async def get_note(note_id: str, _: None = Depends(require_access)):
|
||||
note = await notes.get_note(note_id)
|
||||
if note is None:
|
||||
raise HTTPException(status_code=404, detail="note not found")
|
||||
return note
|
||||
|
||||
|
||||
@app.put("/api/notes/{note_id}")
|
||||
async def update_note(
|
||||
note_id: str, body: NoteUpdate, request: Request, _: None = Depends(require_access)
|
||||
):
|
||||
note = await notes.update_note(note_id, body.body, ip=client_ip(request), ua=client_ua(request))
|
||||
if note is None:
|
||||
raise HTTPException(status_code=404, detail="note not found")
|
||||
return note
|
||||
|
||||
|
||||
@app.delete("/api/notes/{note_id}")
|
||||
async def delete_note(note_id: str, request: Request, _: None = Depends(require_access)):
|
||||
ok = await notes.delete_note(note_id, ip=client_ip(request), ua=client_ua(request))
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="note not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/notes/{note_id}/history")
|
||||
async def note_history(note_id: str, _: None = Depends(require_access)):
|
||||
return await notes.get_history(note_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tags / search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/tags")
|
||||
async def tags(_: None = Depends(require_access)):
|
||||
return await notes.tag_counts()
|
||||
|
||||
|
||||
@app.get("/api/search")
|
||||
async def search(q: str, _: None = Depends(require_access)):
|
||||
if not q.strip():
|
||||
return []
|
||||
return await notes.search_notes(q)
|
||||
|
||||
|
||||
# Mount static assets last so /api routes take precedence
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
19
backend/models.py
Normal file
19
backend/models.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserSelect(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
class NoteCreate(BaseModel):
|
||||
body: str
|
||||
|
||||
|
||||
class NoteUpdate(BaseModel):
|
||||
body: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
password: str
|
||||
326
backend/notes.py
Normal file
326
backend/notes.py
Normal file
@@ -0,0 +1,326 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from backend.config import settings
|
||||
from backend.database import get_user_id
|
||||
|
||||
TAG_RE = re.compile(r"#[A-Za-z0-9_-]+")
|
||||
|
||||
|
||||
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_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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Note CRUD (metadata + tags + FTS)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def create_note(
|
||||
username: str,
|
||||
body: str,
|
||||
ip: str | None = None,
|
||||
ua: str | None = None,
|
||||
) -> dict:
|
||||
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,
|
||||
) -> dict | None:
|
||||
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
|
||||
|
||||
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, row["user_id"], title, body)
|
||||
await _record_history(db, note_id, row["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, 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()]
|
||||
for note in notes:
|
||||
note["tags"] = await get_note_tags(note["id"])
|
||||
return notes
|
||||
|
||||
|
||||
async def list_recent(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, u.name AS username"
|
||||
" FROM notes n JOIN users u ON u.id = n.user_id"
|
||||
" WHERE n.deleted_at IS NULL"
|
||||
" ORDER BY n.updated_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
notes = [dict(r) for r in await rows.fetchall()]
|
||||
for note in notes:
|
||||
note["tags"] = await get_note_tags(note["id"])
|
||||
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 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_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()]
|
||||
Reference in New Issue
Block a user