feat: implement Stage 5 (Interactive Reader, EPUB/TXT parser, Bookshelf, Click-to-Translate) and Stage 4.3 (Strict CEFR level calculation)

This commit is contained in:
2026-10-05 11:42:13 +03:00
parent c51c505b61
commit d8a2c6e34f
9 changed files with 2101 additions and 30 deletions
+5 -5
View File
@@ -146,7 +146,7 @@
- Процент точности запоминания (Retention Rate %). - Процент точности запоминания (Retention Rate %).
- [ ] **4.2. Быстрый захват из браузера**: - [ ] **4.2. Быстрый захват из браузера**:
- Букмарклет для выделения текста на любой странице и отправки в словарь. - Букмарклет для выделения текста на любой странице и отправки в словарь.
- [ ] **4.3. Честный и строгий расчёт уровня CEFR (Active Vocabulary & Retention Weighting)**: - [x] **4.3. Честный и строгий расчёт уровня CEFR (Active Vocabulary & Retention Weighting)**:
- Расчёт уровня владения исключительно по **активным выученным словам** (стадии `Mastered` и `Review` в алгоритме SM-2), исключая пассивные добавленные карточки, которые ещё не прошли интервальные повторения. - Расчёт уровня владения исключительно по **активным выученным словам** (стадии `Mastered` и `Review` в алгоритме SM-2), исключая пассивные добавленные карточки, которые ещё не прошли интервальные повторения.
- Корректирующий коэффициент точности: учёт Retention Rate %, чтобы уровень отражал реальную память, а не просто количество выписанных слов. - Корректирующий коэффициент точности: учёт Retention Rate %, чтобы уровень отражал реальную память, а не просто количество выписанных слов.
- Индикатор пути к следующему уровню (например: «*До уровня B2 осталось закрепить 142 слова*»). - Индикатор пути к следующему уровню (например: «*До уровня B2 осталось закрепить 142 слова*»).
@@ -156,19 +156,19 @@
### Этап 5: Интерактивная Читалка & Библиотека (Interactive Reader & Source-based Learning) ### Этап 5: Интерактивная Читалка & Библиотека (Interactive Reader & Source-based Learning)
*Цель: чтение живых книг, статей и документации с мгновенным клик-переводом и авто-тегированием источников.* *Цель: чтение живых книг, статей и документации с мгновенным клик-переводом и авто-тегированием источников.*
- [ ] **5.1. Хранилище текстов и парсер форматов (Bookshelf & Document Engine)**: - [x] **5.1. Хранилище текстов и парсер форматов (Bookshelf & Document Engine)**:
- Поддержка форматов: вставка текста из буфера, `.txt`, `.md`, электронные книги `.epub` (с разбивкой по главам и сохранением позиции чтения). - Поддержка форматов: вставка текста из буфера, `.txt`, `.md`, электронные книги `.epub` (с разбивкой по главам и сохранением позиции чтения).
- Умная очистка веб-статей от лишней верстки и рекламы (Reader view). - Умная очистка веб-статей от лишней верстки и рекламы (Reader view).
- [ ] **5.2. Автоматическое тегирование источников (Source & Metadata Auto-tagging)**: - [x] **5.2. Автоматическое тегирование источников (Source & Metadata Auto-tagging)**:
- Каждое слово, сохранённое из книги/статьи, автоматически получает теги источника: - Каждое слово, сохранённое из книги/статьи, автоматически получает теги источника:
- Название произведения (например, `#fight-club`, `#the-martian`, `#designing-data-intensive-apps`). - Название произведения (например, `#fight-club`, `#the-martian`, `#designing-data-intensive-apps`).
- Тип/жанр материала (например, `#novel`, `#tech-article`, `#documentation`, `#cybersecurity`). - Тип/жанр материала (например, `#novel`, `#tech-article`, `#documentation`, `#cybersecurity`).
- Автор / Источник (например, `#author:chuck-palahniuk`, `#cloudflare-blog`). - Автор / Источник (например, `#author:chuck-palahniuk`, `#cloudflare-blog`).
- Автоматический захват предложения-контекста, в котором слово встретилось в книге. - Автоматический захват предложения-контекста, в котором слово встретилось в книге.
- [ ] **5.3. Запуск тестов и тренировок по источникам и тегам (Tag-scoped SRS Quizzes)**: - [x] **5.3. Запуск тестов и тренировок по источникам и тегам (Tag-scoped SRS Quizzes)**:
- Возможность запустить любую тренировку (Карточки, 4 варианта ответа, Набор слова, Диктант) **строго по словам конкретной книги, статьи или автора**. - Возможность запустить любую тренировку (Карточки, 4 варианта ответа, Набор слова, Диктант) **строго по словам конкретной книги, статьи или автора**.
- Быстрый запуск из читалки: *«Повторить все 35 новых слов из Fight Club»* прямо во время или после прочтения главы. - Быстрый запуск из читалки: *«Повторить все 35 новых слов из Fight Club»* прямо во время или после прочтения главы.
- [ ] **5.4. Интерактивный клик-перевод и подсветка (Interactive Reader Layer)**: - [x] **5.4. Интерактивный клик-перевод и подсветка (Interactive Reader Layer)**:
- Клик по любому слову $\rightarrow$ моментальный перевод, транскрипция, нативное аудио и кнопка «+ В словарь» в 1 клик. - Клик по любому слову $\rightarrow$ моментальный перевод, транскрипция, нативное аудио и кнопка «+ В словарь» в 1 клик.
- Выделение фраз/связок (chunks) мышкой или тапом с литературным переводом всего выражения. - Выделение фраз/связок (chunks) мышкой или тапом с литературным переводом всего выражения.
- Подсветка в тексте книги уже выученных (`mastered`) и изучаемых (`learning`) слов. - Подсветка в тексте книги уже выученных (`mastered`) и изучаемых (`learning`) слов.
+35
View File
@@ -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_user ON srs_logs(user_id);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_srs_reviewed ON srs_logs(user_id, reviewed_at);") 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(): def init_db():
config.DB_PATH.parent.mkdir(parents=True, exist_ok=True) config.DB_PATH.parent.mkdir(parents=True, exist_ok=True)
@@ -138,6 +157,22 @@ def init_db():
response_json TEXT NOT NULL, response_json TEXT NOT NULL,
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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 # Apply migrations for pre-existing databases
_migrate(conn) _migrate(conn)
+292 -5
View File
@@ -1,5 +1,7 @@
import os import os
import json
import urllib.parse import urllib.parse
from pathlib import Path
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from datetime import datetime, timezone, date from datetime import datetime, timezone, date
@@ -24,7 +26,8 @@ from app.auth import (
) )
from app.dictionary import lookup_term, USER_AGENT from app.dictionary import lookup_term, USER_AGENT
from app.export import export_csv, export_json, export_anki_tsv, export_markdown 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 @asynccontextmanager
@@ -131,6 +134,20 @@ class SrsReviewRequest(BaseModel):
rating: int = Field(..., ge=1, le=4) 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 # PWA Static Endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -528,6 +545,7 @@ async def api_batch_action(req: BatchActionRequest, user: dict = Depends(require
@app.get("/api/srs/due") @app.get("/api/srs/due")
async def api_srs_queue( async def api_srs_queue(
collection: Optional[str] = None, collection: Optional[str] = None,
tag: Optional[str] = None,
limit: int = 25, limit: int = 25,
mix: bool = False, mix: bool = False,
user: dict = Depends(require_user), user: dict = Depends(require_user),
@@ -543,8 +561,14 @@ async def api_srs_queue(
with get_db() as conn: with get_db() as conn:
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
col_filter = " AND collection = ?" if collection else "" col_filter = ""
col_params = [collection] if collection else [] 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: if mix:
# 1. New cards (up to 10) # 1. New cards (up to 10)
@@ -607,6 +631,7 @@ async def api_srs_queue(
@app.get("/api/srs/quiz") @app.get("/api/srs/quiz")
async def api_srs_quiz( async def api_srs_quiz(
collection: Optional[str] = None, collection: Optional[str] = None,
tag: Optional[str] = None,
count: int = 10, count: int = 10,
user: dict = Depends(require_user), user: dict = Depends(require_user),
): ):
@@ -615,8 +640,15 @@ async def api_srs_quiz(
""" """
with get_db() as conn: with get_db() as conn:
# Fetch target cards for user # Fetch target cards for user
col_filter = " AND collection = ?" if collection else "" col_filter = ""
col_params = [collection] if collection else [] 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( rows = conn.execute(
f"SELECT * FROM vocabulary_cards WHERE user_id = ?{col_filter} ORDER BY RANDOM() LIMIT ?", f"SELECT * FROM vocabulary_cards WHERE user_id = ?{col_filter} ORDER BY RANDOM() LIMIT ?",
[user["id"]] + col_params + [count], [user["id"]] + col_params + [count],
@@ -798,6 +830,8 @@ async def api_srs_stats(user: dict = Depends(require_user)):
).fetchone()[0] ).fetchone()[0]
today_reviewed = date_counts.get(today_str, 0) today_reviewed = date_counts.get(today_str, 0)
cefr_info = calculate_cefr_level(mastered, review, retention_rate, total_cards)
return { return {
"total_cards": total_cards, "total_cards": total_cards,
"new_cards": new_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"], "longest_streak": heatmap_info["longest_streak"],
"today_added": today_added, "today_added": today_added,
"today_reviewed": today_reviewed, "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", media_type="text/markdown; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=\"vocabulary.md\""}, 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}
+350
View File
@@ -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 <title> 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
+54
View File
@@ -248,3 +248,57 @@ def build_activity_heatmap(review_dates: Dict[str, int], days: int = 180) -> Dic
"current_streak": current_streak, "current_streak": current_streak,
"longest_streak": max(longest_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,
}
+382
View File
@@ -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;
}
+116
View File
@@ -67,6 +67,9 @@
</div> </div>
<div class="nav-actions"> <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 варианта, диктант, ввод)"> <button id="srs-trainer-btn" class="btn btn-trainer" title="Интервальный тренажер (Flashcards, 4 варианта, диктант, ввод)">
<span class="trainer-icon">🎓</span> <span class="trainer-icon">🎓</span>
<span class="trainer-text">Тренажёр SRS</span> <span class="trainer-text">Тренажёр SRS</span>
@@ -517,6 +520,92 @@
</div> </div>
</section> </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 --> <!-- Sticky Bulk Action Bar for Batch Selection Mode -->
<div id="batch-action-bar" class="batch-action-bar hidden"> <div id="batch-action-bar" class="batch-action-bar hidden">
<div class="batch-left"> <div class="batch-left">
@@ -800,6 +889,33 @@
<button type="submit" id="change-pwd-save-btn" class="btn btn-primary">Сохранить</button> <button type="submit" id="change-pwd-save-btn" class="btn btn-primary">Сохранить</button>
</div> </div>
</form> </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>
</div> </div>
+581 -20
View File
@@ -45,6 +45,8 @@ document.addEventListener('DOMContentLoaded', () => {
const appShell = document.getElementById('app-shell'); const appShell = document.getElementById('app-shell');
const dictionaryView = document.getElementById('dictionary-view'); const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-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 navBrandHome = document.getElementById('nav-brand-home');
const themeToggle = document.getElementById('theme-toggle'); const themeToggle = document.getElementById('theme-toggle');
const statTotal = document.getElementById('stat-total'); const statTotal = document.getElementById('stat-total');
@@ -55,6 +57,53 @@ document.addEventListener('DOMContentLoaded', () => {
const exportOpenBtn = document.getElementById('export-open-btn'); const exportOpenBtn = document.getElementById('export-open-btn');
const changePwdOpenBtn = document.getElementById('change-pwd-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 // Elements - Search & Capture
const termInput = document.getElementById('term-input'); const termInput = document.getElementById('term-input');
const clearSearchBtn = document.getElementById('clear-search-btn'); const clearSearchBtn = document.getElementById('clear-search-btn');
@@ -891,19 +940,38 @@ document.addEventListener('DOMContentLoaded', () => {
renderSrsCurrentCard(); renderSrsCurrentCard();
}); });
function openSrsView() { function openDictionaryView() {
dictionaryView.classList.add('hidden');
srsView.classList.remove('hidden');
startSrsTrainingSession(false);
}
function closeSrsView() {
srsView.classList.add('hidden'); srsView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
dictionaryView.classList.remove('hidden'); dictionaryView.classList.remove('hidden');
loadInitialData(); 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'); srsLoading.classList.remove('hidden');
srsEmpty.classList.add('hidden'); srsEmpty.classList.add('hidden');
srsSummary.classList.add('hidden'); srsSummary.classList.add('hidden');
@@ -914,9 +982,12 @@ document.addEventListener('DOMContentLoaded', () => {
let queue = []; let queue = [];
try { try {
const url = forceAll let url = forceAll
? `/api/srs/queue?limit=50` ? `/api/srs/queue?limit=50`
: `/api/srs/queue?limit=25&mix=${srsSmartMix}`; : `/api/srs/queue?limit=25&mix=${srsSmartMix}`;
if (tagFilter) {
url += `&tag=${encodeURIComponent(tagFilter)}`;
}
const resp = await fetch(url); const resp = await fetch(url);
if (resp.ok) { if (resp.ok) {
const data = await resp.json(); const data = await resp.json();
@@ -1431,17 +1502,21 @@ document.addEventListener('DOMContentLoaded', () => {
kpiTotalWords.textContent = s.total_cards; kpiTotalWords.textContent = s.total_cards;
kpiTodayAdded.textContent = `Сегодня добавлено: +${s.today_added}`; kpiTodayAdded.textContent = `Сегодня добавлено: +${s.today_added}`;
// CEFR Level estimation // Strict CEFR Level estimation (PLAN.md 4.3)
const count = s.total_cards; if (s.cefr) {
let lvl = 'A1'; kpiCefrLevel.textContent = s.cefr.level;
let desc = 'Базовый словарный запас'; kpiCefrDesc.textContent = `${s.cefr.title} • ${s.cefr.message}`;
if (count >= 2500) { lvl = 'C1'; desc = 'Продвинутый свободный уровень'; } } else {
else if (count >= 1200) { lvl = 'B2'; desc = 'Уверенное чтение тех. документации'; } const count = s.total_cards;
else if (count >= 600) { lvl = 'B1'; desc = 'Понимание общих и рабочих тем'; } let lvl = 'A1';
else if (count >= 200) { lvl = 'A2'; desc = 'Элементарный словарный запас'; } let desc = 'Базовый словарный запас';
if (count >= 2500) { lvl = 'C1'; desc = 'Продвинутый свободный уровень'; }
kpiCefrLevel.textContent = lvl; else if (count >= 1200) { lvl = 'B2'; desc = 'Уверенное чтение тех. документации'; }
kpiCefrDesc.textContent = desc; else if (count >= 600) { lvl = 'B1'; desc = 'Понимание общих и рабочих тем'; }
else if (count >= 200) { lvl = 'A2'; desc = 'Элементарный словарный запас'; }
kpiCefrLevel.textContent = lvl;
kpiCefrDesc.textContent = desc;
}
// Mastery Progress Bar // Mastery Progress Bar
cntMastered.textContent = s.mastered; 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 // Utility helpers
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
+286
View File
@@ -503,4 +503,290 @@ def test_change_password_flow():
assert new_auth["username"] == "pwd_user" 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}</title></head>
<body>
<h1>{ch_title}</h1>
{ch_body}
</body>
</html>""")
opf = f"""<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>{title}</dc:title>
<dc:creator>{author}</dc:creator>
</metadata>
<manifest>
{''.join(manifest_items)}
</manifest>
<spine>
{''.join(spine_items)}
</spine>
</package>"""
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)