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:
1 parent
e73d3470b3
commit
82b7a654ca
8 files changed
+877
-66
No files matched your search
+165
-64
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user