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
+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_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
View File
@@ -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
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,
"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 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>
+581 -20
View File
@@ -45,6 +45,8 @@ document.addEventListener('DOMContentLoaded', () => {
const appShell = document.getElementById('app-shell');
const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-view');
const readerView = document.getElementById('reader-view');
const navReaderBtn = document.getElementById('nav-reader-btn');
const navBrandHome = document.getElementById('nav-brand-home');
const themeToggle = document.getElementById('theme-toggle');
const statTotal = document.getElementById('stat-total');
@@ -55,6 +57,53 @@ document.addEventListener('DOMContentLoaded', () => {
const exportOpenBtn = document.getElementById('export-open-btn');
const changePwdOpenBtn = document.getElementById('change-pwd-open-btn');
// Elements - Reader & Bookshelf
const bookshelfContainer = document.getElementById('bookshelf-container');
const bookshelfEmpty = document.getElementById('bookshelf-empty');
const bookshelfGrid = document.getElementById('bookshelf-grid');
const addTextBtn = document.getElementById('add-text-btn');
const bookFileInput = document.getElementById('book-file-input');
const addBookModal = document.getElementById('add-book-modal');
const closeAddBookModalBtn = document.getElementById('close-add-book-modal-btn');
const cancelAddBookBtn = document.getElementById('cancel-add-book-btn');
const addBookForm = document.getElementById('add-book-form');
const bookTitleInput = document.getElementById('book-title-input');
const bookAuthorInput = document.getElementById('book-author-input');
const bookContentInput = document.getElementById('book-content-input');
const readerInterface = document.getElementById('reader-interface');
const readerBackBtn = document.getElementById('reader-back-btn');
const readerBookTitle = document.getElementById('reader-book-title');
const readerBookAuthor = document.getElementById('reader-book-author');
const readerChapterSelect = document.getElementById('reader-chapter-select');
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
const readerFontDecBtn = document.getElementById('reader-font-dec-btn');
const readerFontIncBtn = document.getElementById('reader-font-inc-btn');
const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn');
const readerTrainBtn = document.getElementById('reader-train-btn');
const readerContentBody = document.getElementById('reader-content-body');
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerWordPopup = document.getElementById('reader-word-popup');
const popupTerm = document.getElementById('popup-term');
const popupPhonetic = document.getElementById('popup-phonetic');
const popupAudioBtn = document.getElementById('popup-audio-btn');
const popupCloseBtn = document.getElementById('popup-close-btn');
const popupLoading = document.getElementById('popup-loading');
const popupContent = document.getElementById('popup-content');
const popupTranslation = document.getElementById('popup-translation');
const popupContext = document.getElementById('popup-context');
const popupSaveBtn = document.getElementById('popup-save-btn');
const popupSavedBadge = document.getElementById('popup-saved-badge');
// Reader State
let currentBook = null;
let currentChapterIndex = 0;
let readerFontSize = 19;
let isHighlightEnabled = false;
let popupCurrentData = null;
// Elements - Search & Capture
const termInput = document.getElementById('term-input');
const clearSearchBtn = document.getElementById('clear-search-btn');
@@ -891,19 +940,38 @@ document.addEventListener('DOMContentLoaded', () => {
renderSrsCurrentCard();
});
function openSrsView() {
dictionaryView.classList.add('hidden');
srsView.classList.remove('hidden');
startSrsTrainingSession(false);
}
function closeSrsView() {
function openDictionaryView() {
srsView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
dictionaryView.classList.remove('hidden');
loadInitialData();
}
async function startSrsTrainingSession(forceAll = false) {
function openSrsView(tagFilter = null) {
dictionaryView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
srsView.classList.remove('hidden');
startSrsTrainingSession(false, tagFilter);
}
function closeSrsView() {
srsView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
dictionaryView.classList.remove('hidden');
loadInitialData();
}
function openReaderView() {
dictionaryView.classList.add('hidden');
srsView.classList.add('hidden');
if (readerView) readerView.classList.remove('hidden');
loadBookshelf();
}
navBrandHome.addEventListener('click', () => openDictionaryView());
if (navReaderBtn) navReaderBtn.addEventListener('click', () => openReaderView());
async function startSrsTrainingSession(forceAll = false, tagFilter = null) {
srsLoading.classList.remove('hidden');
srsEmpty.classList.add('hidden');
srsSummary.classList.add('hidden');
@@ -914,9 +982,12 @@ document.addEventListener('DOMContentLoaded', () => {
let queue = [];
try {
const url = forceAll
let url = forceAll
? `/api/srs/queue?limit=50`
: `/api/srs/queue?limit=25&mix=${srsSmartMix}`;
if (tagFilter) {
url += `&tag=${encodeURIComponent(tagFilter)}`;
}
const resp = await fetch(url);
if (resp.ok) {
const data = await resp.json();
@@ -1431,17 +1502,21 @@ document.addEventListener('DOMContentLoaded', () => {
kpiTotalWords.textContent = s.total_cards;
kpiTodayAdded.textContent = `Сегодня добавлено: +${s.today_added}`;
// CEFR Level estimation
const count = s.total_cards;
let lvl = 'A1';
let desc = 'Базовый словарный запас';
if (count >= 2500) { lvl = 'C1'; desc = 'Продвинутый свободный уровень'; }
else if (count >= 1200) { lvl = 'B2'; desc = 'Уверенное чтение тех. документации'; }
else if (count >= 600) { lvl = 'B1'; desc = 'Понимание общих и рабочих тем'; }
else if (count >= 200) { lvl = 'A2'; desc = 'Элементарный словарный запас'; }
kpiCefrLevel.textContent = lvl;
kpiCefrDesc.textContent = desc;
// Strict CEFR Level estimation (PLAN.md 4.3)
if (s.cefr) {
kpiCefrLevel.textContent = s.cefr.level;
kpiCefrDesc.textContent = `${s.cefr.title} • ${s.cefr.message}`;
} else {
const count = s.total_cards;
let lvl = 'A1';
let desc = 'Базовый словарный запас';
if (count >= 2500) { lvl = 'C1'; desc = 'Продвинутый свободный уровень'; }
else if (count >= 1200) { lvl = 'B2'; desc = 'Уверенное чтение тех. документации'; }
else if (count >= 600) { lvl = 'B1'; desc = 'Понимание общих и рабочих тем'; }
else if (count >= 200) { lvl = 'A2'; desc = 'Элементарный словарный запас'; }
kpiCefrLevel.textContent = lvl;
kpiCefrDesc.textContent = desc;
}
// Mastery Progress Bar
cntMastered.textContent = s.mastered;
@@ -1669,6 +1744,492 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// =======================================================================
// STAGE 5: Interactive Reader & Bookshelf Implementation
// =======================================================================
function slugify(text) {
if (!text) return 'book';
return text.toString().toLowerCase().trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
async function loadBookshelf() {
if (!bookshelfContainer) return;
bookshelfContainer.classList.remove('hidden');
if (readerInterface) readerInterface.classList.add('hidden');
if (bookshelfGrid) bookshelfGrid.innerHTML = '';
if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden');
try {
const resp = await fetch('/api/books');
if (resp.ok) {
const books = await resp.json();
if (!books || books.length === 0) {
if (bookshelfEmpty) bookshelfEmpty.classList.remove('hidden');
} else {
renderBookshelf(books);
}
}
} catch (e) {
console.error('Error loading bookshelf:', e);
}
}
function renderBookshelf(books) {
if (!bookshelfGrid) return;
bookshelfGrid.innerHTML = '';
books.forEach(b => {
const card = document.createElement('div');
card.className = 'book-card';
const totalCh = b.chapters_count || 1;
const currentCh = (b.current_chapter || 0) + 1;
const pct = Math.min(100, Math.round((currentCh / totalCh) * 100));
card.innerHTML = `
<div>
<div class="book-card-top">
<div class="book-cover-icon">${b.source_type === 'epub' ? '📕' : '📄'}</div>
<div class="book-card-info">
<h4 class="book-card-title" title="${escapeHtml(b.title)}">${escapeHtml(b.title)}</h4>
<p class="book-card-author">${escapeHtml(b.author || 'Неизвестный автор')}</p>
<div class="book-card-badges">
<span class="badge-format">${escapeHtml(b.source_type)}</span>
<span class="badge-words">${b.total_words ? b.total_words.toLocaleString() + ' слов' : ''}</span>
</div>
</div>
</div>
<div class="book-progress-wrap">
<div class="book-progress-labels">
<span>Глава ${currentCh} из ${totalCh}</span>
<span>${pct}%</span>
</div>
<div class="book-progress-bar-bg">
<div class="book-progress-bar-fill" style="width: ${pct}%"></div>
</div>
</div>
</div>
<div class="book-card-actions">
<button class="btn btn-primary btn-sm read-book-btn" data-id="${b.id}">📖 Читать</button>
<button class="btn btn-outline btn-xs train-book-btn" data-title="${escapeHtml(b.title)}" title="Тренировать слова из этой книги">🎓 Слова книги</button>
<button class="icon-btn delete-book-btn" data-id="${b.id}" title="Удалить книгу">🗑️</button>
</div>
`;
card.querySelector('.read-book-btn').addEventListener('click', () => openBook(b.id));
card.querySelector('.train-book-btn').addEventListener('click', () => {
const bookTag = slugify(b.title);
openSrsView(bookTag);
});
card.querySelector('.delete-book-btn').addEventListener('click', async () => {
if (confirm(`Удалить книгу "${b.title}" из библиотеки?`)) {
await fetch(`/api/books/${b.id}`, { method: 'DELETE' });
loadBookshelf();
}
});
bookshelfGrid.appendChild(card);
});
}
async function openBook(bookId) {
try {
const resp = await fetch(`/api/books/${bookId}`);
if (!resp.ok) return alert('Не удалось открыть книгу');
currentBook = await resp.json();
currentChapterIndex = currentBook.current_chapter || 0;
readerBookTitle.textContent = currentBook.title;
readerBookAuthor.textContent = currentBook.author || '';
// Populate chapter selector
readerChapterSelect.innerHTML = '';
const chapters = currentBook.chapters || [];
chapters.forEach((ch, idx) => {
const opt = document.createElement('option');
opt.value = idx;
opt.textContent = ch.title || `Глава ${idx + 1}`;
readerChapterSelect.appendChild(opt);
});
bookshelfContainer.classList.add('hidden');
readerInterface.classList.remove('hidden');
renderChapter(currentChapterIndex);
} catch (e) {
console.error('Error opening book:', e);
}
}
function renderChapter(index) {
if (!currentBook || !currentBook.chapters) return;
currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1));
readerChapterSelect.value = currentChapterIndex;
readerPrevChBtn.disabled = currentChapterIndex <= 0;
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const chapter = currentBook.chapters[currentChapterIndex] || { title: '', content: '' };
readerContentBody.innerHTML = '';
if (chapter.title) {
const h = document.createElement('h2');
h.textContent = chapter.title;
readerContentBody.appendChild(h);
}
// Map existing user vocabulary for status marking
const knownWordsMap = new Map();
allCards.forEach(c => {
if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
});
// Split content into paragraphs
const paragraphs = chapter.content.split(/\n\s*\n/);
paragraphs.forEach(paraText => {
paraText = paraText.trim();
if (!paraText) return;
const p = document.createElement('p');
// Tokenize words while preserving spaces & punctuation
const tokens = paraText.split(/([A-Za-z0-9'-]+)/);
tokens.forEach(token => {
if (/^[A-Za-z0-9'-]+$/.test(token) && token.length > 1) {
const span = document.createElement('span');
span.className = 'reader-word';
span.dataset.word = token;
span.textContent = token;
const lower = token.toLowerCase();
if (knownWordsMap.has(lower)) {
const card = knownWordsMap.get(lower);
span.classList.add(card.srs_state === 'mastered' ? 'status-mastered' : 'status-learning');
}
if (isHighlightEnabled) {
span.classList.add('highlight-enabled');
}
span.addEventListener('click', (e) => {
e.stopPropagation();
handleWordClick(token, paraText, span);
});
p.appendChild(span);
} else {
p.appendChild(document.createTextNode(token));
}
});
readerContentBody.appendChild(p);
});
if (readerScrollArea) readerScrollArea.scrollTop = 0;
// Save progress to server
fetch(`/api/books/${currentBook.id}/progress`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_chapter: currentChapterIndex, current_position: 0 }),
}).catch(err => console.log('Progress save error:', err));
}
// Extract cleanest surrounding sentence for context
function extractContextSentence(fullText, word) {
if (!fullText) return '';
const re = new RegExp(`([^.!?\\n]*?\\b${escapeRegex(word)}\\b[^.!?\\n]*[.!?]?)`, 'i');
const m = fullText.match(re);
if (m && m[1]) return m[1].trim();
return fullText.slice(0, 160).trim();
}
async function handleWordClick(word, paragraphText, targetElement) {
const sentence = extractContextSentence(paragraphText, word);
openWordPopup(word, sentence, targetElement);
}
async function openWordPopup(term, contextSentence, targetElement) {
popupCurrentData = {
term: term,
contextSentence: contextSentence,
targetElement: targetElement,
};
popupTerm.textContent = term;
popupPhonetic.textContent = '';
popupTranslation.textContent = '';
popupContext.textContent = contextSentence ? `“${contextSentence}”` : '';
popupLoading.classList.remove('hidden');
popupContent.classList.add('hidden');
popupSavedBadge.classList.add('hidden');
popupSaveBtn.classList.remove('hidden');
popupSaveBtn.disabled = false;
// Position popup near target element
readerWordPopup.classList.remove('hidden');
if (targetElement) {
const rect = targetElement.getBoundingClientRect();
let top = rect.bottom + window.scrollY + 6;
let left = rect.left + window.scrollX;
const popupWidth = 320;
if (left + popupWidth > window.innerWidth - 16) {
left = window.innerWidth - popupWidth - 16;
}
if (left < 16) left = 16;
readerWordPopup.style.top = `${top}px`;
readerWordPopup.style.left = `${left}px`;
}
try {
const resp = await fetch(`/api/lookup?q=${encodeURIComponent(term)}`);
if (resp.ok) {
const data = await resp.json();
popupCurrentData.lookup = data;
popupPhonetic.textContent = data.phonetic || '';
popupTranslation.textContent = data.translation_ru || 'Перевод не найден';
popupLoading.classList.add('hidden');
popupContent.classList.remove('hidden');
if (data.audio_url) {
popupAudioBtn.classList.remove('hidden');
popupAudioBtn.onclick = () => playAudio(data.audio_url);
} else {
popupAudioBtn.classList.add('hidden');
}
} else {
popupTranslation.textContent = 'Ошибка перевода';
popupLoading.classList.add('hidden');
popupContent.classList.remove('hidden');
}
} catch (e) {
console.error('Popup lookup error:', e);
popupTranslation.textContent = 'Сетевая ошибка';
popupLoading.classList.add('hidden');
popupContent.classList.remove('hidden');
}
}
if (popupCloseBtn) {
popupCloseBtn.addEventListener('click', () => {
readerWordPopup.classList.add('hidden');
});
}
document.addEventListener('click', (e) => {
if (readerWordPopup && !readerWordPopup.contains(e.target) && !e.target.classList.contains('reader-word')) {
readerWordPopup.classList.add('hidden');
}
});
if (popupSaveBtn) {
popupSaveBtn.addEventListener('click', async () => {
if (!popupCurrentData) return;
const d = popupCurrentData;
const lookup = d.lookup || {};
const bookTag = currentBook ? slugify(currentBook.title) : 'reader';
const payload = {
term: d.term,
translation: lookup.translation_ru || popupTranslation.textContent,
context_sentence: d.contextSentence || '',
phonetic: lookup.phonetic || '',
audio_url: lookup.audio_url || '',
part_of_speech: lookup.part_of_speech || '',
definition_en: lookup.definition_en || '',
collection: 'Книги & Сериалы',
tags: `${bookTag}, reader`,
notes: currentBook ? `Из книги: ${currentBook.title}` : '',
};
try {
const resp = await fetch('/api/cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
popupSaveBtn.classList.add('hidden');
popupSavedBadge.classList.remove('hidden');
if (d.targetElement) {
d.targetElement.classList.add('status-learning');
if (isHighlightEnabled) d.targetElement.classList.add('highlight-enabled');
}
await fetchCards();
await fetchTags();
} else {
alert('Не удалось сохранить слово');
}
} catch (e) {
console.error('Save card from reader error:', e);
}
});
}
// Phrase/Chunk Selection in Reader
if (readerContentBody) {
readerContentBody.addEventListener('mouseup', () => {
const sel = window.getSelection();
const text = sel.toString().trim();
if (text && text.split(/\s+/).length >= 2 && text.length <= 80) {
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
openWordPopup(text, text, {
getBoundingClientRect: () => rect,
});
}
});
}
// Chapter Navigation & Reader Toolbar
if (readerChapterSelect) {
readerChapterSelect.addEventListener('change', () => {
renderChapter(parseInt(readerChapterSelect.value, 10));
});
}
if (readerPrevChBtn) {
readerPrevChBtn.addEventListener('click', () => {
if (currentChapterIndex > 0) renderChapter(currentChapterIndex - 1);
});
}
if (readerNextChBtn) {
readerNextChBtn.addEventListener('click', () => {
if (currentBook && currentChapterIndex < currentBook.chapters.length - 1) {
renderChapter(currentChapterIndex + 1);
}
});
}
if (readerBackBtn) {
readerBackBtn.addEventListener('click', () => {
readerInterface.classList.add('hidden');
bookshelfContainer.classList.remove('hidden');
loadBookshelf();
});
}
if (readerFontIncBtn) {
readerFontIncBtn.addEventListener('click', () => {
if (readerFontSize < 28) {
readerFontSize += 2;
readerContentBody.style.fontSize = `${readerFontSize}px`;
}
});
}
if (readerFontDecBtn) {
readerFontDecBtn.addEventListener('click', () => {
if (readerFontSize > 14) {
readerFontSize -= 2;
readerContentBody.style.fontSize = `${readerFontSize}px`;
}
});
}
if (readerToggleHighlightBtn) {
readerToggleHighlightBtn.addEventListener('click', () => {
isHighlightEnabled = !isHighlightEnabled;
readerToggleHighlightBtn.classList.toggle('btn-primary', isHighlightEnabled);
readerContentBody.querySelectorAll('.reader-word').forEach(w => {
w.classList.toggle('highlight-enabled', isHighlightEnabled);
});
});
}
if (readerTrainBtn) {
readerTrainBtn.addEventListener('click', () => {
if (currentBook) {
const bookTag = slugify(currentBook.title);
openSrsView(bookTag);
}
});
}
// Bookshelf Add/Upload Actions
if (addTextBtn) {
addTextBtn.addEventListener('click', () => {
addBookForm.reset();
addBookModal.classList.remove('hidden');
});
}
if (closeAddBookModalBtn) closeAddBookModalBtn.addEventListener('click', () => addBookModal.classList.add('hidden'));
if (cancelAddBookBtn) cancelAddBookBtn.addEventListener('click', () => addBookModal.classList.add('hidden'));
if (addBookForm) {
addBookForm.addEventListener('submit', async (e) => {
e.preventDefault();
const title = bookTitleInput.value.trim();
const author = bookAuthorInput.value.trim();
const content = bookContentInput.value.trim();
if (!title || !content) return;
try {
const resp = await fetch('/api/books', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title,
author: author,
content: content,
source_type: 'text',
}),
});
if (resp.ok) {
const book = await resp.json();
addBookModal.classList.add('hidden');
await loadBookshelf();
openBook(book.id);
} else {
alert('Ошибка при сохранении книги');
}
} catch (err) {
console.error('Book create error:', err);
}
});
}
if (bookFileInput) {
bookFileInput.addEventListener('change', async () => {
const file = bookFileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch('/api/books', {
method: 'POST',
body: formData,
});
if (resp.ok) {
const book = await resp.json();
bookFileInput.value = '';
await loadBookshelf();
openBook(book.id);
} else {
const errData = await resp.json();
alert(`Ошибка загрузки: ${errData.detail || 'Не удалось обработать файл'}`);
}
} catch (e) {
console.error('File upload error:', e);
alert('Ошибка сети при загрузке файла');
}
});
}
// -----------------------------------------------------------------------
// Utility helpers
// -----------------------------------------------------------------------