feat(reader): autonomous Wikipedia import and reading with media, tables and settings

This commit is contained in:
Antigravity committed 2026-10-07 09:47:33 +03:00
1 parent 52db766b23
commit dee9b8d10b
9 files changed
+1659 -24

No files matched your search

+359 -14
View File
@@ -42,7 +42,10 @@ export function getCurrentChapterIndex() {
function createBookCard(b) {
const card = document.createElement('div');
const isRfc = (b.source_type === 'rfc');
card.className = isRfc ? 'book-card rfc-card' : 'book-card';
const isWiki = (b.source_type === 'wikipedia');
if (isWiki) card.className = 'book-card wiki-card';
else if (isRfc) card.className = 'book-card rfc-card';
else card.className = 'book-card';
const totalCh = Math.max(1, b.chapters_count || 1);
const chIndex = Math.max(0, Math.min(totalCh - 1, b.current_chapter || 0));
@@ -56,22 +59,42 @@ function createBookCard(b) {
? `<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 = '🌐';
let coverHtml = '';
if (b.cover_url) {
coverHtml = `<div class="book-cover-img-wrap"><img src="${escapeHtml(b.cover_url)}" class="book-cover-img" alt="Cover" loading="lazy" onerror="this.parentElement.innerHTML='<div class=\\\'book-cover-icon ${isWiki ? 'wiki-cover-icon' : (isRfc ? 'rfc-cover-icon' : '')}\\\'>${isWiki ? '🌐' : (isRfc ? '📜' : '📄')}</div>';"/></div>`;
} else {
let coverIcon = '📄';
if (b.source_type === 'epub') coverIcon = '📕';
else if (b.source_type === 'pdf') coverIcon = '📑';
else if (isRfc) coverIcon = '📜';
else if (isWiki) coverIcon = '🌐';
const coverIconClass = isWiki ? 'wiki-cover-icon' : (isRfc ? 'rfc-cover-icon' : '');
coverHtml = `<div class="book-cover-icon ${coverIconClass}">${coverIcon}</div>`;
}
const badgeClass = isRfc ? 'badge-format badge-rfc' : 'badge-format';
let badgeClass = 'badge-format';
if (isWiki) badgeClass = 'badge-format badge-wiki';
else if (isRfc) badgeClass = 'badge-format badge-rfc';
const readMinutes = Math.max(1, Math.round((b.total_words || 800) / 180));
const timeBadge = (b.total_words && b.total_words > 0)
? `<span class="badge-time" title="Примерное время чтения: ~${readMinutes} минут">⏱️ ~${readMinutes} мин</span>`
: '';
const progressFillClass = isWiki ? 'wiki-progress-fill' : (isRfc ? 'rfc-progress-fill' : '');
const authorDisplay = b.author || (isWiki ? 'Wikipedia' : (isRfc ? 'IETF' : 'Неизвестный автор'));
const sourceLabel = isWiki ? 'WIKIPEDIA' : (isRfc ? 'RFC' : (b.source_type || 'text').toUpperCase());
card.innerHTML = `
<div>
<div class="book-card-top">
<div class="book-cover-icon ${isRfc ? 'rfc-cover-icon' : ''}">${coverIcon}</div>
${coverHtml}
<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>
<p class="book-card-author">${escapeHtml(authorDisplay)}</p>
<div class="book-card-badges">
<span class="${badgeClass}">${escapeHtml((b.source_type || 'text').toUpperCase())}</span>
<span class="${badgeClass}">${escapeHtml(sourceLabel)}</span>
${timeBadge}
<span class="badge-words">${b.total_words ? b.total_words.toLocaleString() + ' слов' : ''}</span>
</div>
</div>
@@ -81,14 +104,14 @@ function createBookCard(b) {
${progressLabels}
</div>
<div class="book-progress-bar-bg">
<div class="book-progress-bar-fill ${isRfc ? 'rfc-progress-fill' : ''}" style="width: ${totalPct}%"></div>
<div class="book-progress-bar-fill ${progressFillClass}" 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>
<button class="icon-btn delete-book-btn" data-id="${b.id}" title="Удалить из библиотеки">🗑️</button>
</div>
`;
@@ -100,7 +123,7 @@ function createBookCard(b) {
window.location.hash = `#srs/${encodeURIComponent(bookTag)}`;
});
card.querySelector('.delete-book-btn').addEventListener('click', async () => {
const typeName = isRfc ? 'спецификацию' : 'книгу';
const typeName = isWiki ? 'статью Википедии' : (isRfc ? 'спецификацию' : 'книгу');
if (confirm(`Удалить ${typeName} "${b.title}" из библиотеки?`)) {
await api.books.delete(b.id);
loadBookshelf();
@@ -117,6 +140,8 @@ export async function loadBookshelf() {
const bookshelfEmpty = document.getElementById('bookshelf-empty');
const rfcGrid = document.getElementById('rfc-grid');
const rfcEmptyState = document.getElementById('rfc-empty-state');
const wikiGrid = document.getElementById('wiki-grid');
const wikiEmptyState = document.getElementById('wiki-empty-state');
if (!bookshelfContainer) return;
if (!window.location.hash.includes('/book/')) {
@@ -125,8 +150,10 @@ export async function loadBookshelf() {
}
if (bookshelfGrid) bookshelfGrid.innerHTML = '';
if (rfcGrid) rfcGrid.innerHTML = '';
if (wikiGrid) wikiGrid.innerHTML = '';
if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden');
if (rfcEmptyState) rfcEmptyState.classList.add('hidden');
if (wikiEmptyState) wikiEmptyState.classList.add('hidden');
try {
const books = await api.books.getAll();
@@ -141,12 +168,16 @@ export function renderBookshelf(books) {
const bookshelfEmpty = document.getElementById('bookshelf-empty');
const rfcGrid = document.getElementById('rfc-grid');
const rfcEmptyState = document.getElementById('rfc-empty-state');
const wikiGrid = document.getElementById('wiki-grid');
const wikiEmptyState = document.getElementById('wiki-empty-state');
if (bookshelfGrid) bookshelfGrid.innerHTML = '';
if (rfcGrid) rfcGrid.innerHTML = '';
if (wikiGrid) wikiGrid.innerHTML = '';
const generalBooks = (books || []).filter(b => b.source_type !== 'rfc');
const generalBooks = (books || []).filter(b => b.source_type !== 'rfc' && b.source_type !== 'wikipedia');
const rfcBooks = (books || []).filter(b => b.source_type === 'rfc');
const wikiBooks = (books || []).filter(b => b.source_type === 'wikipedia');
if (bookshelfEmpty) {
bookshelfEmpty.classList.toggle('hidden', generalBooks.length > 0);
@@ -157,6 +188,15 @@ export function renderBookshelf(books) {
});
}
if (wikiEmptyState) {
wikiEmptyState.classList.toggle('hidden', wikiBooks.length > 0);
}
if (wikiGrid) {
wikiBooks.forEach(b => {
wikiGrid.appendChild(createBookCard(b));
});
}
if (rfcEmptyState) {
rfcEmptyState.classList.toggle('hidden', rfcBooks.length > 0);
}
@@ -375,6 +415,24 @@ export function buildChapterDOM(chapterIdx, knownWordsMap, isContinuous = false)
blockEl.appendChild(header);
blockEl.appendChild(pre);
} else if (block.type === 'figure') {
blockEl = document.createElement('figure');
blockEl.className = 'reader-figure';
const img = document.createElement('img');
img.className = 'reader-figure-img';
img.src = block.src;
img.alt = block.alt || '';
img.loading = 'lazy';
img.onerror = () => { blockEl.style.display = 'none'; };
blockEl.appendChild(img);
if (block.caption) {
const figcaption = document.createElement('figcaption');
figcaption.className = 'reader-figcaption';
renderInteractiveSpans(figcaption, block.caption, block.caption, knownWordsMap);
blockEl.appendChild(figcaption);
}
} else if (block.type === 'table') {
blockEl = document.createElement('div');
blockEl.className = 'reader-table-wrap';
@@ -804,6 +862,86 @@ export function parseContentBlocks(content) {
continue;
}
// 2a. HTML Figure Block: <figure ...> ... </figure>
if (/^<figure\b/i.test(trimmed)) {
const figLines = [];
while (i < lines.length) {
figLines.push(lines[i]);
if (/<\/figure>/i.test(lines[i])) {
i++;
break;
}
i++;
}
const figHtml = figLines.join('\n');
const imgMatch = figHtml.match(/<img\b([^>]*)>/i);
let src = '';
let alt = '';
if (imgMatch) {
const srcM = imgMatch[1].match(/src=["']([^"']+)["']/i);
const altM = imgMatch[1].match(/alt=["']([^"']*)["']/i);
if (srcM) src = srcM[1];
if (altM) alt = altM[1];
}
let caption = '';
const capMatch = figHtml.match(/<figcaption\b[^>]*>([\s\S]*?)<\/figcaption>/i);
if (capMatch) {
caption = capMatch[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
if (src) {
blocks.push({
type: 'figure',
src: src,
alt: alt,
caption: caption
});
continue;
}
}
// 2b. HTML Data Table: <div class="reader-table-wrap"><table ...> or <table ...>
if (/^(?:<div[^>]*class=["'][^"']*reader-table-wrap[^"']*["'][^>]*>\s*)?<table\b/i.test(trimmed)) {
const tableLines = [];
while (i < lines.length) {
tableLines.push(lines[i]);
if (/<\/table>(?:\s*<\/div>)?/i.test(lines[i])) {
i++;
break;
}
i++;
}
const fullTableHtml = tableLines.join('\n');
const tpl = document.createElement('template');
tpl.innerHTML = fullTableHtml;
const tableEl = tpl.content.querySelector('table');
if (tableEl) {
const headers = [];
const ths = tableEl.querySelectorAll('thead th, tr:first-child th');
ths.forEach(th => headers.push(th.textContent.trim()));
const rows = [];
const trs = tableEl.querySelectorAll('tbody tr, tr');
trs.forEach((tr) => {
const tds = tr.querySelectorAll('td');
if (tds.length > 0) {
const rowCells = [];
tds.forEach(td => rowCells.push(td.textContent.trim()));
rows.push(rowCells);
}
});
if (headers.length > 0 || rows.length > 0) {
blocks.push({
type: 'table',
headers: headers,
alignments: headers.map(() => 'left'),
rows: rows
});
continue;
}
}
}
// 3. Markdown Table: lines starting with | and ending with |
if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
const tableLines = [];
@@ -950,6 +1088,9 @@ export function parseContentBlocks(content) {
/^[-*+]\s+/.test(curTrim) ||
/^\d+\.\s+/.test(curTrim) ||
/^(\-{3,}|\*{3,}|\_{3,})$/.test(curTrim) ||
/^<figure\b/i.test(curTrim) ||
/^<table\b/i.test(curTrim) ||
curTrim.includes('reader-table-wrap') ||
/^<(?:p|div)[^>]*align=["']?center/i.test(curTrim)) {
break;
}
@@ -1899,7 +2040,7 @@ export function initReader() {
if (wordSpan) {
e.stopPropagation();
const word = wordSpan.dataset.word;
const context = wordSpan.dataset.context || wordSpan.closest('p, div, li, td, th, blockquote')?.textContent || word;
const context = wordSpan.dataset.context || wordSpan.closest('p, div, li, td, th, blockquote, figcaption')?.textContent || word;
handleWordClick(word, context, wordSpan);
}
});
@@ -2448,4 +2589,208 @@ export function initReader() {
});
});
}
// -----------------------------------------------------------------------
// Wikipedia Autonomous Import & Settings
// -----------------------------------------------------------------------
const WIKI_SETTINGS_KEY = 'notes_wiki_settings';
const wikiReadBtn = document.getElementById('wiki-read-btn');
const wikiSettingsBtn = document.getElementById('wiki-settings-btn');
const wikiSettingsModal = document.getElementById('wiki-settings-modal');
const wikiSettingsCloseBtn = document.getElementById('wiki-settings-close-btn');
const wikiSettingsSaveBtn = document.getElementById('wiki-settings-save-btn');
const wikiFetchStatus = document.getElementById('wiki-fetch-status');
const wikiCustomForm = document.getElementById('wiki-custom-form');
const wikiCustomInput = document.getElementById('wiki-custom-input');
const wikiCustomFetchBtn = document.getElementById('wiki-custom-fetch-btn');
const wikiActiveLangBadge = document.getElementById('wiki-active-lang-badge');
const wikiActiveTimeBadge = document.getElementById('wiki-active-time-badge');
const wikiActiveTopicBadge = document.getElementById('wiki-active-topic-badge');
const TOPIC_LABELS = {
'random': '🎲 Любая тема',
'science': '🚀 Наука и космос',
'tech': '💻 Технологии и IT',
'history': '🏛️ История',
'nature': '🌿 Природа',
'psychology': '🧠 Психология',
'featured': '⭐ Статья дня'
};
const TIME_LABELS = {
'5': '⏱️ ~5 мин',
'10-15': '⏱️ ~10-15 мин',
'20+': '⏱️ 20+ мин',
'any': '⏱️ Любой объем'
};
function getStoredWikiSettings() {
try {
const raw = localStorage.getItem(WIKI_SETTINGS_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return {
lang: parsed.lang || 'simple',
time_budget: parsed.time_budget || '10-15',
topic: parsed.topic || 'random'
};
}
}
} catch (_) {}
return { lang: 'simple', time_budget: '10-15', topic: 'random' };
}
function saveStoredWikiSettings(settings) {
try {
localStorage.setItem(WIKI_SETTINGS_KEY, JSON.stringify(settings));
} catch (_) {}
updateWikiUIBadges(settings);
}
function updateWikiUIBadges(settings) {
if (wikiActiveLangBadge) {
wikiActiveLangBadge.textContent = settings.lang === 'en' ? 'Standard English (B2–C1)' : 'Simple English (A2–B1)';
}
if (wikiActiveTimeBadge) {
wikiActiveTimeBadge.textContent = TIME_LABELS[settings.time_budget] || '⏱️ ~10-15 мин';
}
if (wikiActiveTopicBadge) {
wikiActiveTopicBadge.textContent = TOPIC_LABELS[settings.topic] || '🎲 Любая тема';
}
}
function syncWikiModalInputs(settings) {
const langRadios = document.querySelectorAll('input[name="wiki-lang"]');
langRadios.forEach(r => {
r.checked = (r.value === settings.lang);
});
const timeRadios = document.querySelectorAll('input[name="wiki-time"]');
timeRadios.forEach(r => {
r.checked = (r.value === settings.time_budget);
});
const topicSelect = document.getElementById('wiki-topic-select');
if (topicSelect) {
topicSelect.value = settings.topic || 'random';
}
}
function readWikiModalInputs() {
const langRadio = document.querySelector('input[name="wiki-lang"]:checked');
const timeRadio = document.querySelector('input[name="wiki-time"]:checked');
const topicSelect = document.getElementById('wiki-topic-select');
return {
lang: langRadio ? langRadio.value : 'simple',
time_budget: timeRadio ? timeRadio.value : '10-15',
topic: topicSelect ? topicSelect.value : 'random'
};
}
const currentWikiSettings = getStoredWikiSettings();
updateWikiUIBadges(currentWikiSettings);
if (wikiSettingsBtn && wikiSettingsModal) {
wikiSettingsBtn.addEventListener('click', () => {
syncWikiModalInputs(getStoredWikiSettings());
wikiSettingsModal.classList.remove('hidden');
});
}
if (wikiSettingsCloseBtn && wikiSettingsModal) {
wikiSettingsCloseBtn.addEventListener('click', () => {
wikiSettingsModal.classList.add('hidden');
});
}
if (wikiSettingsModal) {
wikiSettingsModal.addEventListener('click', (e) => {
if (e.target === wikiSettingsModal) {
wikiSettingsModal.classList.add('hidden');
}
});
}
if (wikiSettingsSaveBtn && wikiSettingsModal) {
wikiSettingsSaveBtn.addEventListener('click', () => {
const newSettings = readWikiModalInputs();
saveStoredWikiSettings(newSettings);
wikiSettingsModal.classList.add('hidden');
showToast('Настройки статей Википедии сохранены!');
});
}
async function handleWikiFetch(params) {
const isCustom = Boolean(params.query);
const activeBtn = isCustom ? wikiCustomFetchBtn : wikiReadBtn;
if (activeBtn) activeBtn.disabled = true;
if (wikiReadBtn) wikiReadBtn.disabled = true;
if (wikiCustomInput) wikiCustomInput.disabled = true;
if (wikiFetchStatus) {
wikiFetchStatus.className = 'wiki-fetch-status wiki-status-loading';
wikiFetchStatus.innerHTML = `<span>⏳ ${isCustom ? 'Загрузка статьи по запросу...' : 'Подбираем и загружаем статью из Википедии...'}</span>`;
wikiFetchStatus.classList.remove('hidden');
}
try {
const book = await api.books.fetchWikipedia(params);
if (wikiFetchStatus) {
wikiFetchStatus.className = 'wiki-fetch-status wiki-status-success';
const estMin = Math.max(1, Math.round((book.total_words || 800) / 180));
wikiFetchStatus.innerHTML = `<span>✅ Статья «${escapeHtml(book.title)}» сохранена (~${estMin} мин, ${book.total_words ? book.total_words.toLocaleString() : ''} слов)!</span>`;
}
showToast(book.already_existed ? `Открываем ${book.title}` : `Статья Википедии сохранена офлайн!`);
if (wikiCustomInput) wikiCustomInput.value = '';
await loadBookshelf();
setTimeout(() => {
if (wikiFetchStatus) wikiFetchStatus.classList.add('hidden');
if (wikiSettingsModal) wikiSettingsModal.classList.add('hidden');
window.location.hash = `#reader/book/${book.id}`;
}, 500);
} catch (err) {
console.error('Wikipedia fetch error:', err);
if (wikiFetchStatus) {
wikiFetchStatus.className = 'wiki-fetch-status wiki-status-error';
wikiFetchStatus.innerHTML = `<span>❌ Ошибка: ${escapeHtml(err.message || 'Не удалось загрузить статью')}</span>`;
wikiFetchStatus.classList.remove('hidden');
}
showToast(err.message || 'Ошибка загрузки статьи Wikipedia', 4000);
} finally {
if (activeBtn) activeBtn.disabled = false;
if (wikiReadBtn) wikiReadBtn.disabled = false;
if (wikiCustomInput) wikiCustomInput.disabled = false;
}
}
if (wikiReadBtn) {
wikiReadBtn.addEventListener('click', () => {
const currentSettings = getStoredWikiSettings();
handleWikiFetch({
query: '',
lang: currentSettings.lang,
time_budget: currentSettings.time_budget,
topic: currentSettings.topic,
force: false
});
});
}
if (wikiCustomForm) {
wikiCustomForm.addEventListener('submit', (e) => {
e.preventDefault();
const val = wikiCustomInput ? wikiCustomInput.value.trim() : '';
if (!val) return;
const currentSettings = getStoredWikiSettings();
handleWikiFetch({
query: val,
lang: currentSettings.lang,
time_budget: currentSettings.time_budget,
topic: currentSettings.topic,
force: false
});
});
}
}