import json from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, Query from pydantic import BaseModel from app.auth import require_user from app.database import get_db from app.reader import ( parse_epub, parse_text, parse_pdf, count_words, extract_rfc_number, fetch_and_parse_rfc, fetch_and_parse_wikipedia, extract_wikipedia_title_and_lang, ) router = APIRouter(tags=["Reader & Bookshelf"]) class BookCreateRequest(BaseModel): title: Optional[str] = "Untitled" author: Optional[str] = "" content: Optional[str] = "" source_type: Optional[str] = "text" class BookProgressRequest(BaseModel): current_chapter: Optional[int] = None current_position: Optional[float] = None chapter: Optional[int] = None position: Optional[float] = None class RfcFetchRequest(BaseModel): rfc: str force: Optional[bool] = False class WikipediaFetchRequest(BaseModel): query: Optional[str] = "" lang: Optional[str] = "simple" time_budget: Optional[str] = "10-15" topic: Optional[str] = "random" force: Optional[bool] = False @router.get("/api/books") async def api_get_books(user: dict = Depends(require_user)): """ Lists books for current user (id, title, author, source_type, chapters_count, total_words, current_chapter, current_position, created_at, updated_at). Excludes full chapters_json for efficiency. """ with get_db() as conn: rows = conn.execute( """ SELECT id, user_id, title, author, source_type, json_array_length(chapters_json) AS chapters_count, total_words, current_chapter, current_position, cover_url, created_at, updated_at FROM books WHERE user_id = ? ORDER BY updated_at DESC, id DESC """, (user["id"],), ).fetchall() return [dict(r) for r in rows] @router.post("/api/books") async def api_create_book( request: Request, user: dict = Depends(require_user), ): """ Creates or uploads a book. Accepts: - JSON: {"title": "...", "author": "...", "content": "...", "source_type": "text"} - Multipart file upload: file (UploadFile for .epub, .txt, .md) and optional title, author """ content_type = request.headers.get("content-type", "") title = "Untitled" author = "" source_type = "text" chapters = [] if "multipart/form-data" in content_type: form = await request.form() file_obj = form.get("file") if not file_obj or not hasattr(file_obj, "filename") or not file_obj.filename: raise HTTPException(status_code=400, detail="No file uploaded") filename = file_obj.filename file_bytes = await file_obj.read() if not file_bytes: raise HTTPException(status_code=400, detail="Uploaded file is empty") form_title = (form.get("title") or "").strip() form_author = (form.get("author") or "").strip() stem = Path(filename).stem lower_name = filename.lower() try: if lower_name.endswith(".epub"): source_type = "epub" parsed_title, parsed_author, chapters = parse_epub(file_bytes) title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem author = form_author or parsed_author elif lower_name.endswith(".pdf"): source_type = "pdf" parsed_title, parsed_author, chapters = parse_pdf(file_bytes, filename=filename) title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem author = form_author or parsed_author elif lower_name.endswith(".md") or lower_name.endswith(".txt"): source_type = "md" if lower_name.endswith(".md") else "txt" text_str = file_bytes.decode("utf-8", errors="replace") parsed_title, parsed_author, chapters = parse_text(text_str, title=form_title or "Untitled", author=form_author) title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem author = form_author or parsed_author else: source_type = "text" text_str = file_bytes.decode("utf-8", errors="replace") parsed_title, parsed_author, chapters = parse_text(text_str, title=form_title or "Untitled", author=form_author) title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem author = form_author or parsed_author except ValueError as ve: raise HTTPException(status_code=400, detail=str(ve)) else: try: body = await request.json() except Exception: raise HTTPException(status_code=400, detail="Invalid JSON or missing body") raw_content = body.get("content") or "" title_in = (body.get("title") or "").strip() author_in = (body.get("author") or "").strip() source_type = body.get("source_type") or "text" if not raw_content and not title_in: raise HTTPException(status_code=400, detail="Book content is required") parsed_title, parsed_author, chapters = parse_text( raw_content, title=title_in or "Untitled", author=author_in ) title = title_in or parsed_title author = author_in or parsed_author total_words = sum(count_words(ch.get("content", "")) for ch in chapters) chapters_json = json.dumps(chapters, ensure_ascii=False) with get_db() as conn: cursor = conn.execute( """ INSERT INTO books ( user_id, title, author, source_type, chapters_json, current_chapter, current_position, total_words ) VALUES (?, ?, ?, ?, ?, 0, 0.0, ?) """, (user["id"], title, author, source_type, chapters_json, total_words), ) new_id = cursor.lastrowid row = conn.execute( """ SELECT id, user_id, title, author, source_type, chapters_json, json_array_length(chapters_json) AS chapters_count, current_chapter, current_position, total_words, cover_url, created_at, updated_at FROM books WHERE id = ? """, (new_id,), ).fetchone() res = dict(row) res["chapters"] = chapters return res @router.post("/api/books/fetch-rfc") async def api_fetch_rfc( req: RfcFetchRequest, user: dict = Depends(require_user), ): """ Fetches an RFC specification by number or URL from RFC Editor & Datatracker, parses sections into readable chapters, and saves to the user's bookshelf. If the RFC already exists on user's shelf and force=False, returns the existing book. """ raw_query = (req.rfc or "").strip() if not raw_query: raise HTTPException(status_code=400, detail="Укажите номер или URL спецификации RFC") try: rfc_num = extract_rfc_number(raw_query) except ValueError as ve: raise HTTPException(status_code=400, detail=str(ve)) # Check if user already loaded this RFC if not req.force: with get_db() as conn: existing = conn.execute( """ SELECT id, user_id, title, author, source_type, chapters_json, json_array_length(chapters_json) AS chapters_count, current_chapter, current_position, total_words, cover_url, created_at, updated_at FROM books WHERE user_id = ? AND source_type = 'rfc' AND (title LIKE ? OR title LIKE ?) ORDER BY id DESC LIMIT 1 """, (user["id"], f"RFC {rfc_num}:%", f"RFC {rfc_num}"), ).fetchone() if existing: res = dict(existing) try: res["chapters"] = json.loads(res["chapters_json"]) except Exception: res["chapters"] = [] res["already_existed"] = True return res try: import sys main_mod = sys.modules.get("app.main") rfc_parser = getattr(main_mod, "fetch_and_parse_rfc", fetch_and_parse_rfc) if main_mod else fetch_and_parse_rfc title, author, chapters, rfc_num, total_words = rfc_parser(raw_query) except ValueError as ve: raise HTTPException(status_code=400, detail=str(ve)) except Exception as e: raise HTTPException(status_code=500, detail=f"Ошибка загрузки RFC: {e}") chapters_json = json.dumps(chapters, ensure_ascii=False) with get_db() as conn: cursor = conn.execute( """ INSERT INTO books ( user_id, title, author, source_type, chapters_json, current_chapter, current_position, total_words ) VALUES (?, ?, ?, 'rfc', ?, 0, 0.0, ?) """, (user["id"], title, author, chapters_json, total_words), ) new_id = cursor.lastrowid row = conn.execute( """ SELECT id, user_id, title, author, source_type, chapters_json, json_array_length(chapters_json) AS chapters_count, current_chapter, current_position, total_words, cover_url, created_at, updated_at FROM books WHERE id = ? """, (new_id,), ).fetchone() res = dict(row) res["chapters"] = chapters res["already_existed"] = False return res @router.post("/api/books/fetch-wikipedia") async def api_fetch_wikipedia( req: WikipediaFetchRequest, user: dict = Depends(require_user), ): """ Fetches a Wikipedia article (by title/URL or random topic & duration), parses sections into readable chapters with media and tables, and stores autonomously in the user's SQLite bookshelf (source_type = 'wikipedia'). """ raw_query = (req.query or "").strip() target_lang = (req.lang or "simple").strip() time_budget = (req.time_budget or "10-15").strip() topic = (req.topic or "random").strip() # If specific title or URL provided and force is False, check for existing book if raw_query and not req.force: title_cand, lang_cand = extract_wikipedia_title_and_lang(raw_query, default_lang=target_lang) search_title = title_cand.replace("_", " ") with get_db() as conn: existing = conn.execute( """ SELECT id, user_id, title, author, source_type, chapters_json, json_array_length(chapters_json) AS chapters_count, current_chapter, current_position, total_words, cover_url, created_at, updated_at FROM books WHERE user_id = ? AND source_type = 'wikipedia' AND lower(title) = lower(?) ORDER BY id DESC LIMIT 1 """, (user["id"], search_title), ).fetchone() if existing: res = dict(existing) try: res["chapters"] = json.loads(res["chapters_json"]) except Exception: res["chapters"] = [] res["already_existed"] = True return res try: title, author, chapters, cover_url, total_words, wiki_url = fetch_and_parse_wikipedia( title_or_url=raw_query, lang=target_lang, time_budget=time_budget, topic=topic, ) except ValueError as ve: raise HTTPException(status_code=400, detail=str(ve)) except Exception as e: raise HTTPException(status_code=500, detail=f"Ошибка загрузки статьи Wikipedia: {e}") # Check again by resolved title if not forcing if not req.force: with get_db() as conn: existing = conn.execute( """ SELECT id, user_id, title, author, source_type, chapters_json, json_array_length(chapters_json) AS chapters_count, current_chapter, current_position, total_words, cover_url, created_at, updated_at FROM books WHERE user_id = ? AND source_type = 'wikipedia' AND lower(title) = lower(?) ORDER BY id DESC LIMIT 1 """, (user["id"], title), ).fetchone() if existing: res = dict(existing) try: res["chapters"] = json.loads(res["chapters_json"]) except Exception: res["chapters"] = [] res["already_existed"] = True return res chapters_json = json.dumps(chapters, ensure_ascii=False) with get_db() as conn: cursor = conn.execute( """ INSERT INTO books ( user_id, title, author, source_type, chapters_json, current_chapter, current_position, total_words, cover_url ) VALUES (?, ?, ?, 'wikipedia', ?, 0, 0.0, ?, ?) """, (user["id"], title, author, chapters_json, total_words, cover_url), ) new_id = cursor.lastrowid row = conn.execute( """ SELECT id, user_id, title, author, source_type, chapters_json, json_array_length(chapters_json) AS chapters_count, current_chapter, current_position, total_words, cover_url, created_at, updated_at FROM books WHERE id = ? """, (new_id,), ).fetchone() res = dict(row) res["chapters"] = chapters res["already_existed"] = False return res @router.get("/api/books/{book_id}") async def api_get_book(book_id: int, user: dict = Depends(require_user)): """ Returns full book details including chapters_json. """ with get_db() as conn: row = conn.execute( """ SELECT id, user_id, title, author, source_type, chapters_json, json_array_length(chapters_json) AS chapters_count, current_chapter, current_position, total_words, cover_url, created_at, updated_at FROM books WHERE id = ? AND user_id = ? """, (book_id, user["id"]), ).fetchone() if not row: raise HTTPException(status_code=404, detail="Book not found") data = dict(row) try: data["chapters"] = json.loads(data["chapters_json"]) except Exception: data["chapters"] = [] return data @router.put("/api/books/{book_id}/progress") @router.patch("/api/books/{book_id}/progress") async def api_update_book_progress( book_id: int, req: Optional[BookProgressRequest] = None, current_chapter: Optional[int] = Query(None), current_position: Optional[float] = Query(None), request: Request = None, user: dict = Depends(require_user), ): """ Updates reading progress (current_chapter, current_position) for a book. """ ch = None pos = None if req is not None: ch = req.current_chapter if req.current_chapter is not None else req.chapter pos = req.current_position if req.current_position is not None else req.position if ch is None: ch = current_chapter if pos is None: pos = current_position if ch is None or pos is None: if request: try: body = await request.json() if ch is None: ch = body.get("current_chapter", body.get("chapter")) if pos is None: pos = body.get("current_position", body.get("position")) except Exception: pass if ch is None and pos is None: raise HTTPException(status_code=400, detail="Missing current_chapter or current_position") with get_db() as conn: row = conn.execute( "SELECT id, current_chapter, current_position FROM books WHERE id = ? AND user_id = ?", (book_id, user["id"]), ).fetchone() if not row: raise HTTPException(status_code=404, detail="Book not found") new_ch = int(ch) if ch is not None else row["current_chapter"] new_pos = float(pos) if pos is not None else row["current_position"] conn.execute( """ UPDATE books SET current_chapter = ?, current_position = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? """, (new_ch, new_pos, book_id, user["id"]), ) updated_row = conn.execute( """ SELECT id, user_id, title, author, source_type, chapters_json, json_array_length(chapters_json) AS chapters_count, current_chapter, current_position, total_words, cover_url, created_at, updated_at FROM books WHERE id = ? """, (book_id,), ).fetchone() res = dict(updated_row) try: res["chapters"] = json.loads(res["chapters_json"]) except Exception: res["chapters"] = [] return res @router.delete("/api/books/{book_id}") async def api_delete_book(book_id: int, user: dict = Depends(require_user)): """ Deletes a book for the current user. """ with get_db() as conn: cursor = conn.execute( "DELETE FROM books WHERE id = ? AND user_id = ?", (book_id, user["id"]), ) if cursor.rowcount == 0: raise HTTPException(status_code=404, detail="Book not found") return {"status": "ok", "deleted_id": book_id}