"""Entry endpoints.""" import logging from typing import Optional import httpx from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.api.deps import get_db_session, get_user_id from app.config import CRAWLER_URL from app.models import Entry, Feed log = logging.getLogger(__name__) router = APIRouter(prefix="/api/entries", tags=["entries"]) # ── Schemas ────────────────────────────────────────────────────────────── class FeedRef(BaseModel): id: int title: str class Config: from_attributes = True class EntryOut(BaseModel): id: int title: str | None = None url: str | None = None content: str | None = None full_content: str | None = None author: str | None = None published_at: str | None = None status: str = "unread" starred: bool = False reading_time: int = 1 feed: FeedRef | None = None class Config: from_attributes = True @classmethod def from_entry(cls, entry: Entry) -> "EntryOut": # Use full_content if available, otherwise RSS content best_content = entry.full_content if entry.full_content else entry.content return cls( id=entry.id, title=entry.title, url=entry.url, content=best_content, full_content=entry.full_content, author=entry.author, published_at=entry.published_at.isoformat() if entry.published_at else None, status=entry.status, starred=entry.starred, reading_time=entry.reading_time, feed=FeedRef(id=entry.feed.id, title=entry.feed.title) if entry.feed else None, ) class EntryListOut(BaseModel): total: int entries: list[EntryOut] class EntryBulkUpdate(BaseModel): entry_ids: list[int] status: str # ── Routes ─────────────────────────────────────────────────────────────── @router.get("", response_model=EntryListOut) async def list_entries( status: Optional[str] = Query(None), starred: Optional[bool] = Query(None), feed_id: Optional[int] = Query(None), category_id: Optional[int] = Query(None), limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), direction: str = Query("desc"), order: str = Query("published_at"), user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): query = select(Entry).where(Entry.user_id == user_id) count_query = select(func.count(Entry.id)).where(Entry.user_id == user_id) if status: query = query.where(Entry.status == status) count_query = count_query.where(Entry.status == status) if starred is not None: query = query.where(Entry.starred == starred) count_query = count_query.where(Entry.starred == starred) if feed_id is not None: query = query.where(Entry.feed_id == feed_id) count_query = count_query.where(Entry.feed_id == feed_id) if category_id is not None: # Join through feed to filter by category query = query.join(Feed, Entry.feed_id == Feed.id).where(Feed.category_id == category_id) count_query = count_query.join(Feed, Entry.feed_id == Feed.id).where(Feed.category_id == category_id) # Ordering order_col = Entry.published_at if order == "published_at" else Entry.created_at if direction == "asc": query = query.order_by(order_col.asc().nullslast()) else: query = query.order_by(order_col.desc().nullsfirst()) # Total count total_result = await db.execute(count_query) total = total_result.scalar() or 0 # Paginate query = query.options(selectinload(Entry.feed)).offset(offset).limit(limit) result = await db.execute(query) entries = result.scalars().all() return EntryListOut( total=total, entries=[EntryOut.from_entry(e) for e in entries], ) @router.put("") async def bulk_update_entries( body: EntryBulkUpdate, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): if body.status not in ("read", "unread"): raise HTTPException(status_code=400, detail="Status must be 'read' or 'unread'") await db.execute( update(Entry) .where(Entry.user_id == user_id, Entry.id.in_(body.entry_ids)) .values(status=body.status) ) await db.commit() return {"ok": True} class MarkAllReadBody(BaseModel): feed_id: int | None = None category_id: int | None = None @router.put("/mark-all-read") async def mark_all_read( body: MarkAllReadBody, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): """Mark ALL unread entries as read, optionally filtered by feed or category.""" q = update(Entry).where(Entry.user_id == user_id, Entry.status == "unread") if body.feed_id: q = q.where(Entry.feed_id == body.feed_id) elif body.category_id: from app.models import Feed feed_ids_q = select(Feed.id).where(Feed.category_id == body.category_id, Feed.user_id == user_id) q = q.where(Entry.feed_id.in_(feed_ids_q)) result = await db.execute(q.values(status="read")) await db.commit() return {"ok": True, "marked": result.rowcount} @router.get("/{entry_id}", response_model=EntryOut) async def get_entry( entry_id: int, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): result = await db.execute( select(Entry) .options(selectinload(Entry.feed)) .where(Entry.id == entry_id, Entry.user_id == user_id) ) entry = result.scalar_one_or_none() if not entry: raise HTTPException(status_code=404, detail="Entry not found") return EntryOut.from_entry(entry) @router.put("/{entry_id}/bookmark") async def toggle_bookmark( entry_id: int, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): result = await db.execute( select(Entry).where(Entry.id == entry_id, Entry.user_id == user_id) ) entry = result.scalar_one_or_none() if not entry: raise HTTPException(status_code=404, detail="Entry not found") entry.starred = not entry.starred await db.commit() return {"starred": entry.starred} @router.post("/{entry_id}/fetch-full-content", response_model=EntryOut) async def fetch_full_content( entry_id: int, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db_session), ): result = await db.execute( select(Entry) .options(selectinload(Entry.feed)) .where(Entry.id == entry_id, Entry.user_id == user_id) ) entry = result.scalar_one_or_none() if not entry: raise HTTPException(status_code=404, detail="Entry not found") if not entry.url: raise HTTPException(status_code=400, detail="Entry has no URL to crawl") try: async with httpx.AsyncClient(timeout=60) as client: resp = await client.post( f"{CRAWLER_URL}/crawl", json={"url": entry.url}, ) resp.raise_for_status() data = resp.json() except httpx.HTTPError as e: log.error("Crawler error for entry %d: %s", entry_id, e) raise HTTPException(status_code=502, detail="Failed to fetch full content") # Prefer readable_html (Readability-extracted clean article with images) readable = data.get("readable_html", "") full_text = data.get("text", "") if readable: entry.full_content = readable elif full_text: paragraphs = [p.strip() for p in full_text.split("\n\n") if p.strip()] if not paragraphs: paragraphs = [p.strip() for p in full_text.split("\n") if p.strip()] entry.full_content = "\n".join(f"
{p}
" for p in paragraphs) else: entry.full_content = "" # Recalculate reading time from plain text if full_text: word_count = len(full_text.split()) entry.reading_time = max(1, word_count // 200) await db.commit() await db.refresh(entry) return EntryOut.from_entry(entry)