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:
Antigravity committed 2026-10-06 21:46:07 +03:00
1 parent e73d3470b3
commit 82b7a654ca
8 files changed
+877 -66

No files matched your search

+165 -64
View File
@@ -39,11 +39,84 @@ export function getCurrentChapterIndex() {
// -----------------------------------------------------------------------
// 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() {
const bookshelfContainer = document.getElementById('bookshelf-container');
const readerInterface = document.getElementById('reader-interface');
const bookshelfGrid = document.getElementById('bookshelf-grid');
const bookshelfEmpty = document.getElementById('bookshelf-empty');
const rfcGrid = document.getElementById('rfc-grid');
const rfcEmptyState = document.getElementById('rfc-empty-state');
if (!bookshelfContainer) return;
if (!window.location.hash.includes('/book/')) {
@@ -51,15 +124,13 @@ export async function loadBookshelf() {
if (readerInterface) readerInterface.classList.add('hidden');
}
if (bookshelfGrid) bookshelfGrid.innerHTML = '';
if (rfcGrid) rfcGrid.innerHTML = '';
if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden');
if (rfcEmptyState) rfcEmptyState.classList.add('hidden');
try {
const books = await api.books.getAll();
if (!books || books.length === 0) {
if (bookshelfEmpty) bookshelfEmpty.classList.remove('hidden');
} else {
renderBookshelf(books);
}
renderBookshelf(books || []);
} catch (e) {
console.error('Error loading bookshelf:', e);
}
@@ -67,70 +138,33 @@ export async function loadBookshelf() {
export function renderBookshelf(books) {
const bookshelfGrid = document.getElementById('bookshelf-grid');
if (!bookshelfGrid) return;
bookshelfGrid.innerHTML = '';
const bookshelfEmpty = document.getElementById('bookshelf-empty');
const rfcGrid = document.getElementById('rfc-grid');
const rfcEmptyState = document.getElementById('rfc-empty-state');
books.forEach(b => {
const card = document.createElement('div');
card.className = 'book-card';
if (bookshelfGrid) bookshelfGrid.innerHTML = '';
if (rfcGrid) rfcGrid.innerHTML = '';
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 generalBooks = (books || []).filter(b => b.source_type !== 'rfc');
const rfcBooks = (books || []).filter(b => b.source_type === 'rfc');
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>`;
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();
}
if (bookshelfEmpty) {
bookshelfEmpty.classList.toggle('hidden', generalBooks.length > 0);
}
if (bookshelfGrid) {
generalBooks.forEach(b => {
bookshelfGrid.appendChild(createBookCard(b));
});
}
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);
});
});
}
}