feat: implement Stage 5 (Interactive Reader, EPUB/TXT parser, Bookshelf, Click-to-Translate) and Stage 4.3 (Strict CEFR level calculation)
This commit is contained in:
1 parent
c51c505b61
commit
d8a2c6e34f
9 files changed
+2092
-21
No files matched your search
@@ -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`) слов.
|
||||
|
||||
@@ -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)
|
||||
+292
-5
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
import json
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timezone, date
|
||||
@@ -24,7 +26,8 @@ from app.auth import (
|
||||
)
|
||||
from app.dictionary import lookup_term, USER_AGENT
|
||||
from app.export import export_csv, export_json, export_anki_tsv, export_markdown
|
||||
from app.srs import calculate_sm2, generate_quiz_options, build_activity_heatmap
|
||||
from app.srs import calculate_sm2, generate_quiz_options, build_activity_heatmap, calculate_cefr_level
|
||||
from app.reader import parse_epub, parse_text, count_words
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -131,6 +134,20 @@ class SrsReviewRequest(BaseModel):
|
||||
rating: int = Field(..., ge=1, le=4)
|
||||
|
||||
|
||||
class BookCreateRequest(BaseModel):
|
||||
title: Optional[str] = "Untitled"
|
||||
author: Optional[str] = ""
|
||||
content: Optional[str] = ""
|
||||
source_type: Optional[str] = "text"
|
||||
|
||||
|
||||
class BookProgressRequest(BaseModel):
|
||||
current_chapter: Optional[int] = None
|
||||
current_position: Optional[float] = None
|
||||
chapter: Optional[int] = None
|
||||
position: Optional[float] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PWA Static Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -528,6 +545,7 @@ async def api_batch_action(req: BatchActionRequest, user: dict = Depends(require
|
||||
@app.get("/api/srs/due")
|
||||
async def api_srs_queue(
|
||||
collection: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
limit: int = 25,
|
||||
mix: bool = False,
|
||||
user: dict = Depends(require_user),
|
||||
@@ -543,8 +561,14 @@ async def api_srs_queue(
|
||||
with get_db() as conn:
|
||||
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
col_filter = " AND collection = ?" if collection else ""
|
||||
col_params = [collection] if collection else []
|
||||
col_filter = ""
|
||||
col_params = []
|
||||
if collection:
|
||||
col_filter += " AND collection = ?"
|
||||
col_params.append(collection.strip())
|
||||
if tag:
|
||||
col_filter += " AND tags LIKE ?"
|
||||
col_params.append(f"%{tag.strip()}%")
|
||||
|
||||
if mix:
|
||||
# 1. New cards (up to 10)
|
||||
@@ -607,6 +631,7 @@ async def api_srs_queue(
|
||||
@app.get("/api/srs/quiz")
|
||||
async def api_srs_quiz(
|
||||
collection: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
count: int = 10,
|
||||
user: dict = Depends(require_user),
|
||||
):
|
||||
@@ -615,8 +640,15 @@ async def api_srs_quiz(
|
||||
"""
|
||||
with get_db() as conn:
|
||||
# Fetch target cards for user
|
||||
col_filter = " AND collection = ?" if collection else ""
|
||||
col_params = [collection] if collection else []
|
||||
col_filter = ""
|
||||
col_params = []
|
||||
if collection:
|
||||
col_filter += " AND collection = ?"
|
||||
col_params.append(collection.strip())
|
||||
if tag:
|
||||
col_filter += " AND tags LIKE ?"
|
||||
col_params.append(f"%{tag.strip()}%")
|
||||
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM vocabulary_cards WHERE user_id = ?{col_filter} ORDER BY RANDOM() LIMIT ?",
|
||||
[user["id"]] + col_params + [count],
|
||||
@@ -798,6 +830,8 @@ async def api_srs_stats(user: dict = Depends(require_user)):
|
||||
).fetchone()[0]
|
||||
today_reviewed = date_counts.get(today_str, 0)
|
||||
|
||||
cefr_info = calculate_cefr_level(mastered, review, retention_rate, total_cards)
|
||||
|
||||
return {
|
||||
"total_cards": total_cards,
|
||||
"new_cards": new_cards,
|
||||
@@ -812,6 +846,7 @@ async def api_srs_stats(user: dict = Depends(require_user)):
|
||||
"longest_streak": heatmap_info["longest_streak"],
|
||||
"today_added": today_added,
|
||||
"today_reviewed": today_reviewed,
|
||||
"cefr": cefr_info,
|
||||
}
|
||||
|
||||
|
||||
@@ -944,3 +979,255 @@ async def api_export_markdown(collection: Optional[str] = None, user: dict = Dep
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=\"vocabulary.md\""},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bookshelf & Interactive Reader Endpoints (Stage 5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/books")
|
||||
async def api_get_books(user: dict = Depends(require_user)):
|
||||
"""
|
||||
Lists books for current user (id, title, author, source_type, chapters_count,
|
||||
total_words, current_chapter, current_position, created_at, updated_at).
|
||||
Excludes full chapters_json for efficiency.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, user_id, title, author, source_type,
|
||||
json_array_length(chapters_json) AS chapters_count,
|
||||
total_words, current_chapter, current_position,
|
||||
created_at, updated_at
|
||||
FROM books
|
||||
WHERE user_id = ?
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
""",
|
||||
(user["id"],),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@app.post("/api/books")
|
||||
async def api_create_book(
|
||||
request: Request,
|
||||
user: dict = Depends(require_user),
|
||||
):
|
||||
"""
|
||||
Creates or uploads a book.
|
||||
Accepts:
|
||||
- JSON: {"title": "...", "author": "...", "content": "...", "source_type": "text"}
|
||||
- Multipart file upload: file (UploadFile for .epub, .txt, .md) and optional title, author
|
||||
"""
|
||||
content_type = request.headers.get("content-type", "")
|
||||
|
||||
title = "Untitled"
|
||||
author = ""
|
||||
source_type = "text"
|
||||
chapters = []
|
||||
|
||||
if "multipart/form-data" in content_type:
|
||||
form = await request.form()
|
||||
file_obj = form.get("file")
|
||||
if not file_obj or not hasattr(file_obj, "filename") or not file_obj.filename:
|
||||
raise HTTPException(status_code=400, detail="No file uploaded")
|
||||
|
||||
filename = file_obj.filename
|
||||
file_bytes = await file_obj.read()
|
||||
if not file_bytes:
|
||||
raise HTTPException(status_code=400, detail="Uploaded file is empty")
|
||||
|
||||
form_title = (form.get("title") or "").strip()
|
||||
form_author = (form.get("author") or "").strip()
|
||||
stem = Path(filename).stem
|
||||
|
||||
lower_name = filename.lower()
|
||||
if lower_name.endswith(".epub"):
|
||||
source_type = "epub"
|
||||
parsed_title, parsed_author, chapters = parse_epub(file_bytes)
|
||||
title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem
|
||||
author = form_author or parsed_author
|
||||
elif lower_name.endswith(".md"):
|
||||
source_type = "md"
|
||||
text_str = file_bytes.decode("utf-8", errors="replace")
|
||||
parsed_title, parsed_author, chapters = parse_text(text_str, title=form_title or "Untitled", author=form_author)
|
||||
title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem
|
||||
author = form_author or parsed_author
|
||||
elif lower_name.endswith(".txt"):
|
||||
source_type = "txt"
|
||||
text_str = file_bytes.decode("utf-8", errors="replace")
|
||||
parsed_title, parsed_author, chapters = parse_text(text_str, title=form_title or "Untitled", author=form_author)
|
||||
title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem
|
||||
author = form_author or parsed_author
|
||||
else:
|
||||
source_type = "text"
|
||||
text_str = file_bytes.decode("utf-8", errors="replace")
|
||||
parsed_title, parsed_author, chapters = parse_text(text_str, title=form_title or "Untitled", author=form_author)
|
||||
title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem
|
||||
author = form_author or parsed_author
|
||||
|
||||
else:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON or missing body")
|
||||
|
||||
raw_content = body.get("content") or ""
|
||||
title_in = (body.get("title") or "").strip()
|
||||
author_in = (body.get("author") or "").strip()
|
||||
source_type = body.get("source_type") or "text"
|
||||
|
||||
if not raw_content and not title_in:
|
||||
raise HTTPException(status_code=400, detail="Book content is required")
|
||||
|
||||
parsed_title, parsed_author, chapters = parse_text(
|
||||
raw_content, title=title_in or "Untitled", author=author_in
|
||||
)
|
||||
title = title_in or parsed_title
|
||||
author = author_in or parsed_author
|
||||
|
||||
total_words = sum(count_words(ch.get("content", "")) for ch in chapters)
|
||||
chapters_json = json.dumps(chapters, ensure_ascii=False)
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO books (
|
||||
user_id, title, author, source_type, chapters_json,
|
||||
current_chapter, current_position, total_words
|
||||
) VALUES (?, ?, ?, ?, ?, 0, 0.0, ?)
|
||||
""",
|
||||
(user["id"], title, author, source_type, chapters_json, total_words),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, user_id, title, author, source_type, chapters_json,
|
||||
json_array_length(chapters_json) AS chapters_count,
|
||||
current_chapter, current_position, total_words,
|
||||
created_at, updated_at
|
||||
FROM books WHERE id = ?
|
||||
""",
|
||||
(new_id,),
|
||||
).fetchone()
|
||||
|
||||
res = dict(row)
|
||||
res["chapters"] = chapters
|
||||
return res
|
||||
|
||||
|
||||
@app.get("/api/books/{book_id}")
|
||||
async def api_get_book(book_id: int, user: dict = Depends(require_user)):
|
||||
"""
|
||||
Returns full book details including chapters_json.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, user_id, title, author, source_type, chapters_json,
|
||||
json_array_length(chapters_json) AS chapters_count,
|
||||
current_chapter, current_position, total_words,
|
||||
created_at, updated_at
|
||||
FROM books
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(book_id, user["id"]),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
data = dict(row)
|
||||
try:
|
||||
data["chapters"] = json.loads(data["chapters_json"])
|
||||
except Exception:
|
||||
data["chapters"] = []
|
||||
return data
|
||||
|
||||
|
||||
@app.put("/api/books/{book_id}/progress")
|
||||
@app.patch("/api/books/{book_id}/progress")
|
||||
async def api_update_book_progress(
|
||||
book_id: int,
|
||||
req: Optional[BookProgressRequest] = None,
|
||||
current_chapter: Optional[int] = Query(None),
|
||||
current_position: Optional[float] = Query(None),
|
||||
request: Request = None,
|
||||
user: dict = Depends(require_user),
|
||||
):
|
||||
"""
|
||||
Updates reading progress (current_chapter, current_position) for a book.
|
||||
"""
|
||||
ch = None
|
||||
pos = None
|
||||
if req is not None:
|
||||
ch = req.current_chapter if req.current_chapter is not None else req.chapter
|
||||
pos = req.current_position if req.current_position is not None else req.position
|
||||
if ch is None:
|
||||
ch = current_chapter
|
||||
if pos is None:
|
||||
pos = current_position
|
||||
if ch is None or pos is None:
|
||||
if request:
|
||||
try:
|
||||
body = await request.json()
|
||||
if ch is None:
|
||||
ch = body.get("current_chapter", body.get("chapter"))
|
||||
if pos is None:
|
||||
pos = body.get("current_position", body.get("position"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if ch is None and pos is None:
|
||||
raise HTTPException(status_code=400, detail="Missing current_chapter or current_position")
|
||||
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, current_chapter, current_position FROM books WHERE id = ? AND user_id = ?",
|
||||
(book_id, user["id"]),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
new_ch = int(ch) if ch is not None else row["current_chapter"]
|
||||
new_pos = float(pos) if pos is not None else row["current_position"]
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE books
|
||||
SET current_chapter = ?, current_position = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(new_ch, new_pos, book_id, user["id"]),
|
||||
)
|
||||
|
||||
updated_row = conn.execute(
|
||||
"""
|
||||
SELECT id, user_id, title, author, source_type, chapters_json,
|
||||
json_array_length(chapters_json) AS chapters_count,
|
||||
current_chapter, current_position, total_words,
|
||||
created_at, updated_at
|
||||
FROM books
|
||||
WHERE id = ?
|
||||
""",
|
||||
(book_id,),
|
||||
).fetchone()
|
||||
res = dict(updated_row)
|
||||
try:
|
||||
res["chapters"] = json.loads(res["chapters_json"])
|
||||
except Exception:
|
||||
res["chapters"] = []
|
||||
return res
|
||||
|
||||
|
||||
@app.delete("/api/books/{book_id}")
|
||||
async def api_delete_book(book_id: int, user: dict = Depends(require_user)):
|
||||
"""
|
||||
Deletes a book for the current user.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM books WHERE id = ? AND user_id = ?",
|
||||
(book_id, user["id"]),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
return {"status": "ok", "deleted_id": book_id}
|
||||
+350
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+572
-11
@@ -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,7 +1502,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
kpiTotalWords.textContent = s.total_cards;
|
||||
kpiTodayAdded.textContent = `Сегодня добавлено: +${s.today_added}`;
|
||||
|
||||
// CEFR Level estimation
|
||||
// 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 = 'Базовый словарный запас';
|
||||
@@ -1439,9 +1514,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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}</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)
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user