novela/containers/novela/routers/search.py
Ivo Oskamp e4d2e2c636 DB-stored books, full-text search, backup restore, and AO3 scraper
- DB-stored books (Fase 1–6): chapters and images stored in PostgreSQL; grabber writes to DB, EPUB→DB conversion, DB→EPUB export, FTS search page (/search)
- Chapter editor: Monaco editor supports DB-stored books; inline title editing
- Grabber: DB/EPUB storage toggle on Convert page
- Backup: restore from Dropbox snapshot (browse snapshots, restore individual or selected files)
- AO3 scraper: initial implementation
- Changelog: v0.1.2 and v0.1.3 entries added to changelog.py and changelog.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:13:08 +02:00

64 lines
2.0 KiB
Python

"""search.py — Full-text search over DB-stored book chapters."""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from db import get_db_conn
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@router.get("/search", response_class=HTMLResponse)
async def search_page(request: Request):
return templates.TemplateResponse(request, "search.html", {"active": "search"})
@router.get("/api/search")
async def api_search(q: str = ""):
q = q.strip()
if not q or len(q) > 500:
return JSONResponse([])
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
l.filename,
l.title,
l.author,
bc.chapter_index,
bc.title AS chapter_title,
ts_headline(
'simple', bc.content,
plainto_tsquery('simple', %s),
'MaxFragments=1, MaxWords=25, MinWords=8, StartSel=<mark>, StopSel=</mark>'
) AS snippet,
ts_rank(bc.content_tsv, plainto_tsquery('simple', %s)) AS rank
FROM book_chapters bc
JOIN library l ON l.filename = bc.filename
WHERE (bc.content_tsv @@ plainto_tsquery('simple', %s)
OR LOWER(bc.title) LIKE LOWER('%%' || %s || '%%'))
AND NOT l.archived
ORDER BY rank DESC, bc.chapter_index ASC
LIMIT 30
""",
(q, q, q, q),
)
rows = cur.fetchall()
results = [
{
"filename": r[0],
"title": r[1] or "",
"author": r[2] or "",
"chapter_index": r[3],
"chapter_title": r[4] or "",
"snippet": r[5] or "",
"rank": float(r[6]),
}
for r in rows
]
return JSONResponse(results)