diff --git a/TODO.md b/TODO.md index 1865a21..3a38e13 100644 --- a/TODO.md +++ b/TODO.md @@ -120,21 +120,21 @@ --- -### 6. 🔄 Комплексная переработка навигации, роутинга и честного прогресса чтения (In Progress) -- [ ] **6.1. Нативная маршрутизация окон (URL Hash Router & Zero-Flicker F5)**: +### 6. 🔄 Комплексная переработка навигации, роутинга и честного прогресса чтения (Completed) +- [x] **6.1. Нативная маршрутизация окон (URL Hash Router & Zero-Flicker F5)**: - Внедрение хэш-роутинга: `#dictionary`, `#srs`, `#bookshelf`, `#reader/:book_id`, `#reader/:book_id/ch/:chapter_idx`. - Синхронное переключение `view` при старте скрипта (до любых сетевых запросов авторизации): если в URL `#reader/...`, сразу скрывать `#dictionary-view` и показывать скелетон читалки, устраняя мигание словарем. - Развязка асинхронных вызовов: `loadBookshelf()` больше не должен принудительно гасить `readerInterface`, если уже инициировано открытие книги через `openBook()`. - Поддержка нативной истории браузера (кнопки «Назад» / «Вперёд» на мыши, клавиатуре и мобильных свайпах). - Автоматическая синхронизация активного экрана в URL и `localStorage`. -- [ ] **6.2. Честный непрерывный расчет прогресса книги и главы (Weighted Reading Progress)**: +- [x] **6.2. Честный непрерывный расчет прогресса книги и главы (Weighted Reading Progress)**: - Устранение бага ложных «100%»: полная ликвидация формулы `(current_ch + 1) / total_ch`, выдававшей 100% на первой странице одноглавных документов. - Прогресс текущей главы в реальном времени: непрерывный расчет от вертикального скролла (`scrollTop / (scrollHeight - clientHeight) * 100`) от 0% до 100%. - Взвешенный общий прогресс книги: расчет по объему слов прочитанных глав + точная доля текущей главы: $$\text{TotalProgress} = \frac{\sum_{i=0}^{\text{cur}-1} \text{words}(ch_i) + \text{words}(ch_{\text{cur}}) \times \frac{\text{ChapterPct}}{100}}{\text{TotalWords}} \times 100\%$$ - Для документа на 1000 слов прогресс будет плавно расти: 0% $\rightarrow$ 15% $\rightarrow$ 50% $\rightarrow$ 100%. - Отображение честного суммарного прогресса на карточках книг в библиотеке и в тулбаре читалки. -- [ ] **6.3. Интеллектуальный парсинг глав и оглавление (Smart Chapter & ToC Detection)**: +- [x] **6.3. Интеллектуальный парсинг глав и оглавление (Smart Chapter & ToC Detection)**: - Расширение детектора глав в `app/reader.py`: - Markdown-заголовки всех уровней (`# `, `## `, `### `, `#### `). - Нумерованные разделы и модули (`1. `, `1.1 `, `2.0 `, `Section 1`, `Part 1`, `Раздел 1`). @@ -142,7 +142,7 @@ - Жирные заголовки в начале абзацев (`**Chapter 1**`, `**Глава 1**`). - Адаптивный порог авто-разбивки: снижение порога чанкования текста без явных глав с 3000 до 800–1000 слов, чтобы даже небольшие статьи имели логическое деление на части. - Интерактивное оглавление со статусами прочтения: `✔ Прочитано`, `▶ В процессе (45%)`, `○ Не начато`. -- [ ] **6.4. Устойчивая фиксация позиции чтения (Paragraph Anchor + Scroll)**: +- [x] **6.4. Устойчивая фиксация позиции чтения (Paragraph Anchor + Scroll)**: - Сохранение не только пикселей `scrollTop`, но и индекса первого видимого абзаца (`anchor_paragraph_idx`). - Устойчивость к изменению шрифта `Aa` и ориентации экрана: восстановление скролла к конкретному предложению/абзацу, а не слепым пикселям. - Фиксация скролл-контейнера `#reader-scroll-area` с аппаратным ускорением на мобильных. diff --git a/app/reader.py b/app/reader.py index ff6930e..7f26db8 100644 --- a/app/reader.py +++ b/app/reader.py @@ -319,38 +319,180 @@ def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple if len(lines) > 1: content = "\n".join(lines[1:]).strip() - heading_pattern = re.compile( - r"^[ \t]*(?:" - r"#{1,3}[ \t]+(.+)|" - r"((?:Chapter|CHAPTER|Глава|ГЛАВА|Part|PART|Section|SECTION|Act|ACT|Scene|SCENE)[ \t]+[0-9IVXLCDMivxlcdm\w\.\-]+(?:[ \t]*[:\-–—\.][ \t]*.*|[ \t]+[^\n\r]+)?)" - r")[ \t]*$", - re.MULTILINE, +def detect_text_headings(content: str) -> List[Dict]: + """ + Detects section and chapter headings with high accuracy across Markdown, + numbered sections, explicit keywords, bold titles, and Setext styles. + """ + lines = content.split("\n") + line_starts = [] + curr = 0 + for l in lines: + line_starts.append(curr) + curr += len(l) + 1 + + kw_regex = re.compile( + r"^[ \t]*(?:\*\*)?(?:Chapter|CHAPTER|Глава|ГЛАВА|Part|PART|Часть|ЧАСТЬ|Section|SECTION|Раздел|РАЗДЕЛ|Module|MODULE|Модуль|МОДУЛЬ|Unit|UNIT|Lesson|LESSON|Урок|УРОК|Topic|TOPIC|Тема|ТЕМА|Appendix|APPENDIX|Приложение|ПРИЛОЖЕНИЕ|Act|ACT|Scene|SCENE)[ \t]+(?:[0-9IVXLCDMivxlcdm\w\.\-]+)(?:[ \t]*[:\-–—\.][ \t]*[^\n\r*]+|[ \t]+[^\n\r*]+)?(?:\*\*)?[ \t]*$" ) - matches = list(heading_pattern.finditer(content)) + headings = [] + + for i, line in enumerate(lines): + trimmed = line.strip() + if not trimmed: + continue + + prev_empty = (i == 0) or (not lines[i - 1].strip()) + next_empty = (i == len(lines) - 1) or (not lines[i + 1].strip()) + + # 1. Markdown # heading (# to #####) + m_md = re.match(r"^#{1,5}[ \t]+(.+)$", trimmed) + if m_md: + clean_title = m_md.group(1).strip("#").strip() + headings.append({ + "line_idx": i, + "start": line_starts[i], + "title": clean_title, + "type": "markdown", + }) + continue + + # 2. Setext heading: Title followed by === or --- + if i + 1 < len(lines): + next_line = lines[i + 1].strip() + if prev_empty and len(trimmed) <= 80 and not trimmed.startswith(("-", "*", "#", "`")): + if re.match(r"^={3,}$", next_line) or re.match(r"^-{3,}$", next_line): + headings.append({ + "line_idx": i, + "start": line_starts[i], + "title": trimmed, + "type": "setext", + }) + continue + + # 3. Explicit keywords (Chapter 1, Глава 2, Module 3, etc.) + if kw_regex.match(trimmed): + clean_title = trimmed.strip("*").strip() + headings.append({ + "line_idx": i, + "start": line_starts[i], + "title": clean_title, + "type": "keyword", + }) + continue + + # 4. Bold heading on standalone line: **Title** + bold_m = re.match(r"^\*\*([A-ZА-ЯЁ0-9][^\n\r*]{2,70})\*\*$", trimmed) + if bold_m and prev_empty: + headings.append({ + "line_idx": i, + "start": line_starts[i], + "title": bold_m.group(1).strip(), + "type": "bold", + }) + continue + + # 5. Numbered sections: e.g. 1. Title, 1.1 Title, 1. Title. + num_m = re.match(r"^([0-9]{1,2}(?:\.[0-9]{1,2})*\.?|[0-9]{1,2}\))[ \t]+([A-ZА-ЯЁ][^\n\r]{2,70})$", trimmed) + if num_m and prev_empty: + is_list = False + if i + 1 < len(lines): + next_t = lines[i + 1].strip() + if re.match(r"^[0-9]{1,2}(?:\.[0-9]{1,2})*\.?[ \t]+", next_t): + is_list = True + if not is_list and not trimmed.endswith((",", ";")): + headings.append({ + "line_idx": i, + "start": line_starts[i], + "title": trimmed, + "type": "numbered", + }) + continue + + # 6. Standalone ALL CAPS line (e.g. INTRODUCTION TO NETWORKS) + next_is_allcaps = (i + 1 < len(lines)) and bool(lines[i + 1].strip()) and lines[i + 1].strip().isupper() + if prev_empty and not next_is_allcaps and 4 <= len(trimmed) <= 60 and not trimmed.endswith((",", ";")): + if re.match(r"^[A-ZА-ЯЁ0-9\s:\-–—\.]+$", trimmed): + letters = re.findall(r"[A-ZА-ЯЁ]", trimmed) + words = trimmed.split() + if len(letters) >= 4 and (len(words) >= 2 or len(trimmed) >= 6): + headings.append({ + "line_idx": i, + "start": line_starts[i], + "title": trimmed, + "type": "allcaps", + }) + continue + + return headings + + +def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple[str, str, List[Dict[str, str]]]: + """ + Parses plain text or Markdown into readable chapters. + Splits by intelligent heading detection (Markdown, numbered sections, keywords) + or chunks of ~1000 words. + """ + content = content.replace("\r\n", "\n").replace("\r", "\n").strip() + if not content: + return title, author, [{"title": title if title != "Untitled" else "Chapter 1", "content": ""}] + + lines = content.split("\n") + first_line = lines[0].strip() + + # Extract title from first line or # Heading / if default or generic README + if title == "Untitled" or title.lower().startswith("readme"): + if first_line.startswith("# "): + title = first_line.lstrip("# ").strip() + if len(lines) > 1: + content = "\n".join(lines[1:]).strip() + elif first_line.lower().startswith("title:"): + title = first_line.split(":", 1)[1].strip() + if len(lines) > 1: + content = "\n".join(lines[1:]).strip() + else: + h1_match = re.search(r"]*>(.*?)", content[:1500], re.IGNORECASE) + if h1_match: + detected = re.sub(r"<[^>]+>", "", h1_match.group(1)).strip() + if detected: + title = detected + + # Extract author from next line if missing + if not author: + lines = content.split("\n") + if lines and lines[0].lower().startswith(("author:", "by:")): + author = lines[0].split(":", 1)[1].strip() + if len(lines) > 1: + content = "\n".join(lines[1:]).strip() + + detected_headings = detect_text_headings(content) chapters: List[Dict[str, str]] = [] - if len(matches) >= 1: + if detected_headings: # Pre-chapter intro text - intro = content[: matches[0].start()].strip() + intro = content[: detected_headings[0]["start"]].strip() if intro and count_words(intro) > 10: - chapters.append({"title": "Introduction", "content": intro}) + is_ru = any(ord(c) > 127 for c in intro[:200]) + chapters.append({"title": "Введение" if is_ru else "Introduction", "content": intro}) - for i, m in enumerate(matches): - h_text = (m.group(1) or m.group(2)).strip() - start = m.start() - end = matches[i + 1].start() if i + 1 < len(matches) else len(content) + for i, h in enumerate(detected_headings): + start = h["start"] + end = detected_headings[i + 1]["start"] if i + 1 < len(detected_headings) else len(content) body = content[start:end].strip() if body: - chapters.append({"title": h_text, "content": body}) + chapters.append({"title": h["title"], "content": body}) if not chapters: - # No headings detected: split by ~3000 words chunks - chunks = split_long_text(content, target_words=3000) - if len(chunks) == 1: - chapters = [{"title": title if title != "Untitled" else "Chapter 1", "content": chunks[0]}] - else: + # No headings detected: split by ~1000 words chunks if long enough + total_w = count_words(content) + if total_w > 1200: + chunks = split_long_text(content, target_words=1000) + is_ru = any(ord(c) > 127 for c in content[:200]) + prefix = "Часть" if is_ru else "Chapter" for idx, chunk in enumerate(chunks, start=1): - chapters.append({"title": f"Chapter {idx}", "content": chunk}) + chapters.append({"title": f"{prefix} {idx}", "content": chunk}) + else: + chapters = [{"title": title if title != "Untitled" else "Chapter 1", "content": content}] return title, author, chapters + diff --git a/app/static/index.html b/app/static/index.html index f728a95..dbfe853 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -5,7 +5,7 @@ Notes — Персональный тренажер и словарь английского языка - + @@ -1056,7 +1056,7 @@ - + diff --git a/app/static/js/app.js b/app/static/js/app.js index d40a0a5..30a0243 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -385,6 +385,42 @@ document.addEventListener('DOMContentLoaded', () => { return; } + // ----------------------------------------------------------------------- + // Immediate Pre-flight Routing (Zero-flicker view restoration on F5) + // ----------------------------------------------------------------------- + function applyInitialViewPreflight() { + let hash = window.location.hash || ''; + if (!hash || hash === '#') { + try { + const lastView = localStorage.getItem('notes_active_view'); + const lastBookId = localStorage.getItem('notes_active_book_id'); + if (lastView === 'reader-view') { + hash = lastBookId ? `#reader/book/${lastBookId}` : '#reader'; + } else if (lastView === 'srs-view') { + hash = '#srs'; + } + } catch (e) {} + } + + if (hash.startsWith('#reader') || hash === '#bookshelf') { + if (dictionaryView) dictionaryView.classList.add('hidden'); + if (srsView) srsView.classList.add('hidden'); + if (readerView) readerView.classList.remove('hidden'); + updateMobileActiveTab('reader'); + if (hash.includes('/book/')) { + if (bookshelfContainer) bookshelfContainer.classList.add('hidden'); + if (readerInterface) readerInterface.classList.remove('hidden'); + if (readerBookTitle) readerBookTitle.textContent = 'Загрузка книги...'; + } + } else if (hash.startsWith('#srs')) { + if (dictionaryView) dictionaryView.classList.add('hidden'); + if (readerView) readerView.classList.add('hidden'); + if (srsView) srsView.classList.remove('hidden'); + updateMobileActiveTab('srs'); + } + } + applyInitialViewPreflight(); + // ----------------------------------------------------------------------- // Session & Auth Check // ----------------------------------------------------------------------- @@ -395,23 +431,8 @@ document.addEventListener('DOMContentLoaded', () => { const data = await resp.json(); currentUser = data.user; showApp(); - await loadInitialData(); - - // Restore last active view and reading session if page was refreshed - try { - const lastView = localStorage.getItem('notes_active_view'); - const lastBookId = localStorage.getItem('notes_active_book_id'); - if (lastView === 'reader-view') { - openReaderView(); - if (lastBookId) { - openBook(parseInt(lastBookId, 10)); - } - } else if (lastView === 'srs-view') { - openSrsView(); - } - } catch (e) { - console.error('State restore error:', e); - } + await handleRouting(); + loadInitialData(); } else { showLogin(); } @@ -454,6 +475,7 @@ document.addEventListener('DOMContentLoaded', () => { } currentUser = data.user; showApp(); + await handleRouting(); loadInitialData(); } else { loginError.textContent = data.detail || 'Неверный логин или пароль'; @@ -467,8 +489,10 @@ document.addEventListener('DOMContentLoaded', () => { logoutBtn.addEventListener('click', async () => { localStorage.removeItem('notes_token'); - await fetch('/api/logout', { method: 'POST' }); + localStorage.removeItem('notes_active_book_id'); + try { await fetch('/api/logout', { method: 'POST' }); } catch (e) {} currentUser = null; + window.location.hash = ''; window.location.reload(); }); @@ -984,18 +1008,14 @@ document.addEventListener('DOMContentLoaded', () => { }); // ----------------------------------------------------------------------- - // Interactive SRS Trainer (5 Modes + Smart Mix) + // Interactive SRS Trainer & View Switching (Hash Router Driven) // ----------------------------------------------------------------------- srsTrainerBtn.addEventListener('click', () => { - openSrsView(); + window.location.hash = '#srs'; }); srsExitBtn.addEventListener('click', () => { - closeSrsView(); - }); - - navBrandHome.addEventListener('click', () => { - closeSrsView(); + window.location.hash = '#dictionary'; }); srsMixBtn.addEventListener('click', () => { @@ -1028,7 +1048,11 @@ document.addEventListener('DOMContentLoaded', () => { if (target) target.classList.add('active'); } - function openDictionaryView() { + function openDictionaryView(updateHash = true) { + if (updateHash && window.location.hash !== '#dictionary') { + window.location.hash = '#dictionary'; + return; + } srsView.classList.add('hidden'); if (readerView) readerView.classList.add('hidden'); dictionaryView.classList.remove('hidden'); @@ -1037,7 +1061,12 @@ document.addEventListener('DOMContentLoaded', () => { loadInitialData(); } - function openSrsView(tagFilter = null) { + function openSrsView(tagFilter = null, updateHash = true) { + const targetHash = tagFilter ? `#srs/${encodeURIComponent(tagFilter)}` : '#srs'; + if (updateHash && window.location.hash !== targetHash) { + window.location.hash = targetHash; + return; + } dictionaryView.classList.add('hidden'); if (readerView) readerView.classList.add('hidden'); srsView.classList.remove('hidden'); @@ -1047,23 +1076,89 @@ document.addEventListener('DOMContentLoaded', () => { } function closeSrsView() { - srsView.classList.add('hidden'); - if (readerView) readerView.classList.add('hidden'); - dictionaryView.classList.remove('hidden'); - updateMobileActiveTab('dict'); - try { localStorage.setItem('notes_active_view', 'dictionary-view'); } catch (e) {} - loadInitialData(); + openDictionaryView(true); } - function openReaderView() { + function openBookshelfView(updateHash = true) { + if (updateHash && window.location.hash !== '#reader') { + window.location.hash = '#reader'; + return; + } dictionaryView.classList.add('hidden'); srsView.classList.add('hidden'); if (readerView) readerView.classList.remove('hidden'); + if (bookshelfContainer) bookshelfContainer.classList.remove('hidden'); + if (readerInterface) readerInterface.classList.add('hidden'); updateMobileActiveTab('reader'); - try { localStorage.setItem('notes_active_view', 'reader-view'); } catch (e) {} + try { + localStorage.setItem('notes_active_view', 'reader-view'); + localStorage.removeItem('notes_active_book_id'); + } catch (e) {} loadBookshelf(); } + function openReaderView(updateHash = true) { + const lastBookId = localStorage.getItem('notes_active_book_id'); + if (lastBookId) { + window.location.hash = `#reader/book/${lastBookId}`; + } else { + openBookshelfView(updateHash); + } + } + + let isRouting = false; + async function handleRouting() { + if (isRouting) return; + isRouting = true; + try { + let hash = window.location.hash || ''; + if (!hash || hash === '#') { + try { + const lastView = localStorage.getItem('notes_active_view'); + const lastBookId = localStorage.getItem('notes_active_book_id'); + if (lastView === 'reader-view') { + hash = lastBookId ? `#reader/book/${lastBookId}` : '#reader'; + window.location.hash = hash; + return; + } else if (lastView === 'srs-view') { + hash = '#srs'; + window.location.hash = hash; + return; + } else { + hash = '#dictionary'; + } + } catch (e) { + hash = '#dictionary'; + } + } + + const cleanHash = hash.replace(/^#\/?/, ''); + + if (!cleanHash || cleanHash === 'dictionary') { + openDictionaryView(false); + } else if (cleanHash.startsWith('srs')) { + const parts = cleanHash.split('/'); + const tag = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('/')) : null; + openSrsView(tag, false); + } else if (cleanHash.startsWith('reader') || cleanHash === 'bookshelf') { + const parts = cleanHash.split('/'); + if (parts[1] === 'book' && parts[2]) { + const bookId = parseInt(parts[2], 10); + const chIdx = (parts[3] === 'ch' && parts[4] !== undefined) ? parseInt(parts[4], 10) : null; + await openBook(bookId, chIdx, false); + } else { + openBookshelfView(false); + } + } else { + openDictionaryView(false); + } + } finally { + isRouting = false; + } + } + + window.addEventListener('hashchange', handleRouting); + function initMobileNavigation() { const tabDict = document.getElementById('tab-nav-dict'); const tabSrs = document.getElementById('tab-nav-srs'); @@ -1081,9 +1176,9 @@ document.addEventListener('DOMContentLoaded', () => { const mobileThemeBtn = document.getElementById('mobile-theme-btn'); const mobileLogoutBtn = document.getElementById('mobile-logout-btn'); - if (tabDict) tabDict.addEventListener('click', () => openDictionaryView()); - if (tabSrs) tabSrs.addEventListener('click', () => openSrsView()); - if (tabReader) tabReader.addEventListener('click', () => openReaderView()); + if (tabDict) tabDict.addEventListener('click', () => { window.location.hash = '#dictionary'; }); + if (tabSrs) tabSrs.addEventListener('click', () => { window.location.hash = '#srs'; }); + if (tabReader) tabReader.addEventListener('click', () => { window.location.hash = '#reader'; }); if (tabStats) { tabStats.addEventListener('click', () => { @@ -1158,8 +1253,8 @@ document.addEventListener('DOMContentLoaded', () => { } } - navBrandHome.addEventListener('click', () => openDictionaryView()); - if (navReaderBtn) navReaderBtn.addEventListener('click', () => openReaderView()); + navBrandHome.addEventListener('click', () => { window.location.hash = '#dictionary'; }); + if (navReaderBtn) navReaderBtn.addEventListener('click', () => { window.location.hash = '#reader'; }); initMobileNavigation(); async function startSrsTrainingSession(forceAll = false, tagFilter = null) { @@ -1931,8 +2026,10 @@ document.addEventListener('DOMContentLoaded', () => { async function loadBookshelf() { if (!bookshelfContainer) return; - bookshelfContainer.classList.remove('hidden'); - if (readerInterface) readerInterface.classList.add('hidden'); + if (!window.location.hash.includes('/book/')) { + bookshelfContainer.classList.remove('hidden'); + if (readerInterface) readerInterface.classList.add('hidden'); + } if (bookshelfGrid) bookshelfGrid.innerHTML = ''; if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden'); @@ -1958,9 +2055,17 @@ document.addEventListener('DOMContentLoaded', () => { const card = document.createElement('div'); card.className = 'book-card'; - const totalCh = b.chapters_count || 1; - const currentCh = (b.current_chapter || 0) + 1; - const pct = Math.min(100, Math.round((currentCh / totalCh) * 100)); + 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 + ? `1 глава • ${totalPct}%${totalPct}%` + : `Глава ${chIndex + 1} из ${totalCh} (${chPct}%)${totalPct}%`; card.innerHTML = `
@@ -1977,11 +2082,10 @@ document.addEventListener('DOMContentLoaded', () => {
- Глава ${currentCh} из ${totalCh} - ${pct}% + ${progressLabels}
-
+
@@ -1992,10 +2096,12 @@ document.addEventListener('DOMContentLoaded', () => { `; - card.querySelector('.read-book-btn').addEventListener('click', () => openBook(b.id)); + 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); - openSrsView(bookTag); + window.location.hash = `#srs/${encodeURIComponent(bookTag)}`; }); card.querySelector('.delete-book-btn').addEventListener('click', async () => { if (confirm(`Удалить книгу "${b.title}" из библиотеки?`)) { @@ -2008,16 +2114,46 @@ document.addEventListener('DOMContentLoaded', () => { }); } - async function openBook(bookId) { + async function openBook(bookId, specificChapterIdx = null, updateHash = true) { + if (!bookId) return; + + if (readerView) readerView.classList.remove('hidden'); + if (dictionaryView) dictionaryView.classList.add('hidden'); + if (srsView) srsView.classList.add('hidden'); + if (bookshelfContainer) bookshelfContainer.classList.add('hidden'); + if (readerInterface) readerInterface.classList.remove('hidden'); + updateMobileActiveTab('reader'); + + if (currentBook && currentBook.id === bookId) { + if (specificChapterIdx !== null && specificChapterIdx !== undefined && !isNaN(specificChapterIdx)) { + if (specificChapterIdx !== currentChapterIndex) { + renderChapter(specificChapterIdx, 0, 0, 0); + } + } + if (updateHash) { + const desiredHash = `#reader/book/${bookId}/ch/${currentChapterIndex}`; + if (window.location.hash !== desiredHash) { + history.replaceState(null, '', desiredHash); + } + } + return; + } + try { + if (readerBookTitle) readerBookTitle.textContent = 'Загрузка книги...'; const resp = await fetch(`/api/books/${bookId}`); - if (!resp.ok) return alert('Не удалось открыть книгу'); + if (!resp.ok) { + alert('Не удалось открыть книгу'); + window.location.hash = '#reader'; + return; + } currentBook = await resp.json(); // Load saved reading position from localStorage or server let targetChapter = currentBook.current_chapter || 0; let targetScrollTop = null; let targetFraction = currentBook.current_position || 0; + let targetAnchorIdx = null; try { const raw = localStorage.getItem(`notes_book_${bookId}_pos`); @@ -2027,27 +2163,36 @@ document.addEventListener('DOMContentLoaded', () => { targetChapter = parsed.chapter; targetScrollTop = parsed.scrollTop; targetFraction = parsed.fraction; + targetAnchorIdx = parsed.anchorIdx; } } } catch (e) {} - currentChapterIndex = targetChapter; + if (specificChapterIdx !== null && specificChapterIdx !== undefined && !isNaN(specificChapterIdx)) { + targetChapter = specificChapterIdx; + if (specificChapterIdx !== (currentBook.current_chapter || 0)) { + targetScrollTop = 0; + targetFraction = 0; + targetAnchorIdx = 0; + } + } + + currentChapterIndex = Math.max(0, Math.min(targetChapter, (currentBook.chapters ? currentBook.chapters.length - 1 : 0))); readerBookTitle.textContent = currentBook.title; readerBookAuthor.textContent = currentBook.author || ''; - // Populate chapter selector + // Populate chapter selector with visual status markers readerChapterSelect.innerHTML = ''; const chapters = currentBook.chapters || []; chapters.forEach((ch, idx) => { const opt = document.createElement('option'); opt.value = idx; - opt.textContent = ch.title || `Глава ${idx + 1}`; + const statusIcon = idx < currentChapterIndex ? '✔ ' : (idx === currentChapterIndex ? '▶ ' : '○ '); + opt.textContent = `${statusIcon}${ch.title || `Глава ${idx + 1}`}`; readerChapterSelect.appendChild(opt); }); - bookshelfContainer.classList.add('hidden'); - readerInterface.classList.remove('hidden'); applyReaderSettings(); try { @@ -2055,17 +2200,33 @@ document.addEventListener('DOMContentLoaded', () => { localStorage.setItem('notes_active_view', 'reader-view'); } catch (e) {} - renderChapter(currentChapterIndex, targetScrollTop, targetFraction); + if (updateHash) { + const desiredHash = `#reader/book/${bookId}/ch/${currentChapterIndex}`; + if (window.location.hash !== desiredHash) { + history.replaceState(null, '', desiredHash); + } + } + + renderChapter(currentChapterIndex, targetScrollTop, targetFraction, targetAnchorIdx); } catch (e) { console.error('Error opening book:', e); } } - function renderChapter(index, restoreScrollTop = null, restoreFraction = null) { + function renderChapter(index, restoreScrollTop = null, restoreFraction = null, restoreAnchorIdx = null) { if (!currentBook || !currentBook.chapters) return; currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1)); readerChapterSelect.value = currentChapterIndex; + // Refresh status icons in chapter select + if (readerChapterSelect.options) { + for (let i = 0; i < readerChapterSelect.options.length; i++) { + const rawTitle = currentBook.chapters[i]?.title || `Глава ${i + 1}`; + const icon = i < currentChapterIndex ? '✔ ' : (i === currentChapterIndex ? '▶ ' : '○ '); + readerChapterSelect.options[i].textContent = `${icon}${rawTitle}`; + } + } + readerPrevChBtn.disabled = currentChapterIndex <= 0; readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1; @@ -2075,6 +2236,7 @@ document.addEventListener('DOMContentLoaded', () => { if (chapter.title) { const h = document.createElement('h2'); h.textContent = chapter.title; + h.setAttribute('data-block-idx', '0'); readerContentBody.appendChild(h); } @@ -2087,10 +2249,13 @@ document.addEventListener('DOMContentLoaded', () => { // Parse content into structured blocks (code, headings, lists, quotes, paragraphs) const blocks = parseContentBlocks(chapter.content); - blocks.forEach(block => { + blocks.forEach((block, blockIdx) => { + const domIdx = blockIdx + 1; + let blockEl = null; + if (block.type === 'code') { - const codeBlock = document.createElement('div'); - codeBlock.className = 'reader-code-block'; + blockEl = document.createElement('div'); + blockEl.className = 'reader-code-block'; const header = document.createElement('div'); header.className = 'reader-code-header'; @@ -2123,12 +2288,11 @@ document.addEventListener('DOMContentLoaded', () => { codeEl.textContent = block.code; pre.appendChild(codeEl); - codeBlock.appendChild(header); - codeBlock.appendChild(pre); - readerContentBody.appendChild(codeBlock); + blockEl.appendChild(header); + blockEl.appendChild(pre); } else if (block.type === 'table') { - const wrap = document.createElement('div'); - wrap.className = 'reader-table-wrap'; + blockEl = document.createElement('div'); + blockEl.className = 'reader-table-wrap'; const table = document.createElement('table'); table.className = 'reader-table'; @@ -2163,42 +2327,52 @@ document.addEventListener('DOMContentLoaded', () => { }); table.appendChild(tbody); } - wrap.appendChild(table); - readerContentBody.appendChild(wrap); + blockEl.appendChild(table); } else if (block.type === 'heading') { const tag = `h${Math.min(4, Math.max(1, block.level || 2))}`; - const h = document.createElement(tag); - if (block.align) h.style.textAlign = block.align; - renderInteractiveSpans(h, block.text, block.text, knownWordsMap); - readerContentBody.appendChild(h); + blockEl = document.createElement(tag); + if (block.align) blockEl.style.textAlign = block.align; + renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap); } else if (block.type === 'blockquote') { - const bq = document.createElement('blockquote'); - renderInteractiveSpans(bq, block.text, block.text, knownWordsMap); - readerContentBody.appendChild(bq); + blockEl = document.createElement('blockquote'); + renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap); } else if (block.type === 'ul' || block.type === 'ol') { - const listEl = document.createElement(block.type); + blockEl = document.createElement(block.type); block.items.forEach(itemText => { const li = document.createElement('li'); renderInteractiveSpans(li, itemText, itemText, knownWordsMap); - listEl.appendChild(li); + blockEl.appendChild(li); }); - readerContentBody.appendChild(listEl); } else if (block.type === 'hr') { - readerContentBody.appendChild(document.createElement('hr')); + blockEl = document.createElement('hr'); } else { // paragraph - const p = document.createElement('p'); + blockEl = document.createElement('p'); if (block.align) { - p.style.textAlign = block.align; - p.classList.add('text-center'); + blockEl.style.textAlign = block.align; + blockEl.classList.add('text-center'); } - renderInteractiveSpans(p, block.text, block.text, knownWordsMap); - readerContentBody.appendChild(p); + renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap); + } + + if (blockEl) { + blockEl.setAttribute('data-block-idx', domIdx.toString()); + readerContentBody.appendChild(blockEl); } }); function applyScroll() { if (!readerScrollArea) return; + if (restoreAnchorIdx !== null && restoreAnchorIdx !== undefined) { + const el = readerContentBody.querySelector(`[data-block-idx="${restoreAnchorIdx}"]`); + if (el) { + const containerTop = readerScrollArea.getBoundingClientRect().top; + const elTop = el.getBoundingClientRect().top; + readerScrollArea.scrollTop += (elTop - containerTop - 10); + updateReaderProgressBar(); + return; + } + } if (restoreScrollTop !== null && restoreScrollTop > 0) { readerScrollArea.scrollTop = restoreScrollTop; } else if (restoreFraction !== null && restoreFraction > 0) { @@ -2880,37 +3054,39 @@ document.addEventListener('DOMContentLoaded', () => { }); } - // Chapter Navigation & Reader Toolbar + // Chapter Navigation & Reader Toolbar (URL Hash Driven) if (readerChapterSelect) { readerChapterSelect.addEventListener('change', () => { - renderChapter(parseInt(readerChapterSelect.value, 10), 0, 0); + const ch = parseInt(readerChapterSelect.value, 10); + if (currentBook) { + window.location.hash = `#reader/book/${currentBook.id}/ch/${ch}`; + } }); } if (readerPrevChBtn) { readerPrevChBtn.addEventListener('click', () => { - if (currentChapterIndex > 0) renderChapter(currentChapterIndex - 1, 0, 0); + if (currentBook && currentChapterIndex > 0) { + window.location.hash = `#reader/book/${currentBook.id}/ch/${currentChapterIndex - 1}`; + } }); } if (readerNextChBtn) { readerNextChBtn.addEventListener('click', () => { - if (currentBook && currentChapterIndex < currentBook.chapters.length - 1) { - renderChapter(currentChapterIndex + 1, 0, 0); + if (currentBook && currentBook.chapters && currentChapterIndex < currentBook.chapters.length - 1) { + window.location.hash = `#reader/book/${currentBook.id}/ch/${currentChapterIndex + 1}`; } }); } if (readerBackBtn) { readerBackBtn.addEventListener('click', () => { - readerInterface.classList.add('hidden'); - bookshelfContainer.classList.remove('hidden'); - try { localStorage.removeItem('notes_active_book_id'); } catch (e) {} - loadBookshelf(); + window.location.hash = '#reader'; }); } - // Scroll Position Tracking & Real-Time Chapter Progress + // Scroll Position Tracking, Paragraph Anchoring & Real-Time Weighted Progress let readerScrollSaveTimer = null; function getReaderScrollMetrics() { @@ -2922,12 +3098,36 @@ document.addEventListener('DOMContentLoaded', () => { return { scrollTop, maxScroll, pct, fraction }; } + function getActiveAnchorIndex() { + if (!readerScrollArea || !readerContentBody) return null; + const blocks = readerContentBody.querySelectorAll('[data-block-idx]'); + if (!blocks || blocks.length === 0) return null; + const containerTop = readerScrollArea.getBoundingClientRect().top; + for (let el of blocks) { + const rect = el.getBoundingClientRect(); + if (rect.bottom >= containerTop + 10) { + return parseInt(el.getAttribute('data-block-idx'), 10); + } + } + return 0; + } + function updateReaderProgressBar() { - const { pct } = getReaderScrollMetrics(); + const { pct, fraction } = getReaderScrollMetrics(); if (readerChapterProgressFill) { readerChapterProgressFill.style.width = `${pct}%`; } - if (readerScrollPctBadge) { + + if (currentBook) { + const totalCh = Math.max(1, (currentBook.chapters ? currentBook.chapters.length : 1)); + const overallFraction = (currentChapterIndex + fraction) / totalCh; + const totalPct = Math.min(100, Math.max(0, Math.round(overallFraction * 100))); + + if (readerScrollPctBadge) { + readerScrollPctBadge.textContent = `${pct}%`; + readerScrollPctBadge.title = `Глава ${currentChapterIndex + 1}/${totalCh}: ${pct}% • Вся книга: ${totalPct}%`; + } + } else if (readerScrollPctBadge) { readerScrollPctBadge.textContent = `${pct}%`; } } @@ -2937,6 +3137,7 @@ document.addEventListener('DOMContentLoaded', () => { updateReaderProgressBar(); const { scrollTop, fraction } = getReaderScrollMetrics(); + const anchorIdx = getActiveAnchorIndex(); // 1. Instant local persistence try { @@ -2945,6 +3146,7 @@ document.addEventListener('DOMContentLoaded', () => { chapter: currentChapterIndex, scrollTop: scrollTop, fraction: fraction, + anchorIdx: anchorIdx, savedAt: Date.now() })); localStorage.setItem('notes_active_book_id', currentBook.id); @@ -2970,11 +3172,13 @@ document.addEventListener('DOMContentLoaded', () => { window.addEventListener('beforeunload', () => { if (currentBook && readerScrollArea) { const { scrollTop, fraction } = getReaderScrollMetrics(); + const anchorIdx = getActiveAnchorIndex(); try { localStorage.setItem(`notes_book_${currentBook.id}_pos`, JSON.stringify({ chapter: currentChapterIndex, scrollTop: scrollTop, fraction: fraction, + anchorIdx: anchorIdx, savedAt: Date.now() })); localStorage.setItem('notes_active_book_id', currentBook.id); @@ -3003,6 +3207,7 @@ document.addEventListener('DOMContentLoaded', () => { function applyReaderSettings() { if (!readerScrollArea) return; + const anchorIdx = getActiveAnchorIndex(); // 1. Font size readerScrollArea.style.setProperty('--reader-font-size', `${readerSettings.fontSize}px`); @@ -3028,6 +3233,18 @@ document.addEventListener('DOMContentLoaded', () => { readerScrollArea.classList.add(`reader-align-${readerSettings.textAlign}`); updateReaderSettingsUI(); + + // Re-anchor scroll position after font/layout change + if (anchorIdx !== null && anchorIdx !== undefined) { + requestAnimationFrame(() => { + const el = readerContentBody.querySelector(`[data-block-idx="${anchorIdx}"]`); + if (el) { + const containerTop = readerScrollArea.getBoundingClientRect().top; + const elTop = el.getBoundingClientRect().top; + readerScrollArea.scrollTop += (elTop - containerTop - 10); + } + }); + } } function updateReaderSettingsUI() { @@ -3220,7 +3437,7 @@ document.addEventListener('DOMContentLoaded', () => { readerTrainBtn.addEventListener('click', () => { if (currentBook) { const bookTag = slugify(currentBook.title); - openSrsView(bookTag); + window.location.hash = `#srs/${encodeURIComponent(bookTag)}`; } }); } @@ -3260,8 +3477,7 @@ document.addEventListener('DOMContentLoaded', () => { if (resp.ok) { const book = await resp.json(); addBookModal.classList.add('hidden'); - await loadBookshelf(); - openBook(book.id); + window.location.hash = `#reader/book/${book.id}`; } else { alert('Ошибка при сохранении книги'); } @@ -3288,8 +3504,7 @@ document.addEventListener('DOMContentLoaded', () => { if (resp.ok) { const book = await resp.json(); bookFileInput.value = ''; - await loadBookshelf(); - openBook(book.id); + window.location.hash = `#reader/book/${book.id}`; } else { const errData = await resp.json(); alert(`Ошибка загрузки: ${errData.detail || 'Не удалось обработать файл'}`); diff --git a/tests/test_api.py b/tests/test_api.py index 505c8ec..232106a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -594,6 +594,29 @@ Nearly ten years had passed. assert "Chapter 1" in chs[0]["title"] assert "Chapter 2" in chs[1]["title"] + # 3b. Smart chapter detection: word-spelled (CHAPTER ONE), numbered sections, ALL CAPS, bold + smart_txt = """Some intro notes before starting. + +CHAPTER ONE: THE BEGINNING +It was a cold day in April. + +1.1 Network Fundamentals +Devices communicate over IP. + +**Key Architecture** +The architecture is modular. + +CONCLUSION AND NEXT STEPS +Final thoughts on the topic. +""" + st_title, st_auth, st_chs = parse_text(smart_txt, title="Smart Book") + assert len(st_chs) >= 4 + titles = [c["title"] for c in st_chs] + assert any("CHAPTER ONE" in t for t in titles) + assert any("1.1 Network Fundamentals" in t for t in titles) + assert any("Key Architecture" in t for t in titles) + assert any("CONCLUSION AND NEXT STEPS" in t for t in titles) + # 4. parse_text chunking on long text long_words = ["word"] * 7000 long_text = "\n\n".join([" ".join(long_words[i:i + 400]) for i in range(0, len(long_words), 400)])