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
@@ -3399,6 +3399,210 @@ body.drawer-open {
|
||||
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 {
|
||||
background-color: var(--bg-surface);
|
||||
|
||||
@@ -545,6 +545,60 @@
|
||||
<p>Загрузите книгу в формате EPUB или PDF, текстовый файл TXT/MD или вставьте текст статьи, чтобы начать интерактивное чтение.</p>
|
||||
</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>
|
||||
|
||||
<!-- Reading Interface (Active when a book is open) -->
|
||||
|
||||
@@ -215,6 +215,20 @@ export const api = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
// Service Worker for Notes English PWA (Stage 2)
|
||||
// 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 PRECACHE_URLS = [
|
||||
|
||||
Reference in new issue
Block a user