From d8a2c6e34f91365e3d498f2910b21a1ad349887c Mon Sep 17 00:00:00 2001 From: Antigravity Date: Mon, 5 Oct 2026 11:42:13 +0300 Subject: [PATCH] feat: implement Stage 5 (Interactive Reader, EPUB/TXT parser, Bookshelf, Click-to-Translate) and Stage 4.3 (Strict CEFR level calculation) --- PLAN.md | 10 +- app/database.py | 35 +++ app/main.py | 297 ++++++++++++++++++- app/reader.py | 350 +++++++++++++++++++++++ app/srs.py | 54 ++++ app/static/css/style.css | 382 +++++++++++++++++++++++++ app/static/index.html | 116 ++++++++ app/static/js/app.js | 601 +++++++++++++++++++++++++++++++++++++-- tests/test_api.py | 286 +++++++++++++++++++ 9 files changed, 2101 insertions(+), 30 deletions(-) create mode 100644 app/reader.py 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'<item\s+[^>]*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'<itemref\s+[^>]*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 @@ </div> <div class="nav-actions"> + <button id="nav-reader-btn" class="btn btn-outline btn-sm" title="Интерактивная читалка и библиотека книг"> + <span>📚 Читалка</span> + </button> <button id="srs-trainer-btn" class="btn btn-trainer" title="Интервальный тренажер (Flashcards, 4 варианта, диктант, ввод)"> <span class="trainer-icon">🎓</span> <span class="trainer-text">Тренажёр SRS</span> @@ -517,6 +520,92 @@ </div> </section> + <!-- VIEW 3: Interactive Reader & Bookshelf View --> + <section id="reader-view" class="reader-view hidden"> + <!-- Bookshelf View --> + <div id="bookshelf-container" class="bookshelf-container"> + <div class="bookshelf-header"> + <div class="bookshelf-title-wrap"> + <h2>📚 Библиотека & Интерактивная Читалка</h2> + <p class="subtitle">Читайте тексты и книги с мгновенным клик-переводом, озвучкой и авто-захватом контекста</p> + </div> + <div class="bookshelf-actions"> + <button id="add-text-btn" class="btn btn-outline">✏️ Вставить текст</button> + <label for="book-file-input" class="btn btn-primary file-upload-label"> + 📁 Загрузить EPUB / TXT + <input type="file" id="book-file-input" accept=".epub,.txt,.md" style="display: none;"> + </label> + </div> + </div> + + <!-- Bookshelf Grid / Empty --> + <div id="bookshelf-empty" class="empty-state hidden"> + <div class="empty-icon">📖</div> + <h3>Ваша библиотека пуста</h3> + <p>Загрузите книгу в формате EPUB, текстовый файл TXT/MD или вставьте текст статьи, чтобы начать интерактивное чтение.</p> + </div> + <div id="bookshelf-grid" class="bookshelf-grid"></div> + </div> + + <!-- Reading Interface (Active when a book is open) --> + <div id="reader-interface" class="reader-interface hidden"> + <header class="reader-toolbar"> + <div class="reader-toolbar-left"> + <button id="reader-back-btn" class="btn btn-sm btn-outline">← Библиотека</button> + <div class="reader-meta"> + <h3 id="reader-book-title" class="reader-title">Книга</h3> + <span id="reader-book-author" class="reader-author">Автор</span> + </div> + </div> + + <div class="reader-toolbar-center"> + <button id="reader-prev-ch-btn" class="btn btn-xs btn-outline" title="Предыдущая глава">◀</button> + <select id="reader-chapter-select" class="reader-select"></select> + <button id="reader-next-ch-btn" class="btn btn-xs btn-outline" title="Следующая глава">▶</button> + </div> + + <div class="reader-toolbar-right"> + <div class="font-controls"> + <button id="reader-font-dec-btn" class="btn btn-xs btn-outline" title="Уменьшить шрифт">A-</button> + <button id="reader-font-inc-btn" class="btn btn-xs btn-outline" title="Увеличить шрифт">A+</button> + </div> + <button id="reader-toggle-highlight-btn" class="btn btn-xs btn-outline" title="Вкл/Выкл подсветку изучаемых слов">✨ Подсветка</button> + <button id="reader-train-btn" class="btn btn-xs btn-trainer" title="Тренировать слова из этой книги">🎓 Слова книги</button> + </div> + </header> + + <!-- Reading Container --> + <div class="reader-scroll-area" id="reader-scroll-area"> + <div class="reader-content-body" id="reader-content-body"> + <!-- Interactive paragraphs dynamically rendered here --> + </div> + </div> + </div> + + <!-- Interactive Click-to-Translate Popup --> + <div id="reader-word-popup" class="reader-word-popup hidden"> + <div class="popup-header"> + <div class="popup-term-wrap"> + <strong id="popup-term" class="popup-term">word</strong> + <span id="popup-phonetic" class="popup-phonetic">/wɜːd/</span> + <button id="popup-audio-btn" class="icon-btn audio-btn" title="Слушать произношение">🔊</button> + </div> + <button id="popup-close-btn" class="popup-close-btn">✕</button> + </div> + <div class="popup-body"> + <div id="popup-loading" class="popup-loading hidden">Переводим...</div> + <div id="popup-content" class="popup-content"> + <div class="popup-translation" id="popup-translation">перевод</div> + <div class="popup-context" id="popup-context">“Контекст из книги...”</div> + </div> + </div> + <div class="popup-footer"> + <button id="popup-save-btn" class="btn btn-primary btn-sm btn-block">💾 Добавить в словарь</button> + <div id="popup-saved-badge" class="popup-saved-badge hidden">✅ Сохранено в словарь!</div> + </div> + </div> + </section> + <!-- Sticky Bulk Action Bar for Batch Selection Mode --> <div id="batch-action-bar" class="batch-action-bar hidden"> <div class="batch-left"> @@ -800,6 +889,33 @@ <button type="submit" id="change-pwd-save-btn" class="btn btn-primary">Сохранить</button> </div> </form> + <!-- Add Book Modal (Paste Text) --> + <div id="add-book-modal" class="modal-backdrop hidden"> + <div class="modal-window"> + <div class="modal-header"> + <h3>✏️ Добавить текст или статью</h3> + <button id="close-add-book-modal-btn" class="icon-btn">✕</button> + </div> + <form id="add-book-form"> + <div class="modal-body"> + <div class="form-group"> + <label for="book-title-input">Название книги / статьи *</label> + <input type="text" id="book-title-input" class="input-styled" required placeholder="Например: Clean Code Principles"> + </div> + <div class="form-group"> + <label for="book-author-input">Автор / Источник</label> + <input type="text" id="book-author-input" class="input-styled" placeholder="Например: Robert C. Martin"> + </div> + <div class="form-group"> + <label for="book-content-input">Текст материала (English) *</label> + <textarea id="book-content-input" class="input-styled" rows="10" required placeholder="Вставьте английский текст книги, статьи или документации..."></textarea> + </div> + </div> + <div class="modal-footer"> + <button type="button" id="cancel-add-book-btn" class="btn btn-outline">Отмена</button> + <button type="submit" id="submit-add-book-btn" class="btn btn-primary">📚 Сохранить в библиотеку</button> + </div> + </form> </div> </div> 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 = ` + <div> + <div class="book-card-top"> + <div class="book-cover-icon">${b.source_type === 'epub' ? '📕' : '📄'}</div> + <div class="book-card-info"> + <h4 class="book-card-title" title="${escapeHtml(b.title)}">${escapeHtml(b.title)}</h4> + <p class="book-card-author">${escapeHtml(b.author || 'Неизвестный автор')}</p> + <div class="book-card-badges"> + <span class="badge-format">${escapeHtml(b.source_type)}</span> + <span class="badge-words">${b.total_words ? b.total_words.toLocaleString() + ' слов' : ''}</span> + </div> + </div> + </div> + <div class="book-progress-wrap"> + <div class="book-progress-labels"> + <span>Глава ${currentCh} из ${totalCh}</span> + <span>${pct}%</span> + </div> + <div class="book-progress-bar-bg"> + <div class="book-progress-bar-fill" style="width: ${pct}%"></div> + </div> + </div> + </div> + <div class="book-card-actions"> + <button class="btn btn-primary btn-sm read-book-btn" data-id="${b.id}">📖 Читать</button> + <button class="btn btn-outline btn-xs train-book-btn" data-title="${escapeHtml(b.title)}" title="Тренировать слова из этой книги">🎓 Слова книги</button> + <button class="icon-btn delete-book-btn" data-id="${b.id}" title="Удалить книгу">🗑️</button> + </div> + `; + + 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", "<p>The Time Traveller was expounding a recondite matter to us.</p>"), + ("Chapter 2: The Journey", "<p>I seemed to see the clock hands spin around.</p>") + ] + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as z: + z.writestr("META-INF/container.xml", """<?xml version="1.0"?> +<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"> + <rootfiles> + <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/> + </rootfiles> +</container>""") + manifest_items = [] + spine_items = [] + for i, (ch_title, ch_body) in enumerate(chapters, 1): + manifest_items.append(f'<item id="c{i}" href="ch{i}.xhtml" media-type="application/xhtml+xml"/>') + spine_items.append(f'<itemref idref="c{i}"/>') + z.writestr(f"OEBPS/ch{i}.xhtml", f"""<!DOCTYPE html> +<html> +<head><title>{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) + + +