From a0e134756ce1df489fa7239c058f0d8217db9e69 Mon Sep 17 00:00:00 2001 From: h Date: Mon, 19 Oct 2026 03:21:57 +0000 Subject: [PATCH] feat(backend): notes list across chats with the message and chat they belong to, filter by tag, tag counts --- backend/src/api/routers/annotations.py | 22 +++++++- backend/src/utils/read/annotations.py | 73 +++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/backend/src/api/routers/annotations.py b/backend/src/api/routers/annotations.py index c7ed538..b6d4b06 100644 --- a/backend/src/api/routers/annotations.py +++ b/backend/src/api/routers/annotations.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel from utils.read import annotations -from utils.read.models import DEFAULT_LIMIT, AnnotationView, Page +from utils.read.models import DEFAULT_LIMIT, AnnotationView, NoteTag, NoteView, Page router = APIRouter( prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute @@ -42,6 +42,26 @@ async def list_annotations( ) +@router.get("/notes") +async def list_notes( + pool: FromDishka[asyncpg.Pool], + account_id: Annotated[int, Query()], + tag: Annotated[str | None, Query()] = None, + limit: Annotated[int, Query()] = DEFAULT_LIMIT, + offset: Annotated[int, Query()] = 0, +) -> list[NoteView]: + return await annotations.list_notes( + pool, account_id, Page(limit=limit, offset=offset), tag=tag + ) + + +@router.get("/tags") +async def list_tags( + pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()] +) -> list[NoteTag]: + return await annotations.list_tags(pool, account_id) + + @router.post("", status_code=201) async def create_annotation( pool: FromDishka[asyncpg.Pool], body: AnnotationCreate diff --git a/backend/src/utils/read/annotations.py b/backend/src/utils/read/annotations.py index 3510fd4..de08f4c 100644 --- a/backend/src/utils/read/annotations.py +++ b/backend/src/utils/read/annotations.py @@ -1,9 +1,34 @@ +import re +from collections import Counter + import asyncpg -from utils.read.models import AnnotationView, Page +from utils.read.chats import get_chats +from utils.read.models import AnnotationView, NoteTag, NoteView, Page _COLS = "id, account_id, chat_id, message_id, text, created_at, updated_at" +TAG = re.compile(r"(? bool: result = await pool.execute("DELETE FROM annotations WHERE id = $1", annotation_id) return result.endswith("1") + + +def _tag_pattern(tag: str | None) -> str | None: + if tag is None: + return None + return f"(^|[^[:alnum:]_])#{tag}($|[^[:alnum:]_])" + + +async def list_notes( + pool: asyncpg.Pool, account_id: int, page: Page, *, tag: str | None = None +) -> list[NoteView]: + if tag is not None and not WORD.fullmatch(tag): + return [] + rows = await pool.fetch( + _NOTES, account_id, page.capped_limit, page.offset, _tag_pattern(tag) + ) + chats = { + chat.chat_id: chat + for chat in await get_chats( + pool, account_id, list({row["chat_id"] for row in rows}) + ) + } + notes: list[NoteView] = [] + for row in rows: + chat = chats.get(row["chat_id"]) + notes.append( + NoteView( + **dict(row), + chat_title=chat.title if chat else None, + chat_has_avatar=chat.has_avatar if chat else False, + ) + ) + return notes + + +async def list_tags(pool: asyncpg.Pool, account_id: int) -> list[NoteTag]: + rows = await pool.fetch( + "SELECT text FROM annotations WHERE account_id = $1", account_id + ) + counts: Counter[str] = Counter() + for row in rows: + counts.update({tag.lower() for tag in TAG.findall(row["text"])}) + return [ + NoteTag(tag=tag, count=count) + for tag, count in sorted(counts.items(), key=lambda item: (-item[1], item[0])) + ]