feat(reader): добавить парсер RFC из RFC Editor и отдельную группу спецификаций на книжной полке

- Реализован парсер RFC в app/reader.py с авто-загрузкой с rfc-editor.org и datatracker.ietf.org
- Очистка пагинации ([Page X], \x0c) и разбиение спецификаций на структурированные главы (TOC, разделы)
- Эндпоинт POST /api/books/fetch-rfc с защитой от дублирования и поддержкой номеров/URL
- Визуальное разделение на книжной полке через hr-разделитель и блок спецификаций IETF & RFC
- Быстрые пресеты стандартов (HTTP/1.1, HTTP/2, TLS 1.3, OAuth 2.0, WebSocket, QUIC, TCP, DNS)
- Интеграция в интерактивную читалку (перевод по клику, авто-захват контекста, SRS карточки)
- Unit-тесты парсера и API (31/31 passed) и обновление Service Worker кэша
This commit is contained in:
2026-10-06 21:46:07 +03:00
parent e73d3470b3
commit 82b7a654ca
8 changed files with 877 additions and 66 deletions
+86 -1
View File
@@ -30,7 +30,7 @@ from app.auth import (
from app.dictionary import lookup_term, USER_AGENT, close_http_client from app.dictionary import lookup_term, USER_AGENT, close_http_client
from app.export import export_csv, export_json, export_anki_tsv, export_markdown from app.export import export_csv, export_json, export_anki_tsv, export_markdown
from app.srs import calculate_sm2, generate_quiz_options, build_activity_heatmap, calculate_cefr_level from app.srs import calculate_sm2, generate_quiz_options, build_activity_heatmap, calculate_cefr_level
from app.reader import parse_epub, parse_text, parse_pdf, count_words from app.reader import parse_epub, parse_text, parse_pdf, count_words, extract_rfc_number, fetch_and_parse_rfc, parse_rfc_text
from app.ai import translate_sentence_ai, get_ollama_status from app.ai import translate_sentence_ai, get_ollama_status
from app.phrasal import detect_phrasal_verb from app.phrasal import detect_phrasal_verb
from app.cefr import ( from app.cefr import (
@@ -182,6 +182,11 @@ class BookProgressRequest(BaseModel):
position: Optional[float] = None position: Optional[float] = None
class RfcFetchRequest(BaseModel):
rfc: str
force: Optional[bool] = False
class SentenceTranslateRequest(BaseModel): class SentenceTranslateRequest(BaseModel):
sentence: str = Field(..., min_length=1) sentence: str = Field(..., min_length=1)
target_word: Optional[str] = "" target_word: Optional[str] = ""
@@ -1315,6 +1320,86 @@ async def api_create_book(
return res return res
@app.post("/api/books/fetch-rfc")
async def api_fetch_rfc(
req: RfcFetchRequest,
user: dict = Depends(require_user),
):
"""
Fetches an RFC specification by number or URL from RFC Editor & Datatracker,
parses sections into readable chapters, and saves to the user's bookshelf.
If the RFC already exists on user's shelf and force=False, returns the existing book.
"""
raw_query = (req.rfc or "").strip()
if not raw_query:
raise HTTPException(status_code=400, detail="Укажите номер или URL спецификации RFC")
try:
rfc_num = extract_rfc_number(raw_query)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
# Check if user already loaded this RFC
if not req.force:
with get_db() as conn:
existing = 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 user_id = ? AND source_type = 'rfc' AND (title LIKE ? OR title LIKE ?)
ORDER BY id DESC LIMIT 1
""",
(user["id"], f"RFC {rfc_num}:%", f"RFC {rfc_num}"),
).fetchone()
if existing:
res = dict(existing)
try:
res["chapters"] = json.loads(res["chapters_json"])
except Exception:
res["chapters"] = []
res["already_existed"] = True
return res
try:
title, author, chapters, rfc_num, total_words = fetch_and_parse_rfc(raw_query)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка загрузки RFC: {e}")
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 (?, ?, ?, 'rfc', ?, 0, 0.0, ?)
""",
(user["id"], title, author, 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
res["already_existed"] = False
return res
@app.get("/api/books/{book_id}") @app.get("/api/books/{book_id}")
async def api_get_book(book_id: int, user: dict = Depends(require_user)): async def api_get_book(book_id: int, user: dict = Depends(require_user)):
""" """
+241
View File
@@ -2,9 +2,12 @@ import io
import re import re
import sys import sys
import html import html
import json
import zipfile import zipfile
import posixpath import posixpath
import urllib.parse import urllib.parse
import urllib.request
import urllib.error
from pathlib import Path from pathlib import Path
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from html.parser import HTMLParser from html.parser import HTMLParser
@@ -846,3 +849,241 @@ def parse_pdf(file_bytes: bytes, filename: str = "") -> Tuple[str, str, List[Dic
return title, author, chapters return title, author, chapters
# ---------------------------------------------------------------------------
# RFC Specifications Parser & Fetcher (IETF & RFC Editor)
# ---------------------------------------------------------------------------
def extract_rfc_number(query: str) -> int:
"""
Extracts RFC number from a string, supporting inputs like:
- "9110", "rfc 9110", "RFC-9110", "rfc9110"
- "https://www.rfc-editor.org/rfc/rfc9110.txt"
- "https://datatracker.ietf.org/doc/rfc9110/"
"""
query = (query or "").strip()
if re.search(r"-\s*\d+", query) and not re.search(r"rfc-\d+", query, re.IGNORECASE):
raise ValueError(f"Недопустимый отрицательный номер RFC: '{query}'")
m = re.search(r"(?:rfc[-_ ]?|doc/(?:html/)?rfc|/rfc)?(\d+)", query, re.IGNORECASE)
if not m:
raise ValueError(f"Не удалось определить номер RFC из строки: '{query}'")
num = int(m.group(1))
if num <= 0 or num > 100000:
raise ValueError(f"Недопустимый номер RFC: {num}")
return num
def parse_rfc_text(
raw_text: str,
rfc_number: int,
title: str = "",
author: str = ""
) -> Tuple[str, str, List[Dict[str, str]], int]:
"""
Cleans raw RFC text, strips pagination artifacts and form feeds,
and splits the specification into structured readable chapters.
Returns: (clean_title, clean_author, chapters, total_words)
"""
raw_text = raw_text.replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff")
# 1. Clean pagination artifacts (running footers, form-feed \x0c, running headers)
clean = re.sub(
r"\n[^\n]*\[Page [0-9ivxlc]+\]\s*[\n\x0c]+\s*(?:RFC[^\n]*\n)?",
"\n\n",
raw_text,
flags=re.IGNORECASE
)
clean = clean.replace("\x0c", "\n\n")
lines = clean.splitlines()
# 2. If title wasn't provided, extract from the document header
if not title:
for l in lines[0:65]:
s = l.strip()
if not s:
continue
if re.match(
r"^(Internet Engineering Task Force|Request for Comments|STD:|Updates:|Obsoletes:|Category:|ISSN:|Network Working Group|BCP:|FYI:|RFC\s*\d+)",
s,
re.IGNORECASE
):
continue
if re.match(r"^Table of Contents\b", s, re.IGNORECASE):
break
if re.match(r"^Abstract\b", s, re.IGNORECASE):
break
if 4 <= len(s) <= 80 and not s.endswith((":", ";", ".")):
title = s
break
clean_title = f"RFC {rfc_number}: {title}" if title and not title.lower().startswith("rfc") else (title or f"RFC {rfc_number}")
clean_author = author or "IETF"
# 3. Detect chapters
in_toc = False
past_toc = False
headings = []
bad_sentence_starters = {
"if", "the", "when", "where", "this", "that", "these", "those",
"allow", "rather", "a", "an", "in", "for", "on", "to", "there",
"clients", "optionally", "both", "all", "each", "such", "see", "note"
}
for i, line in enumerate(lines):
trimmed = line.strip()
if not trimmed:
continue
if re.search(r"\.{4,}", line):
in_toc = True
continue
if not past_toc:
if re.match(r"^Table of Contents\b", trimmed, re.IGNORECASE):
in_toc = True
continue
if in_toc:
if (line.startswith("1.") or line.startswith("1 ") or re.match(r"^\s{10,}1\.\s+[A-Z]", line)) and not re.search(r"\.{3,}", line) and not re.search(r"\s+\d+$", trimmed):
in_toc = False
past_toc = True
else:
continue
prev_blank = (i == 0) or (not lines[i - 1].strip())
if not prev_blank:
continue
m = re.match(
r"^(?:([0-9]{1,2}\.?)\s+([A-Za-z][^\n]{1,65})|(Appendix\s+[A-Z]\.?\s*[^\n]{0,65})|(Authors?'? Addresses\b.*)|(Full Copyright Statement\b.*))$",
trimmed,
re.IGNORECASE
)
if not m and re.match(r"^[0-9]{1,2}\.\s+[A-Z\s]{3,50}$", trimmed):
m = True
if m:
if trimmed.endswith((".", ",", ":", ";", "-")):
continue
if len(trimmed) > 70:
continue
words = re.findall(r"\b[A-Za-z]+\b", trimmed)
if len(words) >= 2:
sec_word = words[0].lower() if not words[0].isdigit() else (words[1].lower() if len(words) > 1 else "")
if sec_word in bad_sentence_starters:
continue
headings.append((i, trimmed))
# Deduplicate headings that are right next to each other
deduped_headings = []
for h in headings:
if not deduped_headings or (h[0] - deduped_headings[-1][0] >= 3):
deduped_headings.append(h)
chapters: List[Dict[str, str]] = []
if deduped_headings:
first_line_idx = deduped_headings[0][0]
intro_content = "\n".join(lines[:first_line_idx]).strip()
if count_words(intro_content) >= 30:
chapters.append({
"title": "Overview & Abstract",
"content": intro_content
})
for idx, (line_idx, h_title) in enumerate(deduped_headings):
next_line_idx = deduped_headings[idx + 1][0] if idx + 1 < len(deduped_headings) else len(lines)
body = "\n".join(lines[line_idx:next_line_idx]).strip()
if body:
chapters.append({
"title": h_title,
"content": body
})
if not chapters:
paras = [p.strip() for p in re.split(r"\n{2,}", clean) if p.strip()]
chunks = []
curr = []
curr_w = 0
for p in paras:
w = count_words(p)
if curr_w + w > 1500 and curr:
chunks.append("\n\n".join(curr))
curr = [p]
curr_w = w
else:
curr.append(p)
curr_w += w
if curr:
chunks.append("\n\n".join(curr))
for idx, chunk in enumerate(chunks, 1):
chapters.append({
"title": f"Section {idx}",
"content": chunk
})
total_words = sum(count_words(ch["content"]) for ch in chapters)
return clean_title, clean_author, chapters, total_words
def fetch_and_parse_rfc(query: str) -> Tuple[str, str, List[Dict[str, str]], int, int]:
"""
Fetches an RFC by number or URL from RFC Editor & IETF Datatracker,
cleans formatting, parses chapters, and returns:
(clean_title, author, chapters, rfc_number, total_words)
"""
rfc_num = extract_rfc_number(query)
# 1. Fetch metadata from IETF Datatracker (title, authors)
dt_title = ""
dt_authors = []
try:
dt_url = f"https://datatracker.ietf.org/doc/rfc{rfc_num}/doc.json"
req = urllib.request.Request(dt_url, headers={"User-Agent": "study-qpid-pro/1.0 (educational-reader)"})
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
dt_title = (data.get("title") or "").strip()
for a in data.get("authors", []):
name = a.get("name") or a.get("person", {}).get("plain_name")
if name and name not in dt_authors:
dt_authors.append(name.strip())
except Exception:
pass
# 2. Fetch raw text from RFC Editor (primary) or Datatracker (fallback)
raw_text = None
urls_to_try = [
f"https://www.rfc-editor.org/rfc/rfc{rfc_num}.txt",
f"https://datatracker.ietf.org/doc/rfc{rfc_num}.txt",
]
fetch_err = None
for u in urls_to_try:
try:
req = urllib.request.Request(u, headers={"User-Agent": "study-qpid-pro/1.0 (educational-reader)"})
with urllib.request.urlopen(req, timeout=8) as resp:
raw_bytes = resp.read()
raw_text = raw_bytes.decode("utf-8", errors="replace")
break
except urllib.error.HTTPError as he:
fetch_err = he
if he.code == 404:
continue
except Exception as e:
fetch_err = e
continue
if not raw_text:
if isinstance(fetch_err, urllib.error.HTTPError) and fetch_err.code == 404:
raise ValueError(f"RFC {rfc_num} не найден в базе RFC Editor (404 Not Found)")
err_msg = str(fetch_err) if fetch_err else "документ недоступен"
raise ValueError(f"Не удалось загрузить RFC {rfc_num}: {err_msg}")
author = ("IETF (" + ", ".join(dt_authors) + ")") if dt_authors else "IETF"
clean_title, clean_author, chapters, total_words = parse_rfc_text(
raw_text, rfc_number=rfc_num, title=dt_title, author=author
)
return clean_title, clean_author, chapters, rfc_num, total_words
+204
View File
@@ -3399,6 +3399,210 @@ body.drawer-open {
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
} }
/* Bookshelf Divider between General Books & RFC Specifications */
.bookshelf-divider {
margin: 48px 0 36px;
border: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--border-color) 15%, var(--border-color) 85%, transparent);
}
/* RFC Specifications Section */
.rfc-section {
margin-top: 8px;
}
.rfc-header {
margin-bottom: 20px;
}
.rfc-title-row {
display: flex;
align-items: center;
gap: 14px;
}
.rfc-icon-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
font-size: 24px;
background: rgba(14, 165, 233, 0.12);
border: 1px solid rgba(14, 165, 233, 0.28);
border-radius: var(--radius-md);
flex-shrink: 0;
}
.rfc-heading {
font-size: 20px;
font-weight: 700;
color: var(--text-main);
margin: 0;
}
.rfc-fetch-box {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px 24px;
margin-bottom: 24px;
box-shadow: var(--shadow-sm);
}
.rfc-fetch-form {
margin-bottom: 16px;
}
.rfc-input-wrap {
display: flex;
align-items: stretch;
gap: 10px;
}
.rfc-input-prefix {
display: inline-flex;
align-items: center;
padding: 0 14px;
font-weight: 700;
font-size: 13px;
letter-spacing: 0.05em;
color: #38bdf8;
background: rgba(56, 189, 248, 0.12);
border: 1px solid rgba(56, 189, 248, 0.25);
border-radius: var(--radius-md);
user-select: none;
}
.rfc-input {
flex: 1;
min-width: 0;
}
.rfc-fetch-btn {
white-space: nowrap;
padding: 0 20px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.rfc-fetch-status {
margin-top: 10px;
padding: 8px 14px;
font-size: 13px;
border-radius: var(--radius-md);
transition: all 0.2s ease;
}
.rfc-status-loading {
background: rgba(14, 165, 233, 0.1);
color: #38bdf8;
border: 1px solid rgba(14, 165, 233, 0.25);
}
.rfc-status-success {
background: rgba(34, 197, 94, 0.12);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.25);
}
.rfc-status-error {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.25);
}
.rfc-presets-wrap {
display: flex;
flex-direction: column;
gap: 10px;
}
.rfc-presets-label {
font-size: 12px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--text-dim);
}
.rfc-presets-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.rfc-preset-chip {
background: var(--bg-input);
color: var(--text-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
padding: 6px 14px;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
user-select: none;
display: inline-flex;
align-items: center;
gap: 5px;
}
.rfc-preset-chip:hover {
background: rgba(14, 165, 233, 0.15);
border-color: #38bdf8;
color: #38bdf8;
transform: translateY(-1px);
}
.rfc-empty-state {
padding: 36px 20px;
background: var(--bg-surface);
border: 1px dashed var(--border-color);
border-radius: var(--radius-lg);
margin-bottom: 24px;
}
.rfc-card {
border-color: rgba(56, 189, 248, 0.2);
}
.rfc-card:hover {
border-color: rgba(56, 189, 248, 0.5);
box-shadow: 0 8px 24px rgba(14, 165, 233, 0.12);
}
.rfc-cover-icon {
background: rgba(14, 165, 233, 0.1);
border-color: rgba(14, 165, 233, 0.25);
}
.badge-rfc {
background: rgba(14, 165, 233, 0.2);
color: #38bdf8;
border: 1px solid rgba(14, 165, 233, 0.35);
}
.rfc-progress-fill {
background: linear-gradient(90deg, #0284c7, #38bdf8);
}
@media (max-width: 640px) {
.rfc-input-wrap {
flex-direction: column;
}
.rfc-input-prefix {
display: none;
}
.rfc-fetch-btn {
width: 100%;
padding: 10px 16px;
}
}
/* Reader Interface */ /* Reader Interface */
.reader-interface { .reader-interface {
background-color: var(--bg-surface); background-color: var(--bg-surface);
+54
View File
@@ -545,6 +545,60 @@
<p>Загрузите книгу в формате EPUB или PDF, текстовый файл TXT/MD или вставьте текст статьи, чтобы начать интерактивное чтение.</p> <p>Загрузите книгу в формате EPUB или PDF, текстовый файл TXT/MD или вставьте текст статьи, чтобы начать интерактивное чтение.</p>
</div> </div>
<div id="bookshelf-grid" class="bookshelf-grid"></div> <div id="bookshelf-grid" class="bookshelf-grid"></div>
<!-- Divider between General Books & RFC Specifications -->
<hr class="bookshelf-divider">
<!-- RFC Specifications Section -->
<div id="rfc-section" class="rfc-section">
<div class="rfc-header">
<div class="rfc-title-wrap">
<div class="rfc-title-row">
<span class="rfc-icon-badge">🌐</span>
<div>
<h3 class="rfc-heading">Спецификации IETF & RFC Editor</h3>
<p class="subtitle">Интерактивное чтение официальных стандартов интернета на английском с клик-переводом и авто-словарем</p>
</div>
</div>
</div>
</div>
<!-- RFC Fetch Form & Preset Chips -->
<div class="rfc-fetch-box">
<form id="rfc-fetch-form" class="rfc-fetch-form">
<div class="rfc-input-wrap">
<span class="rfc-input-prefix">RFC</span>
<input type="text" id="rfc-input" class="input-styled rfc-input" placeholder="Номер (напр. 9110, 7540, 8446, 6749) или URL rfc-editor.org" autocomplete="off" spellcheck="false">
<button type="submit" id="rfc-fetch-btn" class="btn btn-primary rfc-fetch-btn">
<span>⚡ Загрузить RFC</span>
</button>
</div>
<div id="rfc-fetch-status" class="rfc-fetch-status hidden"></div>
</form>
<div class="rfc-presets-wrap">
<span class="rfc-presets-label">Популярные стандарты:</span>
<div class="rfc-presets-list">
<button type="button" class="rfc-preset-chip" data-rfc="9110" title="HTTP Semantics (RFC 9110)">🌐 HTTP/1.1 (9110)</button>
<button type="button" class="rfc-preset-chip" data-rfc="7540" title="HTTP/2 (RFC 7540)">⚡ HTTP/2 (7540)</button>
<button type="button" class="rfc-preset-chip" data-rfc="9000" title="QUIC Transport Protocol (RFC 9000)">🚀 QUIC (9000)</button>
<button type="button" class="rfc-preset-chip" data-rfc="8446" title="TLS Protocol 1.3 (RFC 8446)">🔒 TLS 1.3 (8446)</button>
<button type="button" class="rfc-preset-chip" data-rfc="6749" title="OAuth 2.0 Authorization Framework (RFC 6749)">🔑 OAuth 2.0 (6749)</button>
<button type="button" class="rfc-preset-chip" data-rfc="6455" title="The WebSocket Protocol (RFC 6455)">🔌 WebSocket (6455)</button>
<button type="button" class="rfc-preset-chip" data-rfc="793" title="Transmission Control Protocol (RFC 793)">📡 TCP (793)</button>
<button type="button" class="rfc-preset-chip" data-rfc="1035" title="Domain Names Implementation (RFC 1035)">🧭 DNS (1035)</button>
</div>
</div>
</div>
<!-- RFC Books Grid / Empty State -->
<div id="rfc-empty-state" class="empty-state rfc-empty-state hidden">
<div class="empty-icon">📜</div>
<h4>Нет сохраненных RFC спецификаций</h4>
<p>Введите номер стандарта выше или нажмите на одну из быстрых кнопок, чтобы разобрать спецификацию на главы и читать в интерактивном режиме.</p>
</div>
<div id="rfc-grid" class="bookshelf-grid rfc-grid"></div>
</div>
</div> </div>
<!-- Reading Interface (Active when a book is open) --> <!-- Reading Interface (Active when a book is open) -->
+14
View File
@@ -215,6 +215,20 @@ export const api = {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
},
async fetchRfc(query, force = false) {
const resp = await fetch('/api/books/fetch-rfc', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rfc: query, force: force })
});
if (resp.ok) return await resp.json();
let detail = 'Не удалось загрузить RFC';
try {
const errData = await resp.json();
detail = errData.detail || detail;
} catch (_) {}
throw new Error(detail);
} }
}, },
+165 -64
View File
@@ -39,11 +39,84 @@ export function getCurrentChapterIndex() {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Bookshelf Actions & Rendering // Bookshelf Actions & Rendering
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
function createBookCard(b) {
const card = document.createElement('div');
const isRfc = (b.source_type === 'rfc');
card.className = isRfc ? 'book-card rfc-card' : 'book-card';
const totalCh = Math.max(1, b.chapters_count || 1);
const chIndex = Math.max(0, Math.min(totalCh - 1, b.current_chapter || 0));
const pos = (typeof b.current_position === 'number') ? Math.max(0, Math.min(1.0, b.current_position)) : 0.0;
const progressFraction = (chIndex + pos) / totalCh;
const totalPct = Math.min(100, Math.max(0, Math.round(progressFraction * 100)));
const chPct = Math.min(100, Math.max(0, Math.round(pos * 100)));
const progressLabels = totalCh === 1
? `<span>1 глава • ${totalPct}%</span><span>${totalPct}%</span>`
: `<span>Глава ${chIndex + 1} из ${totalCh} (${chPct}%)</span><span>${totalPct}%</span>`;
let coverIcon = '📄';
if (b.source_type === 'epub') coverIcon = '📕';
else if (b.source_type === 'pdf') coverIcon = '📑';
else if (b.source_type === 'rfc') coverIcon = '🌐';
const badgeClass = isRfc ? 'badge-format badge-rfc' : 'badge-format';
card.innerHTML = `
<div>
<div class="book-card-top">
<div class="book-cover-icon ${isRfc ? 'rfc-cover-icon' : ''}">${coverIcon}</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 || (isRfc ? 'IETF' : 'Неизвестный автор'))}</p>
<div class="book-card-badges">
<span class="${badgeClass}">${escapeHtml((b.source_type || 'text').toUpperCase())}</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">
${progressLabels}
</div>
<div class="book-progress-bar-bg">
<div class="book-progress-bar-fill ${isRfc ? 'rfc-progress-fill' : ''}" style="width: ${totalPct}%"></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', () => {
window.location.hash = `#reader/book/${b.id}`;
});
card.querySelector('.train-book-btn').addEventListener('click', () => {
const bookTag = slugify(b.title);
window.location.hash = `#srs/${encodeURIComponent(bookTag)}`;
});
card.querySelector('.delete-book-btn').addEventListener('click', async () => {
const typeName = isRfc ? 'спецификацию' : 'книгу';
if (confirm(`Удалить ${typeName} "${b.title}" из библиотеки?`)) {
await api.books.delete(b.id);
loadBookshelf();
}
});
return card;
}
export async function loadBookshelf() { export async function loadBookshelf() {
const bookshelfContainer = document.getElementById('bookshelf-container'); const bookshelfContainer = document.getElementById('bookshelf-container');
const readerInterface = document.getElementById('reader-interface'); const readerInterface = document.getElementById('reader-interface');
const bookshelfGrid = document.getElementById('bookshelf-grid'); const bookshelfGrid = document.getElementById('bookshelf-grid');
const bookshelfEmpty = document.getElementById('bookshelf-empty'); const bookshelfEmpty = document.getElementById('bookshelf-empty');
const rfcGrid = document.getElementById('rfc-grid');
const rfcEmptyState = document.getElementById('rfc-empty-state');
if (!bookshelfContainer) return; if (!bookshelfContainer) return;
if (!window.location.hash.includes('/book/')) { if (!window.location.hash.includes('/book/')) {
@@ -51,15 +124,13 @@ export async function loadBookshelf() {
if (readerInterface) readerInterface.classList.add('hidden'); if (readerInterface) readerInterface.classList.add('hidden');
} }
if (bookshelfGrid) bookshelfGrid.innerHTML = ''; if (bookshelfGrid) bookshelfGrid.innerHTML = '';
if (rfcGrid) rfcGrid.innerHTML = '';
if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden'); if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden');
if (rfcEmptyState) rfcEmptyState.classList.add('hidden');
try { try {
const books = await api.books.getAll(); const books = await api.books.getAll();
if (!books || books.length === 0) { renderBookshelf(books || []);
if (bookshelfEmpty) bookshelfEmpty.classList.remove('hidden');
} else {
renderBookshelf(books);
}
} catch (e) { } catch (e) {
console.error('Error loading bookshelf:', e); console.error('Error loading bookshelf:', e);
} }
@@ -67,70 +138,33 @@ export async function loadBookshelf() {
export function renderBookshelf(books) { export function renderBookshelf(books) {
const bookshelfGrid = document.getElementById('bookshelf-grid'); const bookshelfGrid = document.getElementById('bookshelf-grid');
if (!bookshelfGrid) return; const bookshelfEmpty = document.getElementById('bookshelf-empty');
bookshelfGrid.innerHTML = ''; const rfcGrid = document.getElementById('rfc-grid');
const rfcEmptyState = document.getElementById('rfc-empty-state');
books.forEach(b => { if (bookshelfGrid) bookshelfGrid.innerHTML = '';
const card = document.createElement('div'); if (rfcGrid) rfcGrid.innerHTML = '';
card.className = 'book-card';
const totalCh = Math.max(1, b.chapters_count || 1); const generalBooks = (books || []).filter(b => b.source_type !== 'rfc');
const chIndex = Math.max(0, Math.min(totalCh - 1, b.current_chapter || 0)); const rfcBooks = (books || []).filter(b => b.source_type === 'rfc');
const pos = (typeof b.current_position === 'number') ? Math.max(0, Math.min(1.0, b.current_position)) : 0.0;
const progressFraction = (chIndex + pos) / totalCh; if (bookshelfEmpty) {
const totalPct = Math.min(100, Math.max(0, Math.round(progressFraction * 100))); bookshelfEmpty.classList.toggle('hidden', generalBooks.length > 0);
const chPct = Math.min(100, Math.max(0, Math.round(pos * 100))); }
if (bookshelfGrid) {
const progressLabels = totalCh === 1 generalBooks.forEach(b => {
? `<span>1 глава • ${totalPct}%</span><span>${totalPct}%</span>` bookshelfGrid.appendChild(createBookCard(b));
: `<span>Глава ${chIndex + 1} из ${totalCh} (${chPct}%)</span><span>${totalPct}%</span>`;
card.innerHTML = `
<div>
<div class="book-card-top">
<div class="book-cover-icon">${b.source_type === 'epub' ? '📕' : (b.source_type === 'pdf' ? '📑' : '📄')}</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 || 'text').toUpperCase())}</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">
${progressLabels}
</div>
<div class="book-progress-bar-bg">
<div class="book-progress-bar-fill" style="width: ${totalPct}%"></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', () => {
window.location.hash = `#reader/book/${b.id}`;
});
card.querySelector('.train-book-btn').addEventListener('click', () => {
const bookTag = slugify(b.title);
window.location.hash = `#srs/${encodeURIComponent(bookTag)}`;
});
card.querySelector('.delete-book-btn').addEventListener('click', async () => {
if (confirm(`Удалить книгу "${b.title}" из библиотеки?`)) {
await api.books.delete(b.id);
loadBookshelf();
}
}); });
}
bookshelfGrid.appendChild(card); if (rfcEmptyState) {
}); rfcEmptyState.classList.toggle('hidden', rfcBooks.length > 0);
}
if (rfcGrid) {
rfcBooks.forEach(b => {
rfcGrid.appendChild(createBookCard(b));
});
}
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -2335,4 +2369,71 @@ export function initReader() {
} }
}); });
} }
// RFC Fetching & Presets
const rfcFetchForm = document.getElementById('rfc-fetch-form');
const rfcInput = document.getElementById('rfc-input');
const rfcFetchBtn = document.getElementById('rfc-fetch-btn');
const rfcFetchStatus = document.getElementById('rfc-fetch-status');
const rfcPresetChips = document.querySelectorAll('.rfc-preset-chip');
async function handleRfcFetch(query) {
if (!query) return;
query = query.trim();
if (!query) return;
if (rfcFetchBtn) rfcFetchBtn.disabled = true;
if (rfcInput) rfcInput.disabled = true;
if (rfcFetchStatus) {
rfcFetchStatus.className = 'rfc-fetch-status rfc-status-loading';
rfcFetchStatus.innerHTML = `<span>⏳ Загрузка и разбор спецификации RFC...</span>`;
rfcFetchStatus.classList.remove('hidden');
}
try {
const book = await api.books.fetchRfc(query);
if (rfcFetchStatus) {
rfcFetchStatus.className = 'rfc-fetch-status rfc-status-success';
rfcFetchStatus.innerHTML = `<span>✅ ${escapeHtml(book.title)} успешно добавлен!</span>`;
}
showToast(book.already_existed ? `Открываем ${book.title}` : `RFC спецификация загружена!`);
if (rfcInput) rfcInput.value = '';
setTimeout(() => {
if (rfcFetchStatus) rfcFetchStatus.classList.add('hidden');
window.location.hash = `#reader/book/${book.id}`;
}, 400);
} catch (err) {
console.error('RFC fetch error:', err);
if (rfcFetchStatus) {
rfcFetchStatus.className = 'rfc-fetch-status rfc-status-error';
rfcFetchStatus.innerHTML = `<span>❌ Ошибка: ${escapeHtml(err.message || 'Не удалось загрузить RFC')}</span>`;
rfcFetchStatus.classList.remove('hidden');
}
showToast(err.message || 'Ошибка загрузки RFC', 4000);
} finally {
if (rfcFetchBtn) rfcFetchBtn.disabled = false;
if (rfcInput) {
rfcInput.disabled = false;
rfcInput.focus();
}
}
}
if (rfcFetchForm) {
rfcFetchForm.addEventListener('submit', (e) => {
e.preventDefault();
const val = rfcInput ? rfcInput.value : '';
handleRfcFetch(val);
});
}
if (rfcPresetChips && rfcPresetChips.length > 0) {
rfcPresetChips.forEach(chip => {
chip.addEventListener('click', () => {
const rfcVal = chip.dataset.rfc;
if (rfcInput) rfcInput.value = rfcVal;
handleRfcFetch(rfcVal);
});
});
}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
// Service Worker for Notes English PWA (Stage 2) // Service Worker for Notes English PWA (Stage 2)
// Strategy: Cache-first for static assets, Network-first for API calls. // Strategy: Cache-first for static assets, Network-first for API calls.
const CACHE_NAME = 'notes-v5.7'; const CACHE_NAME = 'notes-v5.8';
const OFFLINE_URL = '/'; const OFFLINE_URL = '/';
const PRECACHE_URLS = [ const PRECACHE_URLS = [
+112
View File
@@ -1645,6 +1645,118 @@ def test_background_tasks_and_book_mining():
assert cancel_resp.json()["cancelled"] is True assert cancel_resp.json()["cancelled"] is True
def test_rfc_parser_and_api(monkeypatch):
from app.reader import extract_rfc_number, parse_rfc_text
# 1. Number extraction
assert extract_rfc_number("9110") == 9110
assert extract_rfc_number("RFC 8446") == 8446
assert extract_rfc_number("rfc-6749") == 6749
assert extract_rfc_number("https://www.rfc-editor.org/rfc/rfc7540.txt") == 7540
assert extract_rfc_number("https://datatracker.ietf.org/doc/rfc9000/") == 9000
with pytest.raises(ValueError):
extract_rfc_number("invalid-rfc-string-without-number")
with pytest.raises(ValueError):
extract_rfc_number("rfc -5")
# 2. Parsing raw text with pagination artifacts
sample_rfc = """Internet Engineering Task Force (IETF)
Request for Comments: 9999
Category: Standards Track
Test Protocol Specification
Abstract
This is a test abstract for RFC 9999 demonstrating cleaning.
Table of Contents
1. Introduction .................................................... 1
2. Protocol Framing ................................................ 2
Authors' Addresses ................................................. 3
Standards Track [Page 1]
\x0c
RFC 9999 Test Protocol Specification June 2026
1. Introduction
The Test Protocol is designed for testing RFC parsers.
It provides ultra-clean chapter segmentation.
2. Protocol Framing
Framing uses standard binary encodings.
Authors' Addresses
Jane Doe
Example Corp
"""
clean_title, author, chapters, total_words = parse_rfc_text(sample_rfc, rfc_number=9999)
assert clean_title == "RFC 9999: Test Protocol Specification"
assert author == "IETF"
assert len(chapters) >= 3
# Check that pagination footer/header was stripped
for ch in chapters:
assert "[Page 1]" not in ch["content"]
assert "\x0c" not in ch["content"]
assert any("Introduction" in ch["title"] for ch in chapters)
assert any("Framing" in ch["title"] for ch in chapters)
assert any("Addresses" in ch["title"] for ch in chapters)
assert total_words > 20
# 3. Test API endpoint unauthenticated
anon_client = TestClient(app)
resp = anon_client.post("/api/books/fetch-rfc", json={"rfc": "9999"})
assert resp.status_code == 401
# 4. Test API endpoint authenticated with mock fetch
import app.main as main_mod
def mock_fetch_and_parse_rfc(query):
return ("RFC 9999: Test Protocol Specification", "IETF (Jane Doe)", chapters, 9999, total_words)
monkeypatch.setattr(main_mod, "fetch_and_parse_rfc", mock_fetch_and_parse_rfc)
# Login demo user
login_resp = client.post("/api/login", json={"username": "demo_user", "password": "demo_pass_999"})
assert login_resp.status_code == 200
# Invalid input
bad_resp = client.post("/api/books/fetch-rfc", json={"rfc": "no_number_here"})
assert bad_resp.status_code == 400
# Fetch valid RFC
fetch_resp = client.post("/api/books/fetch-rfc", json={"rfc": "9999"})
assert fetch_resp.status_code == 200
book_data = fetch_resp.json()
assert book_data["source_type"] == "rfc"
assert book_data["title"] == "RFC 9999: Test Protocol Specification"
assert book_data["already_existed"] is False
assert len(book_data["chapters"]) >= 3
book_id = book_data["id"]
# Fetch again without force -> should return existing book
fetch_again = client.post("/api/books/fetch-rfc", json={"rfc": "9999"})
assert fetch_again.status_code == 200
again_data = fetch_again.json()
assert again_data["id"] == book_id
assert again_data["already_existed"] is True
# Check bookshelf retrieval includes RFC book
shelf_resp = client.get("/api/books")
assert shelf_resp.status_code == 200
shelf_books = shelf_resp.json()
rfc_books = [b for b in shelf_books if b["source_type"] == "rfc"]
assert len(rfc_books) > 0
assert rfc_books[0]["id"] == book_id