488 lines
18 KiB
Python
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}
|