feat: implement Stage 5 (Interactive Reader, EPUB/TXT parser, Bookshelf, Click-to-Translate) and Stage 4.3 (Strict CEFR level calculation)
This commit is contained in:
1 parent
c51c505b61
commit
d8a2c6e34f
9 files changed
+2101
-30
No files matched your search
+292
-5
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
import json
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timezone, date
|
||||
@@ -24,7 +26,8 @@ from app.auth import (
|
||||
)
|
||||
from app.dictionary import lookup_term, USER_AGENT
|
||||
from app.export import export_csv, export_json, export_anki_tsv, export_markdown
|
||||
from app.srs import calculate_sm2, generate_quiz_options, build_activity_heatmap
|
||||
from app.srs import calculate_sm2, generate_quiz_options, build_activity_heatmap, calculate_cefr_level
|
||||
from app.reader import parse_epub, parse_text, count_words
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -131,6 +134,20 @@ class SrsReviewRequest(BaseModel):
|
||||
rating: int = Field(..., ge=1, le=4)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PWA Static Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -528,6 +545,7 @@ async def api_batch_action(req: BatchActionRequest, user: dict = Depends(require
|
||||
@app.get("/api/srs/due")
|
||||
async def api_srs_queue(
|
||||
collection: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
limit: int = 25,
|
||||
mix: bool = False,
|
||||
user: dict = Depends(require_user),
|
||||
@@ -543,8 +561,14 @@ async def api_srs_queue(
|
||||
with get_db() as conn:
|
||||
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
col_filter = " AND collection = ?" if collection else ""
|
||||
col_params = [collection] if collection else []
|
||||
col_filter = ""
|
||||
col_params = []
|
||||
if collection:
|
||||
col_filter += " AND collection = ?"
|
||||
col_params.append(collection.strip())
|
||||
if tag:
|
||||
col_filter += " AND tags LIKE ?"
|
||||
col_params.append(f"%{tag.strip()}%")
|
||||
|
||||
if mix:
|
||||
# 1. New cards (up to 10)
|
||||
@@ -607,6 +631,7 @@ async def api_srs_queue(
|
||||
@app.get("/api/srs/quiz")
|
||||
async def api_srs_quiz(
|
||||
collection: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
count: int = 10,
|
||||
user: dict = Depends(require_user),
|
||||
):
|
||||
@@ -615,8 +640,15 @@ async def api_srs_quiz(
|
||||
"""
|
||||
with get_db() as conn:
|
||||
# Fetch target cards for user
|
||||
col_filter = " AND collection = ?" if collection else ""
|
||||
col_params = [collection] if collection else []
|
||||
col_filter = ""
|
||||
col_params = []
|
||||
if collection:
|
||||
col_filter += " AND collection = ?"
|
||||
col_params.append(collection.strip())
|
||||
if tag:
|
||||
col_filter += " AND tags LIKE ?"
|
||||
col_params.append(f"%{tag.strip()}%")
|
||||
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM vocabulary_cards WHERE user_id = ?{col_filter} ORDER BY RANDOM() LIMIT ?",
|
||||
[user["id"]] + col_params + [count],
|
||||
@@ -798,6 +830,8 @@ async def api_srs_stats(user: dict = Depends(require_user)):
|
||||
).fetchone()[0]
|
||||
today_reviewed = date_counts.get(today_str, 0)
|
||||
|
||||
cefr_info = calculate_cefr_level(mastered, review, retention_rate, total_cards)
|
||||
|
||||
return {
|
||||
"total_cards": total_cards,
|
||||
"new_cards": new_cards,
|
||||
@@ -812,6 +846,7 @@ async def api_srs_stats(user: dict = Depends(require_user)):
|
||||
"longest_streak": heatmap_info["longest_streak"],
|
||||
"today_added": today_added,
|
||||
"today_reviewed": today_reviewed,
|
||||
"cefr": cefr_info,
|
||||
}
|
||||
|
||||
|
||||
@@ -944,3 +979,255 @@ async def api_export_markdown(collection: Optional[str] = None, user: dict = Dep
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=\"vocabulary.md\""},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bookshelf & Interactive Reader Endpoints (Stage 5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.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,
|
||||
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]
|
||||
|
||||
|
||||
@app.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()
|
||||
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(".md"):
|
||||
source_type = "md"
|
||||
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
|
||||
elif lower_name.endswith(".txt"):
|
||||
source_type = "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
|
||||
|
||||
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,
|
||||
created_at, updated_at
|
||||
FROM books WHERE id = ?
|
||||
""",
|
||||
(new_id,),
|
||||
).fetchone()
|
||||
|
||||
res = dict(row)
|
||||
res["chapters"] = chapters
|
||||
return res
|
||||
|
||||
|
||||
@app.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,
|
||||
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
|
||||
|
||||
|
||||
@app.put("/api/books/{book_id}/progress")
|
||||
@app.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,
|
||||
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
|
||||
|
||||
|
||||
@app.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}
|
||||
Reference in new issue
Block a user