Files
notes-qpid-pro/app/main.py
T

1986 lines
69 KiB
Python

import os
import re
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
from fastapi import FastAPI, Depends, HTTPException, Request, Response, status, Query
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.gzip import GZipMiddleware
from pydantic import BaseModel, Field
import httpx
import asyncio
from app import config
from app.database import init_db, get_db
from app.auth import (
ensure_admin_user,
authenticate_user,
create_session,
get_user_from_session,
delete_session,
check_login_rate_limit,
record_login_failure,
record_login_success,
change_user_password,
)
from app.dictionary import lookup_term, USER_AGENT, close_http_client
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, calculate_cefr_level
from app.reader import (
parse_epub, parse_text, parse_pdf, count_words, extract_rfc_number,
fetch_and_parse_rfc, parse_rfc_text, fetch_and_parse_wikipedia,
extract_wikipedia_title_and_lang
)
from app.ai import translate_sentence_ai, get_ollama_status, warmup_ollama_model
from app.phrasal import detect_phrasal_verb
from app.cefr import (
get_cefr_level_summaries,
get_cefr_quiz_batch,
process_cefr_answer,
reset_user_cefr_level,
ingest_cefr_word,
)
from app.grammar import (
get_grammar_units_list,
get_grammar_unit_detail,
process_grammar_answer,
record_grammar_completion,
update_handwritten_status,
)
from app.tasks import (
create_task,
get_task_status,
get_active_task,
list_user_tasks,
cancel_task,
)
from app.miner import run_mining_task_background
from app.tts import (
synthesize_speech,
get_audio_hash,
prewarm_card_audio,
AUDIO_CACHE_DIR,
is_piper_available,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
ensure_admin_user()
asyncio.create_task(warmup_ollama_model())
yield
await close_http_client()
app = FastAPI(
title="Notes App",
description="Personal Vocabulary & Language Notebook (notes.qpid.pro)",
version="2.1.0",
lifespan=lifespan,
)
# GZip Compression Middleware (compresses responses >= 1000 bytes)
app.add_middleware(GZipMiddleware, minimum_size=1000)
# Mount static files
app.mount("/static", StaticFiles(directory=str(config.BASE_DIR / "app" / "static")), name="static")
# Security Headers Middleware
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "same-origin"
return response
# ---------------------------------------------------------------------------
# Auth dependencies
# ---------------------------------------------------------------------------
def get_optional_user(request: Request) -> Optional[dict]:
token = request.cookies.get(config.SESSION_COOKIE_NAME)
if not token:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
if token:
return get_user_from_session(token)
return None
def require_user(request: Request) -> dict:
user = get_optional_user(request)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
)
return user
# ---------------------------------------------------------------------------
# Request / Response models
# ---------------------------------------------------------------------------
class LoginRequest(BaseModel):
username: str = Field(..., min_length=1)
password: str = Field(..., min_length=1)
class ChangePasswordRequest(BaseModel):
old_password: str = Field(..., min_length=1)
new_password: str = Field(..., min_length=6)
class CardCreateRequest(BaseModel):
term: str = Field(..., min_length=1)
translation: str = Field(..., min_length=1)
context_sentence: Optional[str] = ""
phonetic: Optional[str] = ""
audio_url: Optional[str] = ""
part_of_speech: Optional[str] = ""
definition_en: Optional[str] = ""
tags: Optional[str] = "general"
notes: Optional[str] = ""
collection: Optional[str] = "General"
class CardUpdateRequest(BaseModel):
term: Optional[str] = None
translation: Optional[str] = None
context_sentence: Optional[str] = None
phonetic: Optional[str] = None
audio_url: Optional[str] = None
part_of_speech: Optional[str] = None
definition_en: Optional[str] = None
tags: Optional[str] = None
notes: Optional[str] = None
collection: Optional[str] = None
class BatchActionRequest(BaseModel):
card_ids: List[int] = Field(..., min_length=1)
action: str = Field(..., pattern="^(set_collection|add_tags|delete)$")
value: Optional[str] = ""
class SrsReviewRequest(BaseModel):
card_id: int
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
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
class SentenceTranslateRequest(BaseModel):
sentence: str = Field(..., min_length=1)
target_word: Optional[str] = ""
context: Optional[str] = ""
class CefrAnswerRequest(BaseModel):
question_id: int
selected_option: str
class CefrResetRequest(BaseModel):
level: str
class CefrAddWordRequest(BaseModel):
level: str
term: str
context_sentence: Optional[str] = ""
class GrammarAnswerRequest(BaseModel):
unit_id: int
exercise_idx: int
selected_option: str
class GrammarCompleteRequest(BaseModel):
unit_id: int
score: int = Field(..., ge=0, le=100)
handwritten_logged: bool = False
class GrammarHandwrittenRequest(BaseModel):
unit_id: int
handwritten: bool
class MineBooksRequest(BaseModel):
book_id: Optional[int] = None
level: str = "A1"
count: int = Field(15, ge=1, le=50)
# ---------------------------------------------------------------------------
# PWA Static Endpoints
# ---------------------------------------------------------------------------
@app.get("/manifest.json", include_in_schema=False)
async def manifest_endpoint():
manifest_path = config.BASE_DIR / "app" / "static" / "manifest.json"
if manifest_path.exists():
return Response(
content=manifest_path.read_text(encoding="utf-8"),
media_type="application/manifest+json; charset=utf-8",
headers={"Cache-Control": "public, max-age=86400"},
)
raise HTTPException(status_code=404, detail="Manifest not found")
@app.get("/favicon.ico", include_in_schema=False)
async def favicon_endpoint():
icon_path = config.BASE_DIR / "app" / "static" / "icons" / "icon-192.png"
if icon_path.exists():
return Response(
content=icon_path.read_bytes(),
media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"},
)
raise HTTPException(status_code=404, detail="Favicon not found")
@app.get("/sw.js", include_in_schema=False)
async def service_worker_endpoint():
sw_path = config.BASE_DIR / "app" / "static" / "sw.js"
if sw_path.exists():
return Response(
content=sw_path.read_text(encoding="utf-8"),
media_type="application/javascript; charset=utf-8",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Service-Worker-Allowed": "/",
},
)
raise HTTPException(status_code=404, detail="Service worker not found")
# ---------------------------------------------------------------------------
# HTML entry point
# ---------------------------------------------------------------------------
@app.api_route("/", methods=["GET", "HEAD"], response_class=HTMLResponse)
async def index_page(request: Request):
index_file = config.BASE_DIR / "app" / "static" / "index.html"
if not index_file.exists():
raise HTTPException(status_code=404, detail="Index file not found")
with open(index_file, "r", encoding="utf-8") as f:
html_content = f.read()
return HTMLResponse(content=html_content, status_code=200)
# ---------------------------------------------------------------------------
# Auth API
# ---------------------------------------------------------------------------
@app.post("/api/login")
async def api_login(req: LoginRequest, request: Request, response: Response):
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
ip = forwarded.split(",")[0].strip()
else:
ip = request.headers.get("x-real-ip") or (request.client.host if request.client else "unknown")
if not check_login_rate_limit(ip):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Please try again in a minute.",
)
user = authenticate_user(req.username, req.password)
if not user:
record_login_failure(ip)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
record_login_success(ip)
user_agent = request.headers.get("User-Agent", "")
session_token = create_session(user["id"], user_agent=user_agent, ip=ip)
is_secure = request.headers.get("x-forwarded-proto", "").lower() == "https" or request.url.scheme == "https"
response.set_cookie(
key=config.SESSION_COOKIE_NAME,
value=session_token,
max_age=config.SESSION_MAX_AGE,
httponly=True,
samesite="lax",
secure=is_secure,
)
return {
"status": "ok",
"user": {"id": user["id"], "username": user["username"]},
"token": session_token,
}
@app.post("/api/logout")
async def api_logout(request: Request, response: Response):
token = request.cookies.get(config.SESSION_COOKIE_NAME)
if token:
delete_session(token)
response.delete_cookie(key=config.SESSION_COOKIE_NAME)
return {"status": "ok"}
@app.get("/api/me")
async def api_me(user: dict = Depends(require_user)):
return {"status": "ok", "user": {"id": user["id"], "username": user["username"]}}
@app.post("/api/change-password")
@app.post("/api/auth/change-password")
async def api_change_password(req: ChangePasswordRequest, user: dict = Depends(require_user)):
ok, msg = change_user_password(user["id"], req.old_password, req.new_password)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=msg or "Current password does not match",
)
return {"status": "ok", "message": "Password changed successfully"}
# ---------------------------------------------------------------------------
# Dictionary Lookup & Audio Proxy
# ---------------------------------------------------------------------------
@app.get("/api/lookup")
async def api_lookup(
term: Optional[str] = None,
q: Optional[str] = None,
context: Optional[str] = None,
user: dict = Depends(require_user)
):
query_str = (term or q or "").strip()
if not query_str:
raise HTTPException(status_code=400, detail="Query term cannot be empty")
data = await lookup_term(query_str)
# Check if this term already exists in user's dictionary
with get_db() as conn:
existing = conn.execute(
"SELECT id, term, translation, context_sentence, tags, collection, srs_state, srs_reps FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))",
(user["id"], query_str),
).fetchone()
if existing:
data["in_library"] = True
data["library_card"] = dict(existing)
else:
data["in_library"] = False
data["library_card"] = None
# Multi-Word & Phrasal Verb Detection
data["phrasal_verb"] = None
if context:
pv = detect_phrasal_verb(
sentence=context,
target_word=query_str,
user_id=user["id"],
conn=conn,
)
if pv and pv["term"].lower() != query_str.lower():
data["phrasal_verb"] = pv
return data
@app.get("/api/detect-phrasal-verb")
async def api_detect_phrasal_verb(
sentence: str = Query(..., min_length=1),
word: str = Query(..., min_length=1),
user: dict = Depends(require_user)
):
"""
Analyzes sentence context around word to detect phrasal verbs, idioms, and collocations.
"""
with get_db() as conn:
pv = detect_phrasal_verb(sentence=sentence, target_word=word, user_id=user["id"], conn=conn)
return {"detected": bool(pv), "phrasal_verb": pv}
@app.api_route("/api/audio", methods=["GET", "HEAD"])
async def api_audio_proxy():
# Audio feature disabled
return Response(status_code=204)
# ---------------------------------------------------------------------------
# Local AI Sentence Translation & In-Context Word Lookup
# ---------------------------------------------------------------------------
@app.post("/api/translate-sentence")
async def api_translate_sentence(
req: SentenceTranslateRequest,
user: dict = Depends(require_user)
):
"""
Translates a sentence using local AI with in-context meaning of the target word.
Uses SQLite sentence_translations cache for sub-millisecond retrieval on repeat queries.
"""
clean_s = req.sentence.strip()
if not clean_s:
raise HTTPException(status_code=400, detail="Sentence cannot be empty")
# Pre-warm neural audio for sentence and target word in background
asyncio.create_task(prewarm_card_audio(req.target_word or "", clean_s))
return await translate_sentence_ai(clean_s, req.target_word or "", req.context or "")
@app.get("/api/ai/status")
async def api_ai_status(user: dict = Depends(require_user)):
"""
Returns local AI status and available models.
"""
return await get_ollama_status()
# ---------------------------------------------------------------------------
# Autonomous Neural TTS & Audio Streaming (Task 18)
# ---------------------------------------------------------------------------
@app.get("/api/audio/{hash_val}.wav")
async def api_get_cached_audio(hash_val: str):
"""
Streams pre-warmed / cached audio file directly with immutable cache headers.
"""
clean_hash = hash_val.lower().strip()
if not re.match(r"^[a-f0-9]{32}$", clean_hash):
raise HTTPException(status_code=400, detail="Invalid audio hash")
audio_path = AUDIO_CACHE_DIR / f"{clean_hash}.wav"
if not audio_path.is_file():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
path=str(audio_path),
media_type="audio/wav",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
@app.get("/api/tts")
async def api_tts(
text: str = Query(..., min_length=1, max_length=1000),
lang: str = Query("en"),
speed: float = Query(1.0, ge=0.5, le=2.0),
user: dict = Depends(require_user),
):
"""
Synthesizes speech on-demand using local Piper TTS (en_US-lessac / ru_RU-dmitri).
Instantly returns cached audio if previously synthesized.
"""
clean_text = text.strip()
if not clean_text:
raise HTTPException(status_code=400, detail="Text cannot be empty")
audio_path = await synthesize_speech(clean_text, lang=lang, speed=speed)
if not audio_path or not audio_path.is_file():
raise HTTPException(status_code=500, detail="TTS synthesis failed or unavailable")
return FileResponse(
path=str(audio_path),
media_type="audio/wav",
headers={"Cache-Control": "public, max-age=604800, immutable"},
)
@app.get("/api/tts/status")
async def api_tts_status(user: dict = Depends(require_user)):
"""Returns status of local Piper TTS and available voice models."""
return {
"available": is_piper_available(),
"engine": "piper-vits",
"voices": ["en_US-lessac", "ru_RU-dmitri"],
"cache_dir": str(AUDIO_CACHE_DIR),
}
# ---------------------------------------------------------------------------
# Collections
# ---------------------------------------------------------------------------
@app.get("/api/collections")
async def api_get_collections(user: dict = Depends(require_user)):
"""Returns all collections for the current user with card count."""
with get_db() as conn:
rows = conn.execute(
"""
SELECT collection, COUNT(*) as count
FROM vocabulary_cards
WHERE user_id = ?
GROUP BY collection
ORDER BY count DESC, collection ASC
""",
(user["id"],),
).fetchall()
return [{"name": r["collection"], "collection": r["collection"], "count": r["count"]} for r in rows]
# ---------------------------------------------------------------------------
# Cards CRUD & Duplicate Check
# ---------------------------------------------------------------------------
@app.get("/api/cards/check-duplicate")
async def api_check_duplicate(term: str = Query(..., min_length=1), user: dict = Depends(require_user)):
clean_term = term.strip().lower()
with get_db() as conn:
row = conn.execute(
"SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ?",
(user["id"], clean_term),
).fetchone()
if row:
return {"exists": True, "card": dict(row)}
return {"exists": False, "card": None}
@app.get("/api/cards")
async def api_get_cards(
search: Optional[str] = None,
tag: Optional[str] = None,
collection: Optional[str] = None,
srs_state: Optional[str] = None,
limit: int = 100,
offset: int = 0,
user: dict = Depends(require_user),
):
with get_db() as conn:
query = """
SELECT id, term, translation, context_sentence, phonetic, audio_url,
part_of_speech, definition_en, tags, notes, collection,
srs_reps, srs_interval, srs_ease, srs_next_review, srs_last_review, srs_state, error_count,
created_at, updated_at
FROM vocabulary_cards
WHERE user_id = ?
"""
params = [user["id"]]
if search:
s_clean = f"%{search.strip()}%"
query += " AND (term LIKE ? OR translation LIKE ? OR context_sentence LIKE ? OR notes LIKE ?)"
params.extend([s_clean, s_clean, s_clean, s_clean])
if tag:
t_clean = f"%{tag.strip()}%"
query += " AND tags LIKE ?"
params.append(t_clean)
if collection:
query += " AND collection = ?"
params.append(collection.strip())
if srs_state:
query += " AND srs_state = ?"
params.append(srs_state.strip())
# Total count before limit
count_query = f"SELECT COUNT(*) FROM ({query})"
total = conn.execute(count_query, params).fetchone()[0]
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
rows = conn.execute(query, params).fetchall()
cards = []
for r in rows:
c = dict(r)
if not c.get("audio_url"):
c["audio_url"] = f"/api/audio/{get_audio_hash(c['term'], 'en', 1.0)}.wav"
if c.get("context_sentence"):
c["context_audio_url"] = f"/api/audio/{get_audio_hash(c['context_sentence'], 'en', 1.0)}.wav"
cards.append(c)
return {"total": total, "cards": cards}
@app.post("/api/cards")
async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_user)):
clean_term = card.term.strip()
with get_db() as conn:
existing = conn.execute(
"SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))",
(user["id"], clean_term),
).fetchone()
if existing:
# Word already exists in library!
existing_row = dict(existing)
new_ctx = (card.context_sentence or "").strip()
old_ctx = (existing_row.get("context_sentence") or "").strip()
updated_ctx = old_ctx
if new_ctx and new_ctx not in old_ctx:
updated_ctx = f"{old_ctx}\n\n{new_ctx}" if old_ctx else new_ctx
# Merge tags
old_tags = set([t.strip() for t in (existing_row.get("tags") or "").split(",") if t.strip()])
new_tags = [t.strip() for t in (card.tags or "").split(",") if t.strip()]
for t in new_tags:
old_tags.add(t)
updated_tags = ", ".join(sorted(old_tags)) if old_tags else "general"
conn.execute(
"""
UPDATE vocabulary_cards
SET context_sentence = ?, tags = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(updated_ctx, updated_tags, existing_row["id"]),
)
updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (existing_row["id"],)).fetchone()
res = dict(updated_row)
if not res.get("audio_url"):
res["audio_url"] = f"/api/audio/{get_audio_hash(res['term'], 'en', 1.0)}.wav"
if res.get("context_sentence"):
res["context_audio_url"] = f"/api/audio/{get_audio_hash(res['context_sentence'], 'en', 1.0)}.wav"
res["already_exists"] = True
# Pre-warm word and context audio in background
asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence))
return res
audio_url_val = (card.audio_url or "").strip()
if not audio_url_val:
audio_url_val = f"/api/audio/{get_audio_hash(clean_term, 'en', 1.0)}.wav"
cursor = conn.execute(
"""
INSERT INTO vocabulary_cards (
user_id, term, translation, context_sentence, phonetic, audio_url,
part_of_speech, definition_en, tags, notes, collection,
srs_reps, srs_interval, srs_ease, srs_state, error_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 2.5, 'new', 0)
""",
(
user["id"],
clean_term,
card.translation.strip(),
(card.context_sentence or "").strip(),
(card.phonetic or "").strip(),
audio_url_val,
(card.part_of_speech or "").strip(),
(card.definition_en or "").strip(),
(card.tags or "general").strip(),
(card.notes or "").strip(),
(card.collection or "General").strip(),
),
)
new_id = cursor.lastrowid
row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (new_id,)).fetchone()
res = dict(row)
if not res.get("audio_url"):
res["audio_url"] = f"/api/audio/{get_audio_hash(res['term'], 'en', 1.0)}.wav"
if res.get("context_sentence"):
res["context_audio_url"] = f"/api/audio/{get_audio_hash(res['context_sentence'], 'en', 1.0)}.wav"
# Pre-warm word and context audio in background!
asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence))
return res
@app.get("/api/cards/{card_id}")
async def api_get_card(card_id: int, user: dict = Depends(require_user)):
with get_db() as conn:
row = conn.execute(
"SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?",
(card_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Card not found")
return dict(row)
@app.put("/api/cards/{card_id}")
async def api_update_card(card_id: int, update: CardUpdateRequest, user: dict = Depends(require_user)):
with get_db() as conn:
row = conn.execute(
"SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?",
(card_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Card not found")
current = dict(row)
def pick(new_val, current_val):
return new_val.strip() if new_val is not None else current_val
conn.execute(
"""
UPDATE vocabulary_cards
SET term = ?, translation = ?, context_sentence = ?, phonetic = ?,
audio_url = ?, part_of_speech = ?, definition_en = ?, tags = ?,
notes = ?, collection = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
""",
(
pick(update.term, current["term"]),
pick(update.translation, current["translation"]),
pick(update.context_sentence, current.get("context_sentence", "")),
pick(update.phonetic, current.get("phonetic", "")),
pick(update.audio_url, current.get("audio_url", "")),
pick(update.part_of_speech, current.get("part_of_speech", "")),
pick(update.definition_en, current.get("definition_en", "")),
pick(update.tags, current.get("tags", "")),
pick(update.notes, current.get("notes", "")),
pick(update.collection, current.get("collection", "General")),
card_id,
user["id"],
),
)
updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (card_id,)).fetchone()
return dict(updated_row)
@app.delete("/api/cards/{card_id}")
async def api_delete_card(card_id: int, user: dict = Depends(require_user)):
with get_db() as conn:
cursor = conn.execute(
"DELETE FROM vocabulary_cards WHERE id = ? AND user_id = ?",
(card_id, user["id"]),
)
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail="Card not found")
return {"status": "ok", "deleted_id": card_id}
# ---------------------------------------------------------------------------
# Batch operations
# ---------------------------------------------------------------------------
@app.post("/api/cards/batch")
async def api_batch_action(req: BatchActionRequest, user: dict = Depends(require_user)):
if not req.card_ids:
raise HTTPException(status_code=400, detail="No card IDs provided")
with get_db() as conn:
placeholders = ",".join("?" * len(req.card_ids))
params_base = list(req.card_ids) + [user["id"]]
if req.action == "delete":
cursor = conn.execute(
f"DELETE FROM vocabulary_cards WHERE id IN ({placeholders}) AND user_id = ?",
params_base,
)
return {"status": "ok", "action": "delete", "affected": cursor.rowcount}
elif req.action == "set_collection":
collection_val = (req.value or "General").strip()
cursor = conn.execute(
f"UPDATE vocabulary_cards SET collection = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders}) AND user_id = ?",
[collection_val] + params_base,
)
return {"status": "ok", "action": "set_collection", "affected": cursor.rowcount}
elif req.action == "add_tags":
new_tags_raw = req.value or ""
new_tags = [t.strip() for t in new_tags_raw.split(",") if t.strip()]
if not new_tags:
return {"status": "ok", "action": "add_tags", "affected": 0}
rows = conn.execute(
f"SELECT id, tags FROM vocabulary_cards WHERE id IN ({placeholders}) AND user_id = ?",
params_base,
).fetchall()
affected = 0
for r in rows:
existing = [t.strip() for t in (r["tags"] or "").split(",") if t.strip()]
combined = existing + [t for t in new_tags if t not in existing]
conn.execute(
"UPDATE vocabulary_cards SET tags = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(", ".join(combined), r["id"]),
)
affected += 1
return {"status": "ok", "action": "add_tags", "affected": affected}
else:
raise HTTPException(status_code=422, detail=f"Unknown action: {req.action}")
# ---------------------------------------------------------------------------
# SRS Spaced Repetition Endpoints & Smart Quiz
# ---------------------------------------------------------------------------
@app.get("/api/srs/queue")
@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),
):
"""
Returns queue of cards ready for study.
If mix=True (Smart Daily Mix):
- up to 10 new cards
- up to 10 review due cards
- up to 5 leeches / problematic cards (error_count > 0 or low ease)
Shuffled for interleaved recall.
"""
with get_db() as conn:
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
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)
new_rows = conn.execute(
f"""
SELECT * FROM vocabulary_cards
WHERE user_id = ? AND srs_state = 'new'{col_filter}
ORDER BY created_at DESC LIMIT 10
""",
[user["id"]] + col_params,
).fetchall()
# 2. Due review cards (up to 10)
due_rows = conn.execute(
f"""
SELECT * FROM vocabulary_cards
WHERE user_id = ? AND srs_state IN ('learning', 'review', 'mastered')
AND (srs_next_review <= ? OR srs_next_review IS NULL){col_filter}
ORDER BY srs_next_review ASC LIMIT 10
""",
[user["id"], now_str] + col_params,
).fetchall()
# 3. Problematic leeches (up to 5 with highest error count)
used_ids = {r["id"] for r in new_rows} | {r["id"] for r in due_rows}
leech_rows = conn.execute(
f"""
SELECT * FROM vocabulary_cards
WHERE user_id = ? AND (error_count > 0 OR srs_ease < 2.0){col_filter}
ORDER BY error_count DESC, srs_ease ASC LIMIT 10
""",
[user["id"]] + col_params,
).fetchall()
leech_filtered = [r for r in leech_rows if r["id"] not in used_ids][:5]
combined = [dict(r) for r in new_rows] + [dict(r) for r in due_rows] + [dict(r) for r in leech_filtered]
import random
random.shuffle(combined)
cards = combined[:limit]
else:
# Standard SRS due queue: Due cards first, then new cards
query = f"""
SELECT * FROM vocabulary_cards
WHERE user_id = ?{col_filter}
AND (srs_state = 'new' OR srs_next_review <= ? OR srs_next_review IS NULL)
ORDER BY
CASE WHEN srs_state != 'new' AND (srs_next_review <= ? OR srs_next_review IS NULL) THEN 0 ELSE 1 END,
srs_next_review ASC,
created_at DESC
LIMIT ?
"""
params = [user["id"]] + col_params + [now_str, now_str, limit]
rows = conn.execute(query, params).fetchall()
cards = [dict(r) for r in rows]
enhanced_cards = []
for c in cards:
cd = dict(c)
if not cd.get("audio_url"):
cd["audio_url"] = f"/api/audio/{get_audio_hash(cd['term'], 'en', 1.0)}.wav"
if cd.get("context_sentence"):
cd["context_audio_url"] = f"/api/audio/{get_audio_hash(cd['context_sentence'], 'en', 1.0)}.wav"
enhanced_cards.append(cd)
return {"count": len(enhanced_cards), "cards": enhanced_cards}
@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),
):
"""
Generates a Multiple-Choice Quiz (4 options per question).
"""
with get_db() as conn:
# Fetch target cards for user
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],
).fetchall()
target_cards = [dict(r) for r in rows]
# Fetch pool of all user cards for generating smart distractors
all_rows = conn.execute(
"SELECT id, term, translation, part_of_speech FROM vocabulary_cards WHERE user_id = ?",
(user["id"],),
).fetchall()
all_cards = [dict(r) for r in all_rows]
quiz_items = []
for card in target_cards:
options = generate_quiz_options(card, all_cards, count=4)
quiz_items.append({
"card": card,
"options": options,
})
return {"count": len(quiz_items), "quiz": quiz_items}
@app.post("/api/srs/review")
@app.post("/api/srs/review/{card_id_path}")
async def api_srs_review(
req: Optional[SrsReviewRequest] = None,
card_id_path: Optional[int] = None,
rating_param: Optional[int] = Query(None, alias="rating"),
request: Request = None,
user: dict = Depends(require_user),
):
"""
Records a review rating (1..4) and updates SM-2 state & activity logs.
"""
target_card_id = req.card_id if req else card_id_path
target_rating = req.rating if req else rating_param
if target_card_id is None:
try:
body = await request.json()
target_card_id = body.get("card_id")
target_rating = body.get("rating")
except Exception:
pass
if target_card_id is None or target_rating is None:
raise HTTPException(status_code=400, detail="Missing card_id or rating")
if target_rating not in (1, 2, 3, 4):
raise HTTPException(status_code=422, detail="Rating must be between 1 and 4")
with get_db() as conn:
row = conn.execute(
"SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?",
(target_card_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Card not found")
current = dict(row)
new_srs = calculate_sm2(
rating=target_rating,
reps=current.get("srs_reps", 0),
interval=current.get("srs_interval", 0),
ease=current.get("srs_ease", 2.5),
state=current.get("srs_state", "new"),
error_count=current.get("error_count", 0),
)
conn.execute(
"""
UPDATE vocabulary_cards
SET srs_reps = ?, srs_interval = ?, srs_ease = ?, srs_state = ?,
srs_last_review = ?, srs_next_review = ?, error_count = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
""",
(
new_srs["srs_reps"],
new_srs["srs_interval"],
new_srs["srs_ease"],
new_srs["srs_state"],
new_srs["srs_last_review"],
new_srs["srs_next_review"],
new_srs["error_count"],
target_card_id,
user["id"],
),
)
# Record review log
conn.execute(
"""
INSERT INTO srs_logs (user_id, card_id, rating, interval, ease, reviewed_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""",
(user["id"], target_card_id, target_rating, new_srs["srs_interval"], new_srs["srs_ease"]),
)
updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (target_card_id,)).fetchone()
return {"status": "ok", "card": dict(updated_row)}
@app.get("/api/srs/stats")
async def api_srs_stats(user: dict = Depends(require_user)):
"""
Returns full learning analytics, SRS states, retention rate, streaks.
"""
with get_db() as conn:
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
# Total cards
total_cards = conn.execute(
"SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ?",
(user["id"],),
).fetchone()[0]
# By state
states = conn.execute(
"""
SELECT srs_state, COUNT(*) as count
FROM vocabulary_cards
WHERE user_id = ?
GROUP BY srs_state
""",
(user["id"],),
).fetchall()
state_counts = {r["srs_state"]: r["count"] for r in states}
new_cards = state_counts.get("new", 0)
learning = state_counts.get("learning", 0)
review = state_counts.get("review", 0)
mastered = state_counts.get("mastered", 0)
# Due now
due_cards = conn.execute(
"""
SELECT COUNT(*) FROM vocabulary_cards
WHERE user_id = ? AND (srs_state = 'new' OR srs_next_review <= ? OR srs_next_review IS NULL)
""",
(user["id"], now_str),
).fetchone()[0]
# Leeches
leech_count = conn.execute(
"""
SELECT COUNT(*) FROM vocabulary_cards
WHERE user_id = ? AND (error_count > 0 OR srs_ease < 2.0)
""",
(user["id"],),
).fetchone()[0]
# Total reviews and retention
review_logs = conn.execute(
"SELECT rating, date(reviewed_at) as rev_date FROM srs_logs WHERE user_id = ?",
(user["id"],),
).fetchall()
total_reviews = len(review_logs)
good_reviews = sum(1 for r in review_logs if r["rating"] in (3, 4))
retention_rate = round((good_reviews / total_reviews * 100), 1) if total_reviews > 0 else 100.0
# Daily review aggregation for streak
date_counts: Dict[str, int] = {}
for r in review_logs:
d = r["rev_date"]
if d:
date_counts[d] = date_counts.get(d, 0) + 1
heatmap_info = build_activity_heatmap(date_counts, days=180)
# Today's activity
today_str = date.today().strftime("%Y-%m-%d")
today_added = conn.execute(
"SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ? AND date(created_at) = date('now')",
(user["id"],),
).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,
"learning": learning,
"review": review,
"mastered": mastered,
"due_cards": due_cards,
"leech_count": leech_count,
"total_reviews": total_reviews,
"retention_rate": retention_rate,
"streak_days": heatmap_info["current_streak"],
"longest_streak": heatmap_info["longest_streak"],
"today_added": today_added,
"today_reviewed": today_reviewed,
"cefr": cefr_info,
}
@app.get("/api/srs/activity-heatmap")
async def api_srs_activity_heatmap(days: int = 180, user: dict = Depends(require_user)):
"""
Returns GitHub-style daily activity heatmap data.
"""
with get_db() as conn:
rows = conn.execute(
"SELECT date(reviewed_at) as rev_date, COUNT(*) as cnt FROM srs_logs WHERE user_id = ? GROUP BY date(reviewed_at)",
(user["id"],),
).fetchall()
date_counts = {r["rev_date"]: r["cnt"] for r in rows if r["rev_date"]}
heatmap_data = build_activity_heatmap(date_counts, days=days)
return heatmap_data
# ---------------------------------------------------------------------------
# Tags & Stats
# ---------------------------------------------------------------------------
@app.get("/api/tags")
async def api_get_tags(user: dict = Depends(require_user)):
with get_db() as conn:
rows = conn.execute(
"SELECT tags FROM vocabulary_cards WHERE user_id = ?",
(user["id"],),
).fetchall()
tag_counts: dict[str, int] = {}
for r in rows:
raw = r["tags"]
if raw:
for t in raw.split(","):
tag_clean = t.strip()
if tag_clean:
tag_counts[tag_clean] = tag_counts.get(tag_clean, 0) + 1
sorted_tags = sorted(
[{"tag": k, "count": v} for k, v in tag_counts.items()],
key=lambda x: x["count"],
reverse=True,
)
return sorted_tags
@app.get("/api/stats")
async def api_stats(user: dict = Depends(require_user)):
with get_db() as conn:
total = conn.execute(
"SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ?",
(user["id"],),
).fetchone()[0]
today_added = conn.execute(
"SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ? AND date(created_at) = date('now')",
(user["id"],),
).fetchone()[0]
return {"total_cards": total, "today_added": today_added}
# ---------------------------------------------------------------------------
# Export endpoints
# ---------------------------------------------------------------------------
def _fetch_all_cards(user_id: int, collection: Optional[str] = None) -> list:
with get_db() as conn:
if collection:
rows = conn.execute(
"""
SELECT id, term, translation, context_sentence, phonetic, audio_url,
part_of_speech, definition_en, tags, notes, collection, created_at, updated_at
FROM vocabulary_cards
WHERE user_id = ? AND collection = ?
ORDER BY collection ASC, created_at DESC
""",
(user_id, collection),
).fetchall()
else:
rows = conn.execute(
"""
SELECT id, term, translation, context_sentence, phonetic, audio_url,
part_of_speech, definition_en, tags, notes, collection, created_at, updated_at
FROM vocabulary_cards
WHERE user_id = ?
ORDER BY collection ASC, created_at DESC
""",
(user_id,),
).fetchall()
return [dict(r) for r in rows]
@app.get("/api/export/csv")
async def api_export_csv(collection: Optional[str] = None, user: dict = Depends(require_user)):
cards = _fetch_all_cards(user["id"], collection)
data = export_csv(cards)
return Response(
content=data,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=\"vocabulary.csv\""},
)
@app.get("/api/export/json")
async def api_export_json(collection: Optional[str] = None, user: dict = Depends(require_user)):
cards = _fetch_all_cards(user["id"], collection)
data = export_json(cards)
return Response(
content=data,
media_type="application/json; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=\"vocabulary.json\""},
)
@app.get("/api/export/anki")
async def api_export_anki(collection: Optional[str] = None, user: dict = Depends(require_user)):
cards = _fetch_all_cards(user["id"], collection)
data = export_anki_tsv(cards)
return Response(
content=data,
media_type="text/plain; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=\"vocabulary_anki.txt\""},
)
@app.get("/api/export/markdown")
async def api_export_markdown(collection: Optional[str] = None, user: dict = Depends(require_user)):
cards = _fetch_all_cards(user["id"], collection)
data = export_markdown(cards)
return Response(
content=data,
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, 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]
@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()
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"):
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
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
@app.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:
title, author, chapters, rfc_num, total_words = fetch_and_parse_rfc(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
@app.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
@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, 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
@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, 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
@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}
# ---------------------------------------------------------------------------
# CEFR Level Testing & Automated Gap Ingestion Endpoints (Task 15)
# ---------------------------------------------------------------------------
@app.get("/api/cefr/levels")
async def api_cefr_levels(user: dict = Depends(require_user)):
"""
Returns progress summaries for all CEFR levels (A1, A2, B1, B2).
"""
with get_db() as conn:
summaries = get_cefr_level_summaries(conn, user["id"])
return {"levels": summaries}
@app.get("/api/cefr/quiz")
async def api_cefr_quiz(
level: str = Query("A1"),
count: int = Query(10, ge=1, le=30),
mode: str = Query("all"),
user: dict = Depends(require_user),
):
"""
Returns next batch of Cloze Tap test questions for level (A1..B2).
"""
with get_db() as conn:
quiz_items = get_cefr_quiz_batch(conn, user["id"], level, count, mode)
return {"level": level.upper(), "count": len(quiz_items), "quiz": quiz_items}
@app.post("/api/cefr/answer")
async def api_cefr_answer(
req: CefrAnswerRequest,
user: dict = Depends(require_user),
):
"""
Submits an answer for a CEFR question.
If wrong, automatically ingests into user's SRS active vocabulary with status 'learning'.
"""
with get_db() as conn:
result = process_cefr_answer(conn, user["id"], req.question_id, req.selected_option)
if "error" in result:
raise HTTPException(status_code=404, detail=result["error"])
return result
@app.post("/api/cefr/reset")
async def api_cefr_reset(
req: CefrResetRequest,
user: dict = Depends(require_user),
):
"""
Resets user testing progress for a given CEFR level.
"""
with get_db() as conn:
deleted = reset_user_cefr_level(conn, user["id"], req.level)
return {"status": "ok", "level": req.level.upper(), "reset_count": deleted}
@app.post("/api/cefr/add-word")
async def api_cefr_add_word(
req: CefrAddWordRequest,
user: dict = Depends(require_user),
):
"""
Adds a word from a CEFR test options list directly into user's SRS active vocabulary.
"""
with get_db() as conn:
return ingest_cefr_word(conn, user["id"], req.level, req.term, req.context_sentence)
# ---------------------------------------------------------------------------
# Task 16: Murphy Grammar Engine & Handwritten Journaling Endpoints
# ---------------------------------------------------------------------------
@app.get("/api/grammar/units")
async def api_grammar_units(user: dict = Depends(require_user)):
"""
Returns the list of all curated Murphy grammar units with user progress.
"""
with get_db() as conn:
units = get_grammar_units_list(conn, user["id"])
return {"units": units}
@app.get("/api/grammar/units/{unit_id}")
async def api_grammar_unit_detail(unit_id: int, user: dict = Depends(require_user)):
"""
Returns full content of a unit for lesson mode.
"""
with get_db() as conn:
unit = get_grammar_unit_detail(conn, user["id"], unit_id)
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
return unit
@app.post("/api/grammar/answer")
async def api_grammar_answer(
req: GrammarAnswerRequest,
user: dict = Depends(require_user),
):
"""
Checks answer for a Cloze Tap grammar exercise.
If wrong, performs automated gap ingestion into active SRS vocabulary.
"""
with get_db() as conn:
result = process_grammar_answer(
conn, user["id"], req.unit_id, req.exercise_idx, req.selected_option
)
if "error" in result:
raise HTTPException(status_code=404, detail=result["error"])
return result
@app.post("/api/grammar/complete")
async def api_grammar_complete(
req: GrammarCompleteRequest,
user: dict = Depends(require_user),
):
"""
Records completion of a grammar unit and user's score.
"""
with get_db() as conn:
return record_grammar_completion(
conn, user["id"], req.unit_id, req.score, req.handwritten_logged
)
@app.post("/api/grammar/handwritten")
async def api_grammar_handwritten(
req: GrammarHandwrittenRequest,
user: dict = Depends(require_user),
):
"""
Toggles handwritten paper notebook checkbox for a unit.
"""
with get_db() as conn:
return update_handwritten_status(conn, user["id"], req.unit_id, req.handwritten)
# ---------------------------------------------------------------------------
# Background Tasks & Book Sentence Mining Endpoints
# ---------------------------------------------------------------------------
@app.post("/api/tasks/mine-books")
async def api_tasks_mine_books(
req: MineBooksRequest,
user: dict = Depends(require_user),
):
"""
Starts an asynchronous background task to mine CEFR Cloze Tap questions
from user's uploaded books (EPUB, TXT, PDF).
"""
level = req.level.upper().strip()
if level not in ["A1", "A2", "B1", "B2", "ALL"]:
raise HTTPException(status_code=400, detail="Invalid CEFR level")
count = max(1, min(req.count, 50))
with get_db() as conn:
active = get_active_task(conn, user["id"], task_type="mine_books")
if active:
return {
"status": "already_running",
"message": "Сборка уже выполняется в фоне",
"task": active,
}
task_id = create_task(
conn,
user_id=user["id"],
task_type="mine_books",
params={
"book_id": req.book_id,
"level": level,
"count": count,
},
total=count,
)
# Launch background asyncio task
asyncio.create_task(
run_mining_task_background(
task_id=task_id,
user_id=user["id"],
book_id=req.book_id,
target_level=level,
target_count=count,
)
)
return {
"status": "ok",
"task_id": task_id,
"message": f"Запущена фоновая сборка тестов ({level})",
}
@app.get("/api/tasks/active")
async def api_tasks_active(
task_type: Optional[str] = Query(None),
user: dict = Depends(require_user),
):
"""
Returns current user's active background task (if any).
"""
with get_db() as conn:
task = get_active_task(conn, user["id"], task_type=task_type)
return {"task": task}
@app.get("/api/tasks/{task_id}")
async def api_tasks_status(
task_id: int,
user: dict = Depends(require_user),
):
"""
Returns status and progress of a background task.
"""
with get_db() as conn:
task = get_task_status(conn, task_id, user_id=user["id"])
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return {"task": task}
@app.post("/api/tasks/{task_id}/cancel")
async def api_tasks_cancel(
task_id: int,
user: dict = Depends(require_user),
):
"""
Cancels a running background task.
"""
with get_db() as conn:
cancelled = cancel_task(conn, task_id, user_id=user["id"])
return {"status": "ok" if cancelled else "not_found", "cancelled": cancelled}