diff --git a/PLAN.md b/PLAN.md
index ef21e5a..2bbffd9 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -146,7 +146,7 @@
- Процент точности запоминания (Retention Rate %).
- [ ] **4.2. Быстрый захват из браузера**:
- Букмарклет для выделения текста на любой странице и отправки в словарь.
-- [ ] **4.3. Честный и строгий расчёт уровня CEFR (Active Vocabulary & Retention Weighting)**:
+- [x] **4.3. Честный и строгий расчёт уровня CEFR (Active Vocabulary & Retention Weighting)**:
- Расчёт уровня владения исключительно по **активным выученным словам** (стадии `Mastered` и `Review` в алгоритме SM-2), исключая пассивные добавленные карточки, которые ещё не прошли интервальные повторения.
- Корректирующий коэффициент точности: учёт Retention Rate %, чтобы уровень отражал реальную память, а не просто количество выписанных слов.
- Индикатор пути к следующему уровню (например: «*До уровня B2 осталось закрепить 142 слова*»).
@@ -156,19 +156,19 @@
### Этап 5: Интерактивная Читалка & Библиотека (Interactive Reader & Source-based Learning)
*Цель: чтение живых книг, статей и документации с мгновенным клик-переводом и авто-тегированием источников.*
-- [ ] **5.1. Хранилище текстов и парсер форматов (Bookshelf & Document Engine)**:
+- [x] **5.1. Хранилище текстов и парсер форматов (Bookshelf & Document Engine)**:
- Поддержка форматов: вставка текста из буфера, `.txt`, `.md`, электронные книги `.epub` (с разбивкой по главам и сохранением позиции чтения).
- Умная очистка веб-статей от лишней верстки и рекламы (Reader view).
-- [ ] **5.2. Автоматическое тегирование источников (Source & Metadata Auto-tagging)**:
+- [x] **5.2. Автоматическое тегирование источников (Source & Metadata Auto-tagging)**:
- Каждое слово, сохранённое из книги/статьи, автоматически получает теги источника:
- Название произведения (например, `#fight-club`, `#the-martian`, `#designing-data-intensive-apps`).
- Тип/жанр материала (например, `#novel`, `#tech-article`, `#documentation`, `#cybersecurity`).
- Автор / Источник (например, `#author:chuck-palahniuk`, `#cloudflare-blog`).
- Автоматический захват предложения-контекста, в котором слово встретилось в книге.
-- [ ] **5.3. Запуск тестов и тренировок по источникам и тегам (Tag-scoped SRS Quizzes)**:
+- [x] **5.3. Запуск тестов и тренировок по источникам и тегам (Tag-scoped SRS Quizzes)**:
- Возможность запустить любую тренировку (Карточки, 4 варианта ответа, Набор слова, Диктант) **строго по словам конкретной книги, статьи или автора**.
- Быстрый запуск из читалки: *«Повторить все 35 новых слов из Fight Club»* прямо во время или после прочтения главы.
-- [ ] **5.4. Интерактивный клик-перевод и подсветка (Interactive Reader Layer)**:
+- [x] **5.4. Интерактивный клик-перевод и подсветка (Interactive Reader Layer)**:
- Клик по любому слову $\rightarrow$ моментальный перевод, транскрипция, нативное аудио и кнопка «+ В словарь» в 1 клик.
- Выделение фраз/связок (chunks) мышкой или тапом с литературным переводом всего выражения.
- Подсветка в тексте книги уже выученных (`mastered`) и изучаемых (`learning`) слов.
diff --git a/app/database.py b/app/database.py
index d3837a1..92cde65 100644
--- a/app/database.py
+++ b/app/database.py
@@ -64,6 +64,25 @@ def _migrate(conn: sqlite3.Connection):
conn.execute("CREATE INDEX IF NOT EXISTS idx_srs_user ON srs_logs(user_id);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_srs_reviewed ON srs_logs(user_id, reviewed_at);")
+ # Ensure books table exists
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS books (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ title TEXT NOT NULL,
+ author TEXT DEFAULT '',
+ source_type TEXT NOT NULL,
+ chapters_json TEXT NOT NULL,
+ current_chapter INTEGER DEFAULT 0,
+ current_position REAL DEFAULT 0,
+ total_words INTEGER DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
+ """)
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_books_user ON books(user_id);")
+
def init_db():
config.DB_PATH.parent.mkdir(parents=True, exist_ok=True)
@@ -138,6 +157,22 @@ def init_db():
response_json TEXT NOT NULL,
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
+
+ CREATE TABLE IF NOT EXISTS books (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ title TEXT NOT NULL,
+ author TEXT DEFAULT '',
+ source_type TEXT NOT NULL, -- 'epub', 'txt', 'md', 'text'
+ chapters_json TEXT NOT NULL, -- JSON array: [{"title": "Chapter 1", "content": "..."}]
+ current_chapter INTEGER DEFAULT 0,
+ current_position REAL DEFAULT 0,
+ total_words INTEGER DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
+ CREATE INDEX IF NOT EXISTS idx_books_user ON books(user_id);
""")
# Apply migrations for pre-existing databases
_migrate(conn)
diff --git a/app/main.py b/app/main.py
index ff07d98..66c3676 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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}
diff --git a/app/reader.py b/app/reader.py
new file mode 100644
index 0000000..e73106f
--- /dev/null
+++ b/app/reader.py
@@ -0,0 +1,350 @@
+import io
+import re
+import html
+import zipfile
+import posixpath
+import urllib.parse
+import xml.etree.ElementTree as ET
+from html.parser import HTMLParser
+from typing import Tuple, List, Dict
+
+
+def count_words(text: str) -> int:
+ """
+ Counts words in text across multiple languages, ignoring isolated punctuation.
+ """
+ if not text:
+ return 0
+ return len(re.findall(r"\b[\w]+(?:['’][\w]+)?\b", text))
+
+
+class HTMLTextExtractor(HTMLParser):
+ """
+ Strips HTML tags cleanly while preserving paragraph and heading breaks with newlines.
+ Also extracts the first heading and/or
tag for chapter naming.
+ """
+ def __init__(self):
+ super().__init__()
+ self.text_parts: List[str] = []
+ self.ignore_tags = {"style", "script", "noscript"}
+ self.ignore_depth = 0
+ self.heading: str | None = None
+ self.in_heading = False
+ self.heading_parts: List[str] = []
+ self.title: str | None = None
+ self.in_title = False
+ self.title_parts: List[str] = []
+
+ def handle_starttag(self, tag: str, attrs):
+ t = tag.lower()
+ if t in self.ignore_tags:
+ self.ignore_depth += 1
+ elif t == "title":
+ self.in_title = True
+ self.title_parts = []
+ elif t in {"h1", "h2", "h3", "h4", "h5", "h6"}:
+ self.text_parts.append("\n\n")
+ if not self.heading:
+ self.in_heading = True
+ self.heading_parts = []
+ elif t in {"p", "div", "section", "article", "blockquote", "header", "footer"}:
+ self.text_parts.append("\n\n")
+ elif t in {"br", "hr", "li", "tr"}:
+ self.text_parts.append("\n")
+
+ def handle_endtag(self, tag: str):
+ t = tag.lower()
+ if t in self.ignore_tags:
+ self.ignore_depth = max(0, self.ignore_depth - 1)
+ elif t == "title":
+ self.in_title = False
+ self.title = "".join(self.title_parts).strip()
+ elif t in {"h1", "h2", "h3", "h4", "h5", "h6"}:
+ self.text_parts.append("\n\n")
+ if self.in_heading:
+ self.in_heading = False
+ if not self.heading:
+ self.heading = "".join(self.heading_parts).strip()
+ elif t in {"p", "div", "section", "article", "blockquote", "header", "footer"}:
+ self.text_parts.append("\n\n")
+ elif t in {"li", "tr"}:
+ self.text_parts.append("\n")
+
+ def handle_data(self, data: str):
+ if self.in_title:
+ self.title_parts.append(data)
+ if self.ignore_depth == 0 and not self.in_title:
+ # Replace non-breaking spaces with standard space
+ cleaned_data = data.replace("\xa0", " ")
+ self.text_parts.append(cleaned_data)
+ if self.in_heading:
+ self.heading_parts.append(cleaned_data)
+
+ def get_text(self) -> str:
+ raw = "".join(self.text_parts)
+ lines = [line.strip() for line in raw.split("\n")]
+ clean = "\n".join(lines)
+ return re.sub(r"\n{3,}", "\n\n", clean).strip()
+
+
+def parse_epub(file_bytes: bytes) -> Tuple[str, str, List[Dict[str, str]]]:
+ """
+ Parses an EPUB file archive and extracts:
+ - title: book title
+ - author: creator/author
+ - chapters: list of {"title": str, "content": str}
+ """
+ try:
+ z = zipfile.ZipFile(io.BytesIO(file_bytes))
+ except Exception as e:
+ raise ValueError(f"Invalid EPUB archive: {e}")
+
+ # 1. Locate root OPF file from META-INF/container.xml
+ opf_path = None
+ try:
+ container_data = z.read("META-INF/container.xml")
+ root = ET.fromstring(container_data)
+ rootfile = root.find(".//{*}rootfile")
+ if rootfile is not None:
+ opf_path = rootfile.attrib.get("full-path")
+ except Exception:
+ pass
+
+ if not opf_path:
+ for name in z.namelist():
+ if name.lower().endswith(".opf"):
+ opf_path = name
+ break
+
+ if not opf_path:
+ raise ValueError("Invalid EPUB: no OPF file found")
+
+ opf_path = opf_path.replace("\\", "/").lstrip("/")
+ opf_dir = posixpath.dirname(opf_path)
+
+ try:
+ opf_content = z.read(opf_path)
+ except KeyError:
+ # Try lowercase matching
+ name_map = {n.lower(): n for n in z.namelist()}
+ if opf_path.lower() in name_map:
+ opf_content = z.read(name_map[opf_path.lower()])
+ else:
+ raise ValueError(f"OPF file not found in archive: {opf_path}")
+
+ # 2. Extract metadata (title, author)
+ title = "Untitled"
+ author = ""
+ opf_tree = None
+ try:
+ opf_tree = ET.fromstring(opf_content)
+ title_el = opf_tree.find(".//{*}title")
+ if title_el is not None and title_el.text:
+ title = title_el.text.strip()
+ creator_el = opf_tree.find(".//{*}creator")
+ if creator_el is not None and creator_el.text:
+ author = creator_el.text.strip()
+ except Exception:
+ opf_str = opf_content.decode("utf-8", errors="ignore")
+ tm = re.search(r"<[^:]*:?title[^>]*>(.*?)[^:]*:?title>", opf_str, re.I | re.S)
+ if tm:
+ title = re.sub(r"<[^>]+>", "", tm.group(1)).strip()
+ cm = re.search(r"<[^:]*:?creator[^>]*>(.*?)[^:]*:?creator>", opf_str, re.I | re.S)
+ if cm:
+ author = re.sub(r"<[^>]+>", "", cm.group(1)).strip()
+
+ # 3. Parse Manifest (id -> href, media-type)
+ manifest: Dict[str, Dict[str, str]] = {}
+ if opf_tree is not None:
+ for item in opf_tree.findall(".//{*}item"):
+ iid = item.attrib.get("id")
+ href = item.attrib.get("href")
+ media_type = item.attrib.get("media-type", "").lower()
+ if iid and href:
+ manifest[iid] = {"href": href, "media_type": media_type}
+ else:
+ opf_str = opf_content.decode("utf-8", errors="ignore")
+ for m in re.finditer(r'- ]*id=["\']([^"\']+)["\'][^>]*href=["\']([^"\']+)["\']', opf_str, re.I):
+ manifest[m.group(1)] = {"href": m.group(2), "media_type": ""}
+
+ # 4. Spine items in reading order
+ spine_ids: List[str] = []
+ if opf_tree is not None:
+ for itemref in opf_tree.findall(".//{*}itemref"):
+ idref = itemref.attrib.get("idref")
+ if idref:
+ spine_ids.append(idref)
+ else:
+ opf_str = opf_content.decode("utf-8", errors="ignore")
+ for m in re.finditer(r']*idref=["\']([^"\']+)["\']', opf_str, re.I):
+ spine_ids.append(m.group(1))
+
+ # Fallback if spine is empty: find all html/xhtml files in manifest
+ candidate_paths: List[str] = []
+ if spine_ids:
+ for idref in spine_ids:
+ item = manifest.get(idref)
+ if item:
+ candidate_paths.append(item["href"])
+ else:
+ for item in manifest.values():
+ href = item["href"]
+ if href.lower().endswith((".xhtml", ".html", ".htm")):
+ candidate_paths.append(href)
+
+ namelist_map = {name.lower(): name for name in z.namelist()}
+ chapters: List[Dict[str, str]] = []
+ chapter_num = 1
+
+ for href in candidate_paths:
+ unquoted = urllib.parse.unquote(href)
+ rel_path = posixpath.normpath(posixpath.join(opf_dir, unquoted)) if opf_dir else unquoted
+ rel_path = rel_path.replace("\\", "/").lstrip("/")
+
+ actual_path = None
+ if rel_path in z.namelist():
+ actual_path = rel_path
+ elif rel_path.lower() in namelist_map:
+ actual_path = namelist_map[rel_path.lower()]
+
+ if not actual_path:
+ continue
+
+ try:
+ raw_bytes = z.read(actual_path)
+ try:
+ html_str = raw_bytes.decode("utf-8")
+ except UnicodeDecodeError:
+ html_str = raw_bytes.decode("latin-1", errors="replace")
+ except Exception:
+ continue
+
+ parser = HTMLTextExtractor()
+ parser.feed(html_str)
+ text = parser.get_text()
+
+ # Skip chapters with little or no readable text
+ if count_words(text) < 1:
+ continue
+
+ ch_title = parser.heading or parser.title or f"Chapter {chapter_num}"
+ # If ch_title is identical to book title or very long, format nicely
+ if len(ch_title) > 120:
+ ch_title = f"Chapter {chapter_num}"
+
+ chapters.append({"title": ch_title, "content": text})
+ chapter_num += 1
+
+ if not chapters:
+ chapters = [{"title": title if title != "Untitled" else "Chapter 1", "content": ""}]
+
+ return title, author, chapters
+
+
+def split_long_text(text: str, target_words: int = 3000) -> List[str]:
+ """
+ Splits text into chunks of approximately target_words on paragraph boundaries.
+ """
+ paragraphs = [p.strip() for p in re.split(r"\n{2,}", text) if p.strip()]
+ if not paragraphs:
+ return [text.strip()] if text.strip() else []
+
+ chunks: List[str] = []
+ current_paras: List[str] = []
+ current_count = 0
+
+ for p in paragraphs:
+ w_count = count_words(p)
+ # If single paragraph is massive (> target_words * 1.5), split by words
+ if w_count > target_words * 1.5:
+ if current_paras:
+ chunks.append("\n\n".join(current_paras))
+ current_paras = []
+ current_count = 0
+ words = p.split()
+ for i in range(0, len(words), target_words):
+ chunk = " ".join(words[i : i + target_words])
+ if chunk.strip():
+ chunks.append(chunk)
+ continue
+
+ if current_count + w_count > target_words and current_count > 0:
+ chunks.append("\n\n".join(current_paras))
+ current_paras = [p]
+ current_count = w_count
+ else:
+ current_paras.append(p)
+ current_count += w_count
+
+ if current_paras:
+ chunks.append("\n\n".join(current_paras))
+
+ return chunks
+
+
+def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple[str, str, List[Dict[str, str]]]:
+ """
+ Parses plain text or Markdown into readable chapters.
+ Splits by explicit chapter markers (e.g. 'Chapter X', '# Chapter') or chunks of ~3000 words.
+ """
+ content = content.replace("\r\n", "\n").replace("\r", "\n").strip()
+ if not content:
+ return title, author, [{"title": title if title != "Untitled" else "Chapter 1", "content": ""}]
+
+ lines = content.split("\n")
+ first_line = lines[0].strip()
+
+ # Extract title from first line if default
+ if title == "Untitled":
+ if first_line.startswith("# "):
+ title = first_line.lstrip("# ").strip()
+ if len(lines) > 1:
+ content = "\n".join(lines[1:]).strip()
+ elif first_line.lower().startswith("title:"):
+ title = first_line.split(":", 1)[1].strip()
+ if len(lines) > 1:
+ content = "\n".join(lines[1:]).strip()
+
+ # Extract author from next line if missing
+ if not author:
+ lines = content.split("\n")
+ if lines and lines[0].lower().startswith(("author:", "by:")):
+ author = lines[0].split(":", 1)[1].strip()
+ if len(lines) > 1:
+ content = "\n".join(lines[1:]).strip()
+
+ heading_pattern = re.compile(
+ r"^[ \t]*(?:"
+ r"#{1,3}[ \t]+(.+)|"
+ r"((?:Chapter|CHAPTER|Глава|ГЛАВА|Part|PART|Section|SECTION|Act|ACT|Scene|SCENE)[ \t]+[0-9IVXLCDMivxlcdm\w\.\-]+(?:[ \t]*[:\-–—\.][ \t]*.*|[ \t]+[^\n\r]+)?)"
+ r")[ \t]*$",
+ re.MULTILINE,
+ )
+
+ matches = list(heading_pattern.finditer(content))
+ chapters: List[Dict[str, str]] = []
+
+ if len(matches) >= 1:
+ # Pre-chapter intro text
+ intro = content[: matches[0].start()].strip()
+ if intro and count_words(intro) > 10:
+ chapters.append({"title": "Introduction", "content": intro})
+
+ for i, m in enumerate(matches):
+ h_text = (m.group(1) or m.group(2)).strip()
+ start = m.start()
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(content)
+ body = content[start:end].strip()
+ if body:
+ chapters.append({"title": h_text, "content": body})
+
+ if not chapters:
+ # No headings detected: split by ~3000 words chunks
+ chunks = split_long_text(content, target_words=3000)
+ if len(chunks) == 1:
+ chapters = [{"title": title if title != "Untitled" else "Chapter 1", "content": chunks[0]}]
+ else:
+ for idx, chunk in enumerate(chunks, start=1):
+ chapters.append({"title": f"Chapter {idx}", "content": chunk})
+
+ return title, author, chapters
diff --git a/app/srs.py b/app/srs.py
index 9855e35..c52a70c 100644
--- a/app/srs.py
+++ b/app/srs.py
@@ -248,3 +248,57 @@ def build_activity_heatmap(review_dates: Dict[str, int], days: int = 180) -> Dic
"current_streak": current_streak,
"longest_streak": max(longest_streak, current_streak),
}
+
+
+def calculate_cefr_level(
+ mastered_count: int,
+ review_count: int,
+ retention_rate: float,
+ total_cards: int = 0
+) -> Dict[str, Any]:
+ """
+ Computes strict CEFR level based on active vocabulary weight and memory retention rate (PLAN.md 4.3).
+ Only words in Mastered (weight 1.0) and Review (weight 0.5) stages count, scaled by retention rate %.
+ """
+ safe_retention = max(0.0, min(100.0, float(retention_rate)))
+ retention_factor = (safe_retention / 100.0) if safe_retention > 0 else 1.0
+
+ # Active weighted vocabulary: Mastered gives 1.0, Review gives 0.5
+ weighted_words = (mastered_count * 1.0) + (review_count * 0.5)
+ active_vocab = int(round(weighted_words * retention_factor))
+
+ # CEFR thresholds: (min_words, level, title, next_level, next_target)
+ brackets = [
+ (0, "A1", "Начальный словарный запас", "A2", 300),
+ (300, "A2", "Элементарный словарный запас", "B1", 800),
+ (800, "B1", "Уверенное понимание общих и рабочих тем", "B2", 1600),
+ (1600, "B2", "Свободное чтение тех. литературы и документации", "C1", 3000),
+ (3000, "C1", "Продвинутый профессиональный уровень", "C2", 5000),
+ ]
+
+ current_bracket = brackets[0]
+ for b in brackets:
+ if active_vocab >= b[0]:
+ current_bracket = b
+
+ min_w, level, title, next_lvl, next_w = current_bracket
+ words_to_next = max(0, next_w - active_vocab)
+ range_span = next_w - min_w
+ current_progress = active_vocab - min_w
+ pct = int(min(100, max(0, (current_progress / range_span) * 100))) if range_span > 0 else 100
+
+ if words_to_next > 0:
+ message = f"До уровня {next_lvl} осталось закрепить {words_to_next} активных слов"
+ else:
+ message = f"Уровень {level} полностью освоен!"
+
+ return {
+ "level": level,
+ "title": title,
+ "active_vocab": active_vocab,
+ "next_level": next_lvl,
+ "words_to_next": words_to_next,
+ "progress_percent": pct,
+ "message": message,
+ "retention_rate": safe_retention,
+ }
diff --git a/app/static/css/style.css b/app/static/css/style.css
index e9fb04f..562b45c 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -2396,4 +2396,386 @@ body {
}
}
+/* ==========================================================================
+ Stage 5: Interactive Reader & Bookshelf Styles
+ ========================================================================== */
+
+.reader-view {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 24px 20px 80px;
+}
+
+.bookshelf-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 16px;
+ margin-bottom: 28px;
+ padding-bottom: 20px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.bookshelf-title-wrap h2 {
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--text-main);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.bookshelf-title-wrap .subtitle {
+ font-size: 14px;
+ color: var(--text-muted);
+ margin-top: 4px;
+}
+
+.bookshelf-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.file-upload-label {
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.bookshelf-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 20px;
+}
+
+.book-card {
+ background-color: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ transition: var(--transition);
+ position: relative;
+ box-shadow: var(--shadow-sm);
+}
+
+.book-card:hover {
+ border-color: var(--border-focus);
+ box-shadow: var(--shadow-md);
+ transform: translateY(-2px);
+}
+
+.book-card-top {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ margin-bottom: 14px;
+}
+
+.book-cover-icon {
+ font-size: 32px;
+ background: var(--bg-input);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ width: 52px;
+ height: 64px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.book-card-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.book-card-title {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-main);
+ margin-bottom: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.book-card-author {
+ font-size: 13px;
+ color: var(--text-muted);
+ margin-bottom: 8px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.book-card-badges {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.badge-format {
+ text-transform: uppercase;
+ font-size: 10px;
+ font-weight: 700;
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+ background: var(--tag-bg);
+ color: var(--tag-text);
+}
+
+.badge-words {
+ font-size: 12px;
+ color: var(--text-dim);
+}
+
+.book-progress-wrap {
+ margin: 14px 0 16px;
+}
+
+.book-progress-labels {
+ display: flex;
+ justify-content: space-between;
+ font-size: 12px;
+ color: var(--text-dim);
+ margin-bottom: 6px;
+}
+
+.book-progress-bar-bg {
+ width: 100%;
+ height: 6px;
+ background-color: var(--bg-input);
+ border-radius: var(--radius-full);
+ overflow: hidden;
+}
+
+.book-progress-bar-fill {
+ height: 100%;
+ background-color: var(--primary);
+ border-radius: var(--radius-full);
+ transition: width 0.3s ease;
+}
+
+.book-card-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border-color);
+}
+
+/* Reader Interface */
+.reader-interface {
+ background-color: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ overflow: hidden;
+}
+
+.reader-toolbar {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ background-color: var(--bg-surface);
+ border-bottom: 1px solid var(--border-color);
+ padding: 12px 18px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+.reader-toolbar-left {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
+.reader-meta {
+ display: flex;
+ flex-direction: column;
+}
+
+.reader-title {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-main);
+ line-height: 1.2;
+}
+
+.reader-author {
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.reader-toolbar-center {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.reader-select {
+ padding: 6px 12px;
+ border-radius: var(--radius-sm);
+ background-color: var(--bg-input);
+ color: var(--text-main);
+ border: 1px solid var(--border-color);
+ font-size: 13px;
+ outline: none;
+ max-width: 240px;
+}
+
+.reader-toolbar-right {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.font-controls {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.reader-scroll-area {
+ padding: 36px 24px 60px;
+ max-width: 780px;
+ margin: 0 auto;
+ min-height: 60vh;
+}
+
+.reader-content-body {
+ font-family: Georgia, 'Times New Roman', serif;
+ font-size: 19px;
+ line-height: 1.85;
+ color: var(--text-main);
+ word-break: break-word;
+ user-select: text;
+}
+
+.reader-content-body h1,
+.reader-content-body h2,
+.reader-content-body h3 {
+ font-family: var(--font-family);
+ margin: 28px 0 16px;
+ color: var(--text-main);
+}
+
+.reader-content-body p {
+ margin-bottom: 20px;
+ text-indent: 1.2em;
+}
+
+/* Interactive Words */
+.reader-word {
+ cursor: pointer;
+ border-radius: 3px;
+ transition: background-color 0.12s ease, color 0.12s ease;
+ padding: 1px 2px;
+}
+
+.reader-word:hover {
+ background-color: var(--primary-dim);
+ color: var(--primary);
+}
+
+.reader-word.status-mastered.highlight-enabled {
+ border-bottom: 2px dotted rgba(16, 185, 129, 0.5);
+}
+
+.reader-word.status-learning.highlight-enabled {
+ border-bottom: 2px dotted rgba(56, 189, 248, 0.5);
+}
+
+/* Click-to-Translate Popup */
+.reader-word-popup {
+ position: fixed;
+ z-index: 10001;
+ background-color: var(--bg-surface);
+ border: 1px solid var(--border-focus);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 12px 24px -4px rgba(0, 0, 0, 0.5);
+ width: 320px;
+ max-width: calc(100vw - 32px);
+ padding: 16px;
+ animation: fadeInScale 0.15s ease-out;
+}
+
+.popup-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.popup-term-wrap {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.popup-term {
+ font-size: 18px;
+ color: var(--text-main);
+}
+
+.popup-phonetic {
+ font-size: 13px;
+ color: var(--primary);
+ font-family: monospace;
+}
+
+.popup-close-btn {
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ font-size: 16px;
+ cursor: pointer;
+ padding: 4px;
+}
+
+.popup-close-btn:hover {
+ color: var(--text-main);
+}
+
+.popup-translation {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--success);
+ margin-bottom: 8px;
+}
+
+.popup-context {
+ font-size: 12px;
+ color: var(--text-muted);
+ font-style: italic;
+ background: var(--bg-input);
+ padding: 8px 10px;
+ border-radius: var(--radius-sm);
+ margin-bottom: 14px;
+ line-height: 1.4;
+ border-left: 3px solid var(--primary);
+}
+
+.popup-saved-badge {
+ text-align: center;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--success);
+ padding: 6px;
+}
+
+
diff --git a/app/static/index.html b/app/static/index.html
index f61b6af..41dd552 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -67,6 +67,9 @@
+
+
+
+
+
+
+
+
+
+
📖
+
Ваша библиотека пуста
+
Загрузите книгу в формате EPUB, текстовый файл TXT/MD или вставьте текст статьи, чтобы начать интерактивное чтение.
+
+
+
+
+
+
+
+
+
+
+
@@ -800,6 +889,33 @@
+
+
diff --git a/app/static/js/app.js b/app/static/js/app.js
index 259ee6f..267c9b0 100644
--- a/app/static/js/app.js
+++ b/app/static/js/app.js
@@ -45,6 +45,8 @@ document.addEventListener('DOMContentLoaded', () => {
const appShell = document.getElementById('app-shell');
const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-view');
+ const readerView = document.getElementById('reader-view');
+ const navReaderBtn = document.getElementById('nav-reader-btn');
const navBrandHome = document.getElementById('nav-brand-home');
const themeToggle = document.getElementById('theme-toggle');
const statTotal = document.getElementById('stat-total');
@@ -55,6 +57,53 @@ document.addEventListener('DOMContentLoaded', () => {
const exportOpenBtn = document.getElementById('export-open-btn');
const changePwdOpenBtn = document.getElementById('change-pwd-open-btn');
+ // Elements - Reader & Bookshelf
+ const bookshelfContainer = document.getElementById('bookshelf-container');
+ const bookshelfEmpty = document.getElementById('bookshelf-empty');
+ const bookshelfGrid = document.getElementById('bookshelf-grid');
+ const addTextBtn = document.getElementById('add-text-btn');
+ const bookFileInput = document.getElementById('book-file-input');
+ const addBookModal = document.getElementById('add-book-modal');
+ const closeAddBookModalBtn = document.getElementById('close-add-book-modal-btn');
+ const cancelAddBookBtn = document.getElementById('cancel-add-book-btn');
+ const addBookForm = document.getElementById('add-book-form');
+ const bookTitleInput = document.getElementById('book-title-input');
+ const bookAuthorInput = document.getElementById('book-author-input');
+ const bookContentInput = document.getElementById('book-content-input');
+
+ const readerInterface = document.getElementById('reader-interface');
+ const readerBackBtn = document.getElementById('reader-back-btn');
+ const readerBookTitle = document.getElementById('reader-book-title');
+ const readerBookAuthor = document.getElementById('reader-book-author');
+ const readerChapterSelect = document.getElementById('reader-chapter-select');
+ const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
+ const readerNextChBtn = document.getElementById('reader-next-ch-btn');
+ const readerFontDecBtn = document.getElementById('reader-font-dec-btn');
+ const readerFontIncBtn = document.getElementById('reader-font-inc-btn');
+ const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn');
+ const readerTrainBtn = document.getElementById('reader-train-btn');
+ const readerContentBody = document.getElementById('reader-content-body');
+ const readerScrollArea = document.getElementById('reader-scroll-area');
+
+ const readerWordPopup = document.getElementById('reader-word-popup');
+ const popupTerm = document.getElementById('popup-term');
+ const popupPhonetic = document.getElementById('popup-phonetic');
+ const popupAudioBtn = document.getElementById('popup-audio-btn');
+ const popupCloseBtn = document.getElementById('popup-close-btn');
+ const popupLoading = document.getElementById('popup-loading');
+ const popupContent = document.getElementById('popup-content');
+ const popupTranslation = document.getElementById('popup-translation');
+ const popupContext = document.getElementById('popup-context');
+ const popupSaveBtn = document.getElementById('popup-save-btn');
+ const popupSavedBadge = document.getElementById('popup-saved-badge');
+
+ // Reader State
+ let currentBook = null;
+ let currentChapterIndex = 0;
+ let readerFontSize = 19;
+ let isHighlightEnabled = false;
+ let popupCurrentData = null;
+
// Elements - Search & Capture
const termInput = document.getElementById('term-input');
const clearSearchBtn = document.getElementById('clear-search-btn');
@@ -891,19 +940,38 @@ document.addEventListener('DOMContentLoaded', () => {
renderSrsCurrentCard();
});
- function openSrsView() {
- dictionaryView.classList.add('hidden');
- srsView.classList.remove('hidden');
- startSrsTrainingSession(false);
- }
-
- function closeSrsView() {
+ function openDictionaryView() {
srsView.classList.add('hidden');
+ if (readerView) readerView.classList.add('hidden');
dictionaryView.classList.remove('hidden');
loadInitialData();
}
- async function startSrsTrainingSession(forceAll = false) {
+ function openSrsView(tagFilter = null) {
+ dictionaryView.classList.add('hidden');
+ if (readerView) readerView.classList.add('hidden');
+ srsView.classList.remove('hidden');
+ startSrsTrainingSession(false, tagFilter);
+ }
+
+ function closeSrsView() {
+ srsView.classList.add('hidden');
+ if (readerView) readerView.classList.add('hidden');
+ dictionaryView.classList.remove('hidden');
+ loadInitialData();
+ }
+
+ function openReaderView() {
+ dictionaryView.classList.add('hidden');
+ srsView.classList.add('hidden');
+ if (readerView) readerView.classList.remove('hidden');
+ loadBookshelf();
+ }
+
+ navBrandHome.addEventListener('click', () => openDictionaryView());
+ if (navReaderBtn) navReaderBtn.addEventListener('click', () => openReaderView());
+
+ async function startSrsTrainingSession(forceAll = false, tagFilter = null) {
srsLoading.classList.remove('hidden');
srsEmpty.classList.add('hidden');
srsSummary.classList.add('hidden');
@@ -914,9 +982,12 @@ document.addEventListener('DOMContentLoaded', () => {
let queue = [];
try {
- const url = forceAll
+ let url = forceAll
? `/api/srs/queue?limit=50`
: `/api/srs/queue?limit=25&mix=${srsSmartMix}`;
+ if (tagFilter) {
+ url += `&tag=${encodeURIComponent(tagFilter)}`;
+ }
const resp = await fetch(url);
if (resp.ok) {
const data = await resp.json();
@@ -1431,17 +1502,21 @@ document.addEventListener('DOMContentLoaded', () => {
kpiTotalWords.textContent = s.total_cards;
kpiTodayAdded.textContent = `Сегодня добавлено: +${s.today_added}`;
- // CEFR Level estimation
- const count = s.total_cards;
- let lvl = 'A1';
- let desc = 'Базовый словарный запас';
- if (count >= 2500) { lvl = 'C1'; desc = 'Продвинутый свободный уровень'; }
- else if (count >= 1200) { lvl = 'B2'; desc = 'Уверенное чтение тех. документации'; }
- else if (count >= 600) { lvl = 'B1'; desc = 'Понимание общих и рабочих тем'; }
- else if (count >= 200) { lvl = 'A2'; desc = 'Элементарный словарный запас'; }
-
- kpiCefrLevel.textContent = lvl;
- kpiCefrDesc.textContent = desc;
+ // Strict CEFR Level estimation (PLAN.md 4.3)
+ if (s.cefr) {
+ kpiCefrLevel.textContent = s.cefr.level;
+ kpiCefrDesc.textContent = `${s.cefr.title} • ${s.cefr.message}`;
+ } else {
+ const count = s.total_cards;
+ let lvl = 'A1';
+ let desc = 'Базовый словарный запас';
+ if (count >= 2500) { lvl = 'C1'; desc = 'Продвинутый свободный уровень'; }
+ else if (count >= 1200) { lvl = 'B2'; desc = 'Уверенное чтение тех. документации'; }
+ else if (count >= 600) { lvl = 'B1'; desc = 'Понимание общих и рабочих тем'; }
+ else if (count >= 200) { lvl = 'A2'; desc = 'Элементарный словарный запас'; }
+ kpiCefrLevel.textContent = lvl;
+ kpiCefrDesc.textContent = desc;
+ }
// Mastery Progress Bar
cntMastered.textContent = s.mastered;
@@ -1669,6 +1744,492 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
+ // =======================================================================
+ // STAGE 5: Interactive Reader & Bookshelf Implementation
+ // =======================================================================
+
+ function slugify(text) {
+ if (!text) return 'book';
+ return text.toString().toLowerCase().trim()
+ .replace(/[^\w\s-]/g, '')
+ .replace(/[\s_-]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+ }
+
+ async function loadBookshelf() {
+ if (!bookshelfContainer) return;
+ bookshelfContainer.classList.remove('hidden');
+ if (readerInterface) readerInterface.classList.add('hidden');
+ if (bookshelfGrid) bookshelfGrid.innerHTML = '';
+ if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden');
+
+ try {
+ const resp = await fetch('/api/books');
+ if (resp.ok) {
+ const books = await resp.json();
+ if (!books || books.length === 0) {
+ if (bookshelfEmpty) bookshelfEmpty.classList.remove('hidden');
+ } else {
+ renderBookshelf(books);
+ }
+ }
+ } catch (e) {
+ console.error('Error loading bookshelf:', e);
+ }
+ }
+
+ function renderBookshelf(books) {
+ if (!bookshelfGrid) return;
+ bookshelfGrid.innerHTML = '';
+ books.forEach(b => {
+ const card = document.createElement('div');
+ card.className = 'book-card';
+
+ const totalCh = b.chapters_count || 1;
+ const currentCh = (b.current_chapter || 0) + 1;
+ const pct = Math.min(100, Math.round((currentCh / totalCh) * 100));
+
+ card.innerHTML = `
+
+
+
${b.source_type === 'epub' ? '📕' : '📄'}
+
+
${escapeHtml(b.title)}
+
${escapeHtml(b.author || 'Неизвестный автор')}
+
+ ${escapeHtml(b.source_type)}
+ ${b.total_words ? b.total_words.toLocaleString() + ' слов' : ''}
+
+
+
+
+
+ Глава ${currentCh} из ${totalCh}
+ ${pct}%
+
+
+
+
+
+
+
+
+
+ `;
+
+ card.querySelector('.read-book-btn').addEventListener('click', () => openBook(b.id));
+ card.querySelector('.train-book-btn').addEventListener('click', () => {
+ const bookTag = slugify(b.title);
+ openSrsView(bookTag);
+ });
+ card.querySelector('.delete-book-btn').addEventListener('click', async () => {
+ if (confirm(`Удалить книгу "${b.title}" из библиотеки?`)) {
+ await fetch(`/api/books/${b.id}`, { method: 'DELETE' });
+ loadBookshelf();
+ }
+ });
+
+ bookshelfGrid.appendChild(card);
+ });
+ }
+
+ async function openBook(bookId) {
+ try {
+ const resp = await fetch(`/api/books/${bookId}`);
+ if (!resp.ok) return alert('Не удалось открыть книгу');
+ currentBook = await resp.json();
+ currentChapterIndex = currentBook.current_chapter || 0;
+
+ readerBookTitle.textContent = currentBook.title;
+ readerBookAuthor.textContent = currentBook.author || '';
+
+ // Populate chapter selector
+ readerChapterSelect.innerHTML = '';
+ const chapters = currentBook.chapters || [];
+ chapters.forEach((ch, idx) => {
+ const opt = document.createElement('option');
+ opt.value = idx;
+ opt.textContent = ch.title || `Глава ${idx + 1}`;
+ readerChapterSelect.appendChild(opt);
+ });
+
+ bookshelfContainer.classList.add('hidden');
+ readerInterface.classList.remove('hidden');
+
+ renderChapter(currentChapterIndex);
+ } catch (e) {
+ console.error('Error opening book:', e);
+ }
+ }
+
+ function renderChapter(index) {
+ if (!currentBook || !currentBook.chapters) return;
+ currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1));
+ readerChapterSelect.value = currentChapterIndex;
+
+ readerPrevChBtn.disabled = currentChapterIndex <= 0;
+ readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
+
+ const chapter = currentBook.chapters[currentChapterIndex] || { title: '', content: '' };
+ readerContentBody.innerHTML = '';
+
+ if (chapter.title) {
+ const h = document.createElement('h2');
+ h.textContent = chapter.title;
+ readerContentBody.appendChild(h);
+ }
+
+ // Map existing user vocabulary for status marking
+ const knownWordsMap = new Map();
+ allCards.forEach(c => {
+ if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
+ });
+
+ // Split content into paragraphs
+ const paragraphs = chapter.content.split(/\n\s*\n/);
+ paragraphs.forEach(paraText => {
+ paraText = paraText.trim();
+ if (!paraText) return;
+
+ const p = document.createElement('p');
+ // Tokenize words while preserving spaces & punctuation
+ const tokens = paraText.split(/([A-Za-z0-9'-]+)/);
+ tokens.forEach(token => {
+ if (/^[A-Za-z0-9'-]+$/.test(token) && token.length > 1) {
+ const span = document.createElement('span');
+ span.className = 'reader-word';
+ span.dataset.word = token;
+ span.textContent = token;
+
+ const lower = token.toLowerCase();
+ if (knownWordsMap.has(lower)) {
+ const card = knownWordsMap.get(lower);
+ span.classList.add(card.srs_state === 'mastered' ? 'status-mastered' : 'status-learning');
+ }
+ if (isHighlightEnabled) {
+ span.classList.add('highlight-enabled');
+ }
+
+ span.addEventListener('click', (e) => {
+ e.stopPropagation();
+ handleWordClick(token, paraText, span);
+ });
+
+ p.appendChild(span);
+ } else {
+ p.appendChild(document.createTextNode(token));
+ }
+ });
+
+ readerContentBody.appendChild(p);
+ });
+
+ if (readerScrollArea) readerScrollArea.scrollTop = 0;
+
+ // Save progress to server
+ fetch(`/api/books/${currentBook.id}/progress`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ current_chapter: currentChapterIndex, current_position: 0 }),
+ }).catch(err => console.log('Progress save error:', err));
+ }
+
+ // Extract cleanest surrounding sentence for context
+ function extractContextSentence(fullText, word) {
+ if (!fullText) return '';
+ const re = new RegExp(`([^.!?\\n]*?\\b${escapeRegex(word)}\\b[^.!?\\n]*[.!?]?)`, 'i');
+ const m = fullText.match(re);
+ if (m && m[1]) return m[1].trim();
+ return fullText.slice(0, 160).trim();
+ }
+
+ async function handleWordClick(word, paragraphText, targetElement) {
+ const sentence = extractContextSentence(paragraphText, word);
+ openWordPopup(word, sentence, targetElement);
+ }
+
+ async function openWordPopup(term, contextSentence, targetElement) {
+ popupCurrentData = {
+ term: term,
+ contextSentence: contextSentence,
+ targetElement: targetElement,
+ };
+
+ popupTerm.textContent = term;
+ popupPhonetic.textContent = '';
+ popupTranslation.textContent = '';
+ popupContext.textContent = contextSentence ? `“${contextSentence}”` : '';
+ popupLoading.classList.remove('hidden');
+ popupContent.classList.add('hidden');
+ popupSavedBadge.classList.add('hidden');
+ popupSaveBtn.classList.remove('hidden');
+ popupSaveBtn.disabled = false;
+
+ // Position popup near target element
+ readerWordPopup.classList.remove('hidden');
+ if (targetElement) {
+ const rect = targetElement.getBoundingClientRect();
+ let top = rect.bottom + window.scrollY + 6;
+ let left = rect.left + window.scrollX;
+
+ const popupWidth = 320;
+ if (left + popupWidth > window.innerWidth - 16) {
+ left = window.innerWidth - popupWidth - 16;
+ }
+ if (left < 16) left = 16;
+
+ readerWordPopup.style.top = `${top}px`;
+ readerWordPopup.style.left = `${left}px`;
+ }
+
+ try {
+ const resp = await fetch(`/api/lookup?q=${encodeURIComponent(term)}`);
+ if (resp.ok) {
+ const data = await resp.json();
+ popupCurrentData.lookup = data;
+
+ popupPhonetic.textContent = data.phonetic || '';
+ popupTranslation.textContent = data.translation_ru || 'Перевод не найден';
+ popupLoading.classList.add('hidden');
+ popupContent.classList.remove('hidden');
+
+ if (data.audio_url) {
+ popupAudioBtn.classList.remove('hidden');
+ popupAudioBtn.onclick = () => playAudio(data.audio_url);
+ } else {
+ popupAudioBtn.classList.add('hidden');
+ }
+ } else {
+ popupTranslation.textContent = 'Ошибка перевода';
+ popupLoading.classList.add('hidden');
+ popupContent.classList.remove('hidden');
+ }
+ } catch (e) {
+ console.error('Popup lookup error:', e);
+ popupTranslation.textContent = 'Сетевая ошибка';
+ popupLoading.classList.add('hidden');
+ popupContent.classList.remove('hidden');
+ }
+ }
+
+ if (popupCloseBtn) {
+ popupCloseBtn.addEventListener('click', () => {
+ readerWordPopup.classList.add('hidden');
+ });
+ }
+
+ document.addEventListener('click', (e) => {
+ if (readerWordPopup && !readerWordPopup.contains(e.target) && !e.target.classList.contains('reader-word')) {
+ readerWordPopup.classList.add('hidden');
+ }
+ });
+
+ if (popupSaveBtn) {
+ popupSaveBtn.addEventListener('click', async () => {
+ if (!popupCurrentData) return;
+ const d = popupCurrentData;
+ const lookup = d.lookup || {};
+ const bookTag = currentBook ? slugify(currentBook.title) : 'reader';
+
+ const payload = {
+ term: d.term,
+ translation: lookup.translation_ru || popupTranslation.textContent,
+ context_sentence: d.contextSentence || '',
+ phonetic: lookup.phonetic || '',
+ audio_url: lookup.audio_url || '',
+ part_of_speech: lookup.part_of_speech || '',
+ definition_en: lookup.definition_en || '',
+ collection: 'Книги & Сериалы',
+ tags: `${bookTag}, reader`,
+ notes: currentBook ? `Из книги: ${currentBook.title}` : '',
+ };
+
+ try {
+ const resp = await fetch('/api/cards', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+
+ if (resp.ok) {
+ popupSaveBtn.classList.add('hidden');
+ popupSavedBadge.classList.remove('hidden');
+
+ if (d.targetElement) {
+ d.targetElement.classList.add('status-learning');
+ if (isHighlightEnabled) d.targetElement.classList.add('highlight-enabled');
+ }
+
+ await fetchCards();
+ await fetchTags();
+ } else {
+ alert('Не удалось сохранить слово');
+ }
+ } catch (e) {
+ console.error('Save card from reader error:', e);
+ }
+ });
+ }
+
+ // Phrase/Chunk Selection in Reader
+ if (readerContentBody) {
+ readerContentBody.addEventListener('mouseup', () => {
+ const sel = window.getSelection();
+ const text = sel.toString().trim();
+ if (text && text.split(/\s+/).length >= 2 && text.length <= 80) {
+ const range = sel.getRangeAt(0);
+ const rect = range.getBoundingClientRect();
+ openWordPopup(text, text, {
+ getBoundingClientRect: () => rect,
+ });
+ }
+ });
+ }
+
+ // Chapter Navigation & Reader Toolbar
+ if (readerChapterSelect) {
+ readerChapterSelect.addEventListener('change', () => {
+ renderChapter(parseInt(readerChapterSelect.value, 10));
+ });
+ }
+
+ if (readerPrevChBtn) {
+ readerPrevChBtn.addEventListener('click', () => {
+ if (currentChapterIndex > 0) renderChapter(currentChapterIndex - 1);
+ });
+ }
+
+ if (readerNextChBtn) {
+ readerNextChBtn.addEventListener('click', () => {
+ if (currentBook && currentChapterIndex < currentBook.chapters.length - 1) {
+ renderChapter(currentChapterIndex + 1);
+ }
+ });
+ }
+
+ if (readerBackBtn) {
+ readerBackBtn.addEventListener('click', () => {
+ readerInterface.classList.add('hidden');
+ bookshelfContainer.classList.remove('hidden');
+ loadBookshelf();
+ });
+ }
+
+ if (readerFontIncBtn) {
+ readerFontIncBtn.addEventListener('click', () => {
+ if (readerFontSize < 28) {
+ readerFontSize += 2;
+ readerContentBody.style.fontSize = `${readerFontSize}px`;
+ }
+ });
+ }
+
+ if (readerFontDecBtn) {
+ readerFontDecBtn.addEventListener('click', () => {
+ if (readerFontSize > 14) {
+ readerFontSize -= 2;
+ readerContentBody.style.fontSize = `${readerFontSize}px`;
+ }
+ });
+ }
+
+ if (readerToggleHighlightBtn) {
+ readerToggleHighlightBtn.addEventListener('click', () => {
+ isHighlightEnabled = !isHighlightEnabled;
+ readerToggleHighlightBtn.classList.toggle('btn-primary', isHighlightEnabled);
+ readerContentBody.querySelectorAll('.reader-word').forEach(w => {
+ w.classList.toggle('highlight-enabled', isHighlightEnabled);
+ });
+ });
+ }
+
+ if (readerTrainBtn) {
+ readerTrainBtn.addEventListener('click', () => {
+ if (currentBook) {
+ const bookTag = slugify(currentBook.title);
+ openSrsView(bookTag);
+ }
+ });
+ }
+
+ // Bookshelf Add/Upload Actions
+ if (addTextBtn) {
+ addTextBtn.addEventListener('click', () => {
+ addBookForm.reset();
+ addBookModal.classList.remove('hidden');
+ });
+ }
+
+ if (closeAddBookModalBtn) closeAddBookModalBtn.addEventListener('click', () => addBookModal.classList.add('hidden'));
+ if (cancelAddBookBtn) cancelAddBookBtn.addEventListener('click', () => addBookModal.classList.add('hidden'));
+
+ if (addBookForm) {
+ addBookForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const title = bookTitleInput.value.trim();
+ const author = bookAuthorInput.value.trim();
+ const content = bookContentInput.value.trim();
+
+ if (!title || !content) return;
+
+ try {
+ const resp = await fetch('/api/books', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: title,
+ author: author,
+ content: content,
+ source_type: 'text',
+ }),
+ });
+
+ if (resp.ok) {
+ const book = await resp.json();
+ addBookModal.classList.add('hidden');
+ await loadBookshelf();
+ openBook(book.id);
+ } else {
+ alert('Ошибка при сохранении книги');
+ }
+ } catch (err) {
+ console.error('Book create error:', err);
+ }
+ });
+ }
+
+ if (bookFileInput) {
+ bookFileInput.addEventListener('change', async () => {
+ const file = bookFileInput.files[0];
+ if (!file) return;
+
+ const formData = new FormData();
+ formData.append('file', file);
+
+ try {
+ const resp = await fetch('/api/books', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (resp.ok) {
+ const book = await resp.json();
+ bookFileInput.value = '';
+ await loadBookshelf();
+ openBook(book.id);
+ } else {
+ const errData = await resp.json();
+ alert(`Ошибка загрузки: ${errData.detail || 'Не удалось обработать файл'}`);
+ }
+ } catch (e) {
+ console.error('File upload error:', e);
+ alert('Ошибка сети при загрузке файла');
+ }
+ });
+ }
+
// -----------------------------------------------------------------------
// Utility helpers
// -----------------------------------------------------------------------
diff --git a/tests/test_api.py b/tests/test_api.py
index b57cb73..e6351e9 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -503,4 +503,290 @@ def test_change_password_flow():
assert new_auth["username"] == "pwd_user"
+# ---------------------------------------------------------------------------
+# Stage 5: Bookshelf & Interactive Reader Tests
+# ---------------------------------------------------------------------------
+
+def make_mock_epub(title="The Time Machine", author="H. G. Wells", chapters=None):
+ import io, zipfile
+ if chapters is None:
+ chapters = [
+ ("Chapter 1: The Machine", "
The Time Traveller was expounding a recondite matter to us.
"),
+ ("Chapter 2: The Journey", "
I seemed to see the clock hands spin around.
")
+ ]
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as z:
+ z.writestr("META-INF/container.xml", """
+
+
+
+
+""")
+ manifest_items = []
+ spine_items = []
+ for i, (ch_title, ch_body) in enumerate(chapters, 1):
+ manifest_items.append(f'
')
+ spine_items.append(f'
')
+ z.writestr(f"OEBPS/ch{i}.xhtml", f"""
+
+
{ch_title}
+
+
{ch_title}
+{ch_body}
+
+""")
+
+ opf = f"""
+
+
+ {title}
+ {author}
+
+
+ {''.join(manifest_items)}
+
+
+ {''.join(spine_items)}
+
+"""
+ z.writestr("OEBPS/content.opf", opf)
+ return buf.getvalue()
+
+
+def test_reader_unit():
+ from app.reader import count_words, parse_text, parse_epub
+
+ # 1. count_words
+ assert count_words("") == 0
+ assert count_words(" \n\t ") == 0
+ assert count_words("Hello world!") == 2
+ assert count_words("Привет, мир! Это тест.") == 4
+ assert count_words("Don't stop believing") == 3
+
+ # 2. parse_text with Markdown headers
+ md_content = """# My Book
+Author: Arthur Dent
+
+## Chapter 1: Hitchhiker
+Far out in the uncharted backwaters.
+
+## Chapter 2: The Towel
+A towel is about the most useful thing.
+"""
+ t, a, chs = parse_text(md_content)
+ assert t == "My Book"
+ assert a == "Arthur Dent"
+ assert len(chs) == 2
+ assert "Chapter 1: Hitchhiker" in chs[0]["title"]
+ assert "Far out" in chs[0]["content"]
+
+ # 3. parse_text with Chapter X headings
+ txt_content = """Chapter 1. The Boy Who Lived
+Mr. and Mrs. Dursley of number four Privet Drive.
+
+Chapter 2 - The Vanishing Glass
+Nearly ten years had passed.
+"""
+ t, a, chs = parse_text(txt_content, title="Harry Potter", author="J.K. Rowling")
+ assert t == "Harry Potter"
+ assert a == "J.K. Rowling"
+ assert len(chs) == 2
+ assert "Chapter 1" in chs[0]["title"]
+ assert "Chapter 2" in chs[1]["title"]
+
+ # 4. parse_text chunking on long text
+ long_words = ["word"] * 7000
+ long_text = "\n\n".join([" ".join(long_words[i:i + 400]) for i in range(0, len(long_words), 400)])
+ t, a, chs = parse_text(long_text, title="Long Essay")
+ assert t == "Long Essay"
+ assert len(chs) >= 2
+
+ # 5. parse_epub
+ epub_bytes = make_mock_epub("Brave New World", "Aldous Huxley")
+ t, a, chs = parse_epub(epub_bytes)
+ assert t == "Brave New World"
+ assert a == "Aldous Huxley"
+ assert len(chs) == 2
+ assert "Chapter 1: The Machine" in chs[0]["title"]
+ assert "Time Traveller" in chs[0]["content"]
+
+ # 6. parse_epub invalid archive error
+ with pytest.raises(ValueError):
+ parse_epub(b"not a zip file")
+
+
+def test_books_unauthenticated_blocked():
+ unauth = TestClient(app)
+ resp = unauth.get("/api/books")
+ assert resp.status_code == 401
+
+ resp = unauth.post("/api/books", json={"title": "Test", "content": "Text"})
+ assert resp.status_code == 401
+
+ resp = unauth.get("/api/books/1")
+ assert resp.status_code == 401
+
+ resp = unauth.put("/api/books/1/progress", json={"current_chapter": 1, "current_position": 0.5})
+ assert resp.status_code == 401
+
+ resp = unauth.delete("/api/books/1")
+ assert resp.status_code == 401
+
+
+def test_books_crud_workflow():
+ # Login as admin
+ client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
+
+ # 1. Create book via JSON
+ payload = {
+ "title": "Fight Club",
+ "author": "Chuck Palahniuk",
+ "content": "Chapter 1\nTyler gets me a job.\n\nChapter 2\nA guy had come to the club.",
+ "source_type": "text"
+ }
+ resp = client.post("/api/books", json=payload)
+ assert resp.status_code == 200
+ book = resp.json()
+ assert book["id"] > 0
+ book_id = book["id"]
+ assert book["title"] == "Fight Club"
+ assert book["author"] == "Chuck Palahniuk"
+ assert book["source_type"] == "text"
+ assert book["chapters_count"] == 2
+ assert book["total_words"] > 0
+ assert book["current_chapter"] == 0
+ assert book["current_position"] == 0.0
+
+ # 2. List books (ensure chapters_json is excluded from list)
+ resp = client.get("/api/books")
+ assert resp.status_code == 200
+ books = resp.json()
+ assert isinstance(books, list)
+ target = next((b for b in books if b["id"] == book_id), None)
+ assert target is not None
+ assert target["title"] == "Fight Club"
+ assert target["chapters_count"] == 2
+ assert "chapters_json" not in target
+
+ # 3. Get single book (includes chapters_json and chapters)
+ resp = client.get(f"/api/books/{book_id}")
+ assert resp.status_code == 200
+ detail = resp.json()
+ assert detail["id"] == book_id
+ assert "chapters_json" in detail
+ assert "chapters" in detail
+ assert len(detail["chapters"]) == 2
+ assert "Chapter 1" in detail["chapters"][0]["title"]
+
+ # 4. Update reading progress
+ resp = client.put(f"/api/books/{book_id}/progress", json={
+ "current_chapter": 1,
+ "current_position": 0.75
+ })
+ assert resp.status_code == 200
+ updated = resp.json()
+ assert updated["current_chapter"] == 1
+ assert updated["current_position"] == 0.75
+
+ # 5. Delete book
+ resp = client.delete(f"/api/books/{book_id}")
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "ok"
+ assert resp.json()["deleted_id"] == book_id
+
+ # 6. Verify deleted (404)
+ resp = client.get(f"/api/books/{book_id}")
+ assert resp.status_code == 404
+
+
+def test_books_multipart_file_uploads():
+ # Login
+ client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
+
+ # 1. Upload TXT file
+ txt_data = b"Chapter 1: The Start\nOnce upon a time in a distant land.\n\nChapter 2: The End\nThey lived happily ever after."
+ resp = client.post(
+ "/api/books",
+ files={"file": ("fairytale.txt", txt_data, "text/plain")},
+ data={"title": "Fairy Tale", "author": "Brothers Grimm"}
+ )
+ assert resp.status_code == 200
+ book_txt = resp.json()
+ assert book_txt["title"] == "Fairy Tale"
+ assert book_txt["author"] == "Brothers Grimm"
+ assert book_txt["source_type"] == "txt"
+ assert book_txt["chapters_count"] == 2
+
+ # 2. Upload Markdown file
+ md_data = b"# The Cyberpunk Guide\nBy: Anonymous\n\n## Section 1\nWelcome to the matrix.\n\n## Section 2\nJack into cyberspace."
+ resp = client.post(
+ "/api/books",
+ files={"file": ("cyberpunk.md", md_data, "text/markdown")},
+ )
+ assert resp.status_code == 200
+ book_md = resp.json()
+ assert book_md["title"] == "The Cyberpunk Guide"
+ assert book_md["author"] == "Anonymous"
+ assert book_md["source_type"] == "md"
+ assert book_md["chapters_count"] == 2
+
+ # 3. Upload EPUB file
+ epub_bytes = make_mock_epub("The Invisible Man", "H. G. Wells")
+ resp = client.post(
+ "/api/books",
+ files={"file": ("invisible_man.epub", epub_bytes, "application/epub+zip")},
+ )
+ assert resp.status_code == 200
+ book_epub = resp.json()
+ assert book_epub["title"] == "The Invisible Man"
+ assert book_epub["author"] == "H. G. Wells"
+ assert book_epub["source_type"] == "epub"
+ assert book_epub["chapters_count"] == 2
+
+
+def test_srs_queue_and_quiz_tag_filtering():
+ # Login
+ client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
+
+ # Create distinct tagged cards for book-scoped testing
+ c1 = client.post("/api/cards", json={
+ "term": "recondite",
+ "translation": "малопонятный, глубокий",
+ "tags": "book:time-machine, scifi",
+ "collection": "Literature"
+ }).json()
+
+ c2 = client.post("/api/cards", json={
+ "term": "invisible",
+ "translation": "невидимый",
+ "tags": "book:invisible-man, scifi",
+ "collection": "Literature"
+ }).json()
+
+ c3 = client.post("/api/cards", json={
+ "term": "soap",
+ "translation": "мыло",
+ "tags": "book:fight-club",
+ "collection": "Fiction"
+ }).json()
+
+ # 1. Filter queue by tag
+ resp = client.get("/api/srs/queue?tag=book:time-machine")
+ assert resp.status_code == 200
+ queue_cards = resp.json()["cards"]
+ card_ids = [c["id"] for c in queue_cards]
+ assert c1["id"] in card_ids
+ assert c2["id"] not in card_ids
+ assert c3["id"] not in card_ids
+
+ # 2. Filter quiz by tag
+ resp = client.get("/api/srs/quiz?tag=book:fight-club")
+ assert resp.status_code == 200
+ quiz_items = resp.json()["quiz"]
+ assert any(q["card"]["id"] == c3["id"] for q in quiz_items)
+ assert not any(q["card"]["id"] == c1["id"] for q in quiz_items)
+ assert not any(q["card"]["id"] == c2["id"] for q in quiz_items)
+
+
+