Search showed at most the first 50 matching messages ("1–50 of 50"). The
first page now carries the number of matching conversations, further pages
load as the list is paged through (a conversation spanning two appears once;
oldest-first loads them all, up to 40 pages), and the Unread/Pinned tabs filter
on the server, so their count covers every result.
Bulk Delete / Archive / Restore over selected results acts at once: the rows
leave the list (greyed instead when archived from a search, which still finds
them), the search results are refreshed, and the reading pane returns to
"Select a conversation". Each move landing on the server refreshes them again,
and a hidden row only comes back when no move has landed for 30 seconds — a
long batch no longer brings deleted results back.
Settings → Accounts → Notifications: shorter description.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
82 lines
2.8 KiB
Python
82 lines
2.8 KiB
Python
"""Search endpoint (SPEC §9.1 Search, §8). Results grouped by thread (FR-28)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.schemas import SearchOut, SearchThreadHit
|
|
from app.api.threads import _summary, thread_senders
|
|
from app.db import get_session
|
|
from app.identity.deps import CurrentUser
|
|
from app.models import Thread
|
|
from app.search.factory import get_search_backend
|
|
from app.search.queryparser import to_search_query
|
|
|
|
router = APIRouter(prefix="/search", tags=["search"])
|
|
|
|
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
|
|
|
|
|
@router.get("", response_model=SearchOut)
|
|
async def search(
|
|
user: CurrentUser,
|
|
db: DbSession,
|
|
q: str = Query(""),
|
|
cursor: str | None = None,
|
|
limit: int = Query(50, ge=1, le=200),
|
|
filter: str | None = Query(None, pattern="^(unread|pinned)$"),
|
|
) -> SearchOut:
|
|
"""One page of matching messages, grouped into conversations. ``filter``
|
|
keeps conversations with unread mail / pinned ones (the list's Unread and
|
|
Pinned tabs). The first page also says how many conversations match in
|
|
all, where the search engine can count them."""
|
|
if not q.strip():
|
|
return SearchOut(threads=[], cursor=None)
|
|
|
|
backend = get_search_backend()
|
|
query = to_search_query(q, user.id)
|
|
hits, next_cursor = await backend.query(query, limit=limit, cursor=cursor)
|
|
total: int | None = None
|
|
count = getattr(backend, "count_threads", None)
|
|
if cursor is None and count is not None:
|
|
total = await count(query, thread_filter=filter)
|
|
|
|
# Group hits by thread, preserving order and keeping the first highlight.
|
|
order: list[uuid.UUID] = []
|
|
highlights: dict[uuid.UUID, dict[str, str]] = {}
|
|
for hit in hits:
|
|
if hit.thread_id not in highlights:
|
|
order.append(hit.thread_id)
|
|
highlights[hit.thread_id] = hit.highlights
|
|
elif hit.highlights and not highlights[hit.thread_id]:
|
|
highlights[hit.thread_id] = hit.highlights
|
|
|
|
if not order:
|
|
return SearchOut(threads=[], cursor=next_cursor, total=total)
|
|
|
|
threads = {
|
|
t.id: t
|
|
for t in await db.scalars(
|
|
select(Thread).where(Thread.id.in_(order), Thread.user_id == user.id)
|
|
)
|
|
}
|
|
senders = await thread_senders(db, user.id, list(threads))
|
|
results = [
|
|
SearchThreadHit(
|
|
thread=_summary(threads[tid], senders=senders.get(tid)),
|
|
highlights=highlights.get(tid, {}),
|
|
)
|
|
for tid in order
|
|
if tid in threads
|
|
and (
|
|
filter is None
|
|
or (threads[tid].unread_count > 0 if filter == "unread" else threads[tid].pinned)
|
|
)
|
|
]
|
|
return SearchOut(threads=results, cursor=next_cursor, total=total)
|