Files

488 lines
18 KiB
Python

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}