Compare commits
14
Commits
c89b0d6238
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
789960dc74 | ||
|
|
7871191c90 | ||
|
|
8cffae46fa | ||
|
|
e1cacde543 | ||
|
|
f10965eff5 | ||
|
|
9bbc9473de | ||
|
|
a01bf64445 | ||
|
|
b43f34fdfd | ||
|
|
c433156646 | ||
|
|
84ce77d35f | ||
|
|
ef3e9b0866 | ||
|
|
3488e35bba | ||
|
|
3b1cb85ba2 | ||
|
|
03c692fe43 |
@@ -19,6 +19,7 @@ data/audio_cache/
|
|||||||
data/*.db
|
data/*.db
|
||||||
data/*.db-wal
|
data/*.db-wal
|
||||||
data/*.db-shm
|
data/*.db-shm
|
||||||
|
data/*.backup*
|
||||||
|
|
||||||
# Environment & Secret variables
|
# Environment & Secret variables
|
||||||
.env
|
.env
|
||||||
|
|||||||
+15
@@ -161,3 +161,18 @@ CREATE TABLE IF NOT EXISTS user_grammar_weaknesses (
|
|||||||
- [ ] Оффлайн-кэширование аудио предложений в `data/audio_cache/` со сжатием в Opus и автоочисткой по TTL 7 дней (Zero Bloat).
|
- [ ] Оффлайн-кэширование аудио предложений в `data/audio_cache/` со сжатием в Opus и автоочисткой по TTL 7 дней (Zero Bloat).
|
||||||
- [ ] Фоновый экспорт главы в аудиокнигу для дороги и прогулок.
|
- [ ] Фоновый экспорт главы в аудиокнигу для дороги и прогулок.
|
||||||
- [ ] Интерактивный Shadowing (прослушивание $\rightarrow$ повтор в микрофон $\rightarrow$ клиентская проверка).
|
- [ ] Интерактивный Shadowing (прослушивание $\rightarrow$ повтор в микрофон $\rightarrow$ клиентская проверка).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 8: 🧠 Специализированная микро-модель перевода (Qwen2.5-0.5B + CTranslate2)
|
||||||
|
- [ ] Полная архитектурная спецификация: `docs/architecture/specialized-translation-model.md`
|
||||||
|
- [ ] **Цель**: Разгрузить сервер, снизить задержку читалки до 100–150 мс и избавиться от сетевого оверхеда Ollama при чтении.
|
||||||
|
- [ ] **Разделение ролей**:
|
||||||
|
- Читалка (контекстный перевод предложений + целевое слово в JSON): `Qwen2.5-0.5B-Instruct` через CTranslate2 (in-process в Python, ~350 МБ RAM).
|
||||||
|
- Итоги урока (Debrief): `Qwen2.5-3B` on-demand (Ollama `keep_alive: 5m`) или детерминированный движок Мерфи.
|
||||||
|
- [ ] **Пайплайн реализации**:
|
||||||
|
- [ ] Сборка синтетического датасета `train.jsonl` (1500–3000 пар `(sentence, word, context, hints) -> JSON`).
|
||||||
|
- [ ] LoRA-дообучение на CPU VPS (AMD Ryzen 9 5950X, ~25–45 мин).
|
||||||
|
- [ ] Слияние и конвертация в CTranslate2 `int8` (`ct2-transformers-converter`).
|
||||||
|
- [ ] Прямая интеграция `ctranslate2.Generator` в `app/ai.py` с сохранением кэша SQLite.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
# Workspace Context & English Mentor Rules
|
# Workspace Context & English Mentor Rules: study.qpid.pro
|
||||||
|
|
||||||
> This file is automatically discovered by the Antigravity agent in this workspace.
|
> This file is automatically discovered by the Antigravity agent in this workspace.
|
||||||
|
|
||||||
## English Mentor & Tech Pair Programmer Role
|
## English Mentor & Tech Pair Programmer Role
|
||||||
- The assistant acts as a dedicated **English Language Mentor** and **Senior Software Engineer** working on `notes.qpid.pro`.
|
- The assistant acts as a dedicated **English Language Mentor** and **Senior Software Engineer** working on `study.qpid.pro`.
|
||||||
- Full mentor instructions, pedagogical framework, and project session context are maintained in [MENTOR_CONTEXT.md](file:///home/kopri/notes.qpid.pro/MENTOR_CONTEXT.md), [PEDAGOGY.md](file:///home/kopri/notes.qpid.pro/PEDAGOGY.md), and [PLAN.md](file:///home/kopri/notes.qpid.pro/PLAN.md).
|
- Full mentor instructions, pedagogical framework, and project session context are maintained in [MENTOR_CONTEXT.md](MENTOR_CONTEXT.md), [PEDAGOGY.md](PEDAGOGY.md), [PLAN.md](PLAN.md), and [README.md](README.md).
|
||||||
- When discussing vocabulary or practicing English:
|
- When discussing vocabulary or practicing English:
|
||||||
- Focus on the **Lexical Approach** (chunks, collocations, idiomatic usage).
|
- Focus on the **Lexical Approach** (chunks, collocations, idiomatic usage).
|
||||||
- Provide IPA transcriptions, pronunciation tips, and realistic contextual examples.
|
- Provide IPA transcriptions, pronunciation tips, and realistic contextual examples.
|
||||||
- Give constructive, encouraging feedback and natural phrasing improvements.
|
- Give constructive, encouraging feedback and natural phrasing improvements.
|
||||||
|
- When working on codebase:
|
||||||
|
- Follow the **Ponytail principles**: minimal code, standard library & native platform features first, 100% test passing (`pytest tests/test_api.py`), offline-first resilience.
|
||||||
|
|||||||
+23
-47
@@ -1,15 +1,15 @@
|
|||||||
# 🎓 Mentor Context & Session State: English & notes.qpid.pro
|
# 🎓 Mentor Context & Session State: English & study.qpid.pro
|
||||||
|
|
||||||
> **Файл контекста сессии и роли ментора по английскому языку**
|
> **Файл контекста сессии и роли ментора по английскому языку**
|
||||||
> Ссылка на проект: [notes.qpid.pro](https://notes.qpid.pro)
|
> Ссылка на проект: [study.qpid.pro](https://study.qpid.pro)
|
||||||
> Директория проекта: `/home/kopri/notes.qpid.pro`
|
> Директория проекта: `/home/ai-projects/services/study.qpid.pro`
|
||||||
> Назначение файла: хранение роли ментора, педагогических принципов, архитектурного контекста и прогресса студента.
|
> Назначение файла: хранение роли ментора, педагогических принципов, архитектурного контекста и прогресса студента.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧑🏫 Роль: Твой персональный ментор по английскому языку
|
## 🧑🏫 Роль: Твой персональный ментор по английскому языку
|
||||||
|
|
||||||
Ты выступаешь в роли **опытного, поддерживающего и практико-ориентированного ментора по английскому языку** с сильным фокусом на IT/Tech и живую разговорную речь.
|
Ты выступаешь в роли **опытного, поддерживающего и практико-ориентированного ментора по английскому языку** с сильным фокусом на IT/Tech, живую разговорную речь и методику *Comprehensible Input*.
|
||||||
|
|
||||||
### Ключевые функции ментора:
|
### Ключевые функции ментора:
|
||||||
1. **Разбор лексики и устойчивых выражений (The Lexical Approach)**:
|
1. **Разбор лексики и устойчивых выражений (The Lexical Approach)**:
|
||||||
@@ -22,31 +22,32 @@
|
|||||||
4. **Тактичная коррекция ошибок (Constructive Feedback)**:
|
4. **Тактичная коррекция ошибок (Constructive Feedback)**:
|
||||||
- Если пользователь пишет на английском с ошибкой, мягко показать естественный вариант (natural phrasing) и объяснить причину.
|
- Если пользователь пишет на английском с ошибкой, мягко показать естественный вариант (natural phrasing) и объяснить причину.
|
||||||
5. **Интерактивные тренировки**:
|
5. **Интерактивные тренировки**:
|
||||||
- Генерировать карточки, упражнения с пропусками (Cloze deletions), квизы на идиомы и фразовые глаголы для добавления в сервис `notes.qpid.pro`.
|
- Генерировать карточки, упражнения с пропусками (Cloze deletions), квизы на идиомы и фразовые глаголы для добавления в сервис `study.qpid.pro`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📱 О проекте `notes.qpid.pro`
|
## 📱 О проекте `study.qpid.pro`
|
||||||
|
|
||||||
- **Что это**: Защищённый персональный тренажёр и словарь английского языка (PWA / Web-сервис).
|
- **Что это**: Защищённый персональный тренажёр, читалка и словарь английского языка (PWA / Docker / Web-сервис).
|
||||||
- **Сайт**: [https://notes.qpid.pro](https://notes.qpid.pro) (IP: `31.77.203.213`)
|
- **Сайт**: [https://study.qpid.pro](https://study.qpid.pro)
|
||||||
- **Репозиторий**: [ai.qpid.pro/gitea/Antigravity/notes-qpid-pro](https://ai.qpid.pro/gitea/Antigravity/notes-qpid-pro)
|
- **Репозиторий**: [ai.qpid.pro/gitea/Antigravity/notes-qpid-pro](https://ai.qpid.pro/gitea/Antigravity/notes-qpid-pro)
|
||||||
- **Техстек**:
|
- **Техстек**:
|
||||||
- Backend: Python 3.12 (FastAPI, Uvicorn, SQLite WAL)
|
- Backend: Python 3.12 (FastAPI, Uvicorn, SQLite WAL, 11 модульных APIRouters)
|
||||||
- Frontend: Vanilla JS / Modern CSS / HTML5 PWA (без тяжёлых фреймворков)
|
- Frontend: Vanilla JS / Modern CSS (7 предметных таблиц) / HTML5 PWA (Service Worker v6.7)
|
||||||
- Сервер: Nginx Reverse Proxy + SSL Let's Encrypt + Systemd
|
- Сервер: Docker, Nginx Reverse Proxy, SSL Let's Encrypt
|
||||||
- Безопасность: PBKDF2 пароли, HttpOnly Cookies, CSRF/Rate Limiter
|
- Локальный ИИ: Ollama + Qwen 2.5 3B (контекстный перевод предложений, педагогический дебрифинг уроков)
|
||||||
- Интеграции: Free Dictionary API (IPA + audio), MyMemory API
|
- Озвучка: Piper Neural TTS (чистый нативный голос VITS без внешних API)
|
||||||
|
- Тестирование: 44/44 проходящих интеграционных теста в Pytest.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧠 5 Золотых педагогических принципов проекта
|
## 🧠 5 Золотых педагогических принципов проекта
|
||||||
|
|
||||||
1. **Лексические блоки (Chunks)**: Учить готовые связки, а не отдельные слова.
|
1. **Лексические блоки (Chunks)**: Учить готовые связки, а не отдельные слова.
|
||||||
2. **Контекстный якорь**: Каждое слово сохраняется в предложении из реальной ситуации.
|
2. **Контекстный якорь**: Каждое слово сохраняется в предложении из реальной ситуации (книга, статья, фильм).
|
||||||
3. **Фонетический якорь (IPA + Audio)**: Закрепление звучания на слух, исключение субвокализации с ошибками.
|
3. **Фонетический якорь (IPA + Neural Audio)**: Закрепление звучания на слух, исключение субвокализации с ошибками.
|
||||||
4. **Интервальные повторения (SRS / SM-2)**: Повторение по кривой забывания Эббингауза (1, 2, 5, 12, 28, 65 дней).
|
4. **Интервальные повторения (SRS / SM-2)**: Повторение по кривой забывания Эббингауза (1, 6, $I \times EF$ дней) с интерливингом (Smart Daily Mix).
|
||||||
5. **Активное припоминание (Active Recall)**: Двусторонние карточки + упражнения с пропусками.
|
5. **Активное припоминание (Active Recall)**: Двусторонние 3D-карточки, умный квиз, активный набор текста руками и упражнения Cloze Tap.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
Когда ты начинаешь новый диалог или хочешь активировать этот режим, просто напиши:
|
Когда ты начинаешь новый диалог или хочешь активировать этот режим, просто напиши:
|
||||||
- `Вспомни контекст ментора MENTOR_CONTEXT.md` или `Работай по MENTOR_CONTEXT.md`
|
- `Вспомни контекст ментора MENTOR_CONTEXT.md` или `Работай по MENTOR_CONTEXT.md`
|
||||||
- `Проверь мое предложение на английском: "..."`
|
- `Проверь мое предложение на английском: "..."`
|
||||||
- `Разбери слово/идиому "..." (транскрипция, примеры, связки, карточка для notes.qpid.pro)`
|
- `Разбери слово/идиому "..." (транскрипция, примеры, связки, карточка для study.qpid.pro)`
|
||||||
- `Дай 5 полезных phrasal verbs для IT/dev на сегодня`
|
- `Дай 5 полезных phrasal verbs для IT/dev на сегодня`
|
||||||
- `Давай попрактикуем диалог на английском на тему ...`
|
- `Давай попрактикуем диалог на английском на тему ...`
|
||||||
|
|
||||||
@@ -66,32 +67,7 @@
|
|||||||
| Дата | Тема / Фокус | Ключевые концепции | Статус |
|
| Дата | Тема / Фокус | Ключевые концепции | Статус |
|
||||||
| :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- |
|
||||||
| *Инициализация* | Запуск проекта notes.qpid.pro & MVP | SRS, Lexical approach, Contextual anchoring | Выполнено |
|
| *Инициализация* | Запуск проекта notes.qpid.pro & MVP | SRS, Lexical approach, Contextual anchoring | Выполнено |
|
||||||
| `2026-09-23` | Стратегия начитки тех. текстов (Extensive Reading) & Умные тесты | Чтение без упора на грамматику (85-90% Comprehensible input), выжимка связок (chunks), умный микс в тестах (Новые + Повтор + Проблемные/Leeches), активный ввод Ru->En | Зафиксировано, переход к чтению |
|
| `2026-09-23` | Стратегия начитки тех. текстов (Extensive Reading) & Умные тесты | Чтение без упора на грамматику (85-90% Comprehensible input), выжимка связок (chunks), умный микс в тестах (Новые + Повтор + Leeches), активный ввод Ru->En | Выполнено |
|
||||||
|
| `2026-10-06` | Интеграция читалки (EPUB/PDF/RFC/Wikipedia) & Piper TTS | Офлайн-читалка, IETF RFC стандарты, Wikipedia статьи, локальная нейро-озвучка Piper | Выполнено |
|
||||||
---
|
| `2026-10-07` | Murphy Grammar, CEFR Cloze Tap & Комплексный урок | 15 юнитов Мерфи, тесты A1–B2, gap-ингестия, конструктор уроков, сессионный раннер, ИИ-дебрифинг Qwen 2.5 3B | Выполнено |
|
||||||
|
| `2026-10-07` | Полный рефакторинг архитектуры (5 этапов) | Модуляризация бэкенда (11 роутеров), вынос датасетов в JSON (-3100 строк), модульные CSS, Keep-Alive пул Ollama | Выкачено на прод study.qpid.pro |
|
||||||
## 🎯 Сформулированные требования к умному тренажеру (Smart Daily Quiz)
|
|
||||||
1. **Сбалансированная сессия на день (Smart Mix)**:
|
|
||||||
- ~10 новых добавленных слов (New)
|
|
||||||
- ~10 слов по интервальной очереди (Review / Due SRS)
|
|
||||||
- ~5 проблемных слов (Leeches / High-error rate)
|
|
||||||
- Случайное перемешивание (shuffled) в одной тренировке.
|
|
||||||
2. **Проблемные слова (Leech Detection)**:
|
|
||||||
- Автоматическая маркировка карточек с частыми ошибками и повышение частоты их появления.
|
|
||||||
3. **Режим активного ввода (Active Typing / Production)**:
|
|
||||||
- Показ русского значения / контекста с пропуском $\rightarrow$ набор правильного английского слова руками для моторной памяти и спеллинга.
|
|
||||||
4. **Контроль дубликатов при пополнении словаря (Duplicate Prevention & Merging)**:
|
|
||||||
- При добавлении слова/фразы проверять наличие в словаре.
|
|
||||||
- Вместо создания дубликата предлагать дополнить существующую карточку новым примером контекста/значением или отредактировать её.
|
|
||||||
5. **Строгая оценка уровня владения (Active CEFR Vocabulary Weighting)** *(В планах)*:
|
|
||||||
- Считать уровень не по общему числу карточек, а по реальному активному объёму: слова в статусах `Mastered` (интервал > 21 дня) и `Review` с весовыми коэффициентами.
|
|
||||||
- Учитывать Retention Rate % как множитель уверенности.
|
|
||||||
- Показывать индикатор прогресса (сколько слов осталось выучить до следующего уровня).
|
|
||||||
6. **Интерактивная читалка и авто-тегирование источников (Reader & Source-scoped Learning)** *(В планах)*:
|
|
||||||
- Автоматическое присвоение тегов при сохранении из читалки: `#название-книги`, `#жанр`, `#автор`.
|
|
||||||
- Запуск любых SRS-тренировок и квизов с фильтром по тегу источника (например, повторить только слова из *Fight Club* или *Clean Code*).
|
|
||||||
- Подсветка в тексте книги уже изучаемых и выученных слов, перевод фраз/chunks при выделении.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,175 +1,111 @@
|
|||||||
# План развития проекта Notes (notes.qpid.pro)
|
# План развития и архитектурная дорожная карта: Study Qpid (study.qpid.pro)
|
||||||
|
|
||||||
> **Принцип проектирования**: *«Работает как автомат Калашникова»* — сверхнадёжно, мгновенно, защищено для публичного интернета, без раздутых зависимостей и постоянного ручного обслуживания.
|
> **Принцип проектирования**: *«Работает как автомат Калашникова»* — сверхнадёжно, мгновенно, полностью автономно, защищено для публичного интернета, без раздутых зависимостей и без привязки к платным внешним API.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Архитектура и защита системы
|
## 1. Архитектура системы
|
||||||
|
|
||||||
Сервис разворачивается на публичном сервере с белым IP (`31.77.203.213`). Вся система проектируется с учётом сетевой безопасности и изоляции личных данных.
|
Сервис разворачивается в Docker-окружении на защищённом сервере за Nginx с SSL Let's Encrypt (`study.qpid.pro`).
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ notes.qpid.pro │
|
│ study.qpid.pro │
|
||||||
│ Nginx (HTTPS / Let's Encrypt / Security Headers) │
|
│ Nginx (HTTPS / Let's Encrypt / Security Headers) │
|
||||||
└──────────────────────────────┬──────────────────────────────┘
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
│ proxy_pass :8002
|
│ proxy_pass 127.0.0.1:8002
|
||||||
┌──────────────────────────────▼──────────────────────────────┐
|
┌──────────────────────────────▼──────────────────────────────┐
|
||||||
│ Слой безопасности (Auth) │
|
│ Docker Container: study-qpid-pro │
|
||||||
│ • Аутентификация: сессионные токены (HTTP-only cookies) │
|
│ │
|
||||||
│ • Хэширование паролей: PBKDF2-HMAC-SHA256 (соль + 100k) │
|
│ • FastAPI Lifespan & Security Middlewares (GZip, Headers) │
|
||||||
│ • Защита от перебора (Rate Limiter: макс 5 попыток/мин) │
|
│ • Модульные APIRouters (auth, cards, srs, reader, etc.) │
|
||||||
│ • Закрытый доступ: все эндпоинты требуют авторизации │
|
│ • SQLite WAL + mmap I/O (транзакции, 0.01ms чтение) │
|
||||||
|
│ • Локальный Piper Neural TTS (чистый нативный голос VITS) │
|
||||||
|
│ • Вынесенные JSON-датасеты (data/seeds/) │
|
||||||
|
│ • Модульные CSS-стили (7 предметных таблиц + PWA SW v6.7) │
|
||||||
└──────────────────────────────┬──────────────────────────────┘
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
│
|
│ http://ollama:11434 (Keep-Alive 120s)
|
||||||
┌──────────────────────────────▼──────────────────────────────┐
|
┌──────────────────────────────▼──────────────────────────────┐
|
||||||
│ Backend: Python 3.12 │
|
│ Docker Container: study-ollama │
|
||||||
│ FastAPI / Uvicorn + SQLite (WAL mode) + Dictionary APIs │
|
│ • Qwen 2.5 3B Instruct (контекстный перевод предложений │
|
||||||
└──────────────────────────────┬──────────────────────────────┘
|
│ и педагогический дебрифинг уроков) │
|
||||||
│
|
└─────────────────────────────────────────────────────────────┘
|
||||||
┌───────────────────────┴───────────────────────┐
|
|
||||||
▼ ▼
|
|
||||||
┌─────────────────────────────┐ ┌─────────────────────────────┐
|
|
||||||
│ Внешние открытые API │ │ Локальная БД SQLite │
|
|
||||||
│ • Free Dictionary API │ │ • users (аккаунт, пароль) │
|
|
||||||
│ (IPA, audio, definitions) │ │ • vocabulary_cards │
|
|
||||||
│ • MyMemory / Translation │ │ • sessions (активные входы) │
|
|
||||||
│ (русский перевод фразы) │ │ • tags & categories │
|
|
||||||
└─────────────────────────────┘ └─────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Выбор технологий:
|
---
|
||||||
- **Безопасность**: Защищённые сессии (Signed HTTP-only cookie `SameSite=Lax`, `Secure`). Защита от CSRF, XSS и брутфорс-атак через встроенный rate limiter.
|
|
||||||
- **Backend**: Python 3.12 + FastAPI (асинхронный, легковесный, моментальный старт).
|
## 2. Реализованные этапы развития (Completed Roadmap)
|
||||||
- **База данных**: SQLite в режиме `WAL` (Write-Ahead Logging). Идеальная транзакционная надежность, автономность, бэкап в один файл.
|
|
||||||
- **Frontend**: Чистый Vanilla ES6+ / Modern CSS / HTML5. Мгновенная загрузка (< 50 мс), оффлайн-кэш, адаптивность (смартфон + десктоп), поддержка хоткеев.
|
### ✅ Этап 1: Защищённое ядро (MVP) и базовый словарь
|
||||||
- **Внешние API (Zero-cost & Open)**:
|
- [x] Авторизация на сессионных куках (`HttpOnly`, `SameSite=Lax`, `Secure`).
|
||||||
1. *Free Dictionary API* (`api.dictionaryapi.dev`): фонетическая транскрипция IPA, аудио-произношение нативных дикторов (US/UK), части речи, толкования и примеры.
|
- [x] Хэширование паролей `PBKDF2-HMAC-SHA256` с уникальной солью на пользователя.
|
||||||
2. *MyMemory Translation API*: литературный перевод фраз, идиом и предложений на русский язык.
|
- [x] Rate limiting для предотвращения атак перебором.
|
||||||
|
- [x] Офлайн-словарь (140k+ записей) с каскадным переводом.
|
||||||
|
- [x] Сохранение слов с контекстным предложением и горячими клавишами (`Ctrl+Enter`).
|
||||||
|
- [x] Журнал личного словаря, поиск, фильтрация по тегам и коллекциям.
|
||||||
|
|
||||||
|
### ✅ Этап 2: Организация, экспорт и PWA
|
||||||
|
- [x] Коллекции и теги, разбивка слов по категориям.
|
||||||
|
- [x] Пакетные операции над карточками (массовое перемещение, удаление, добавление тегов).
|
||||||
|
- [x] Экспорт данных в форматы: CSV, JSON, Anki TSV и Markdown.
|
||||||
|
- [x] PWA-манифест и Service Worker для офлайн-доступа к приложению.
|
||||||
|
|
||||||
|
### ✅ Этап 3: Алгоритм интервальных повторений (SRS SM-2) и тренажер
|
||||||
|
- [x] Реализация математической модели SuperMemo SM-2 (Ease Factor, Repetitions, Intervals).
|
||||||
|
- [x] 3D-флип карточки с градацией оценок (Снова, Трудно, Хорошо, Легко).
|
||||||
|
- [x] Режим Smart Quiz (выбор из 4 вариантов с дистракторами из той же части речи).
|
||||||
|
- [x] Режим активного ввода (Active Typing) с набором английского слова по русскому переводу.
|
||||||
|
- [x] Smart Daily Mix: сбалансированная подборка (10 новых + 10 повторов + 5 проблемных слов).
|
||||||
|
- [x] Детекция проблемных слов (Leeches) и повторный фокус на них.
|
||||||
|
- [x] Аналитика: календарь активности (Heatmap 180 дней), стрик дней, процент удержания (Retention Rate), оценка уровня CEFR.
|
||||||
|
|
||||||
|
### ✅ Этап 4: Интерактивная читалка и книжная полка
|
||||||
|
- [x] Поддержка книг и документов: EPUB, PDF, TXT, Markdown (`.md`).
|
||||||
|
- [x] Автоматический сборщик стандартов IETF RFC по номеру или URL.
|
||||||
|
- [x] Интеграция с Wikipedia: автономная загрузка статей с очисткой верстки под тайм-бюджет.
|
||||||
|
- [x] Интерактивный клик-перевод каждого слова с выводом контекста.
|
||||||
|
- [x] Добавление слова и цитаты в словарь в 1 клик прямо из текста.
|
||||||
|
- [x] Непрерывное чтение (continuous scroll) с карточками перехода между главами.
|
||||||
|
- [x] Темы оформления (Light, Dark, Sepia, Night, Parchment, Hacker) и типографика.
|
||||||
|
|
||||||
|
### ✅ Этап 5: Автономный локальный ИИ (Ollama) и нейро-озвучка (Piper)
|
||||||
|
- [x] Развертывание локального контейнера Ollama с моделью `qwen2.5:3b`.
|
||||||
|
- [x] Контекстный перевод предложений с учетом целевого слова и сохранением в кэш SQLite.
|
||||||
|
- [x] Локальный VITS-синтез речи Piper TTS (US Lessac, RU Dmitri) с нулевой задержкой.
|
||||||
|
- [x] Фоновое пред-кэширование произношения слов и предложений при добавлении в словарь.
|
||||||
|
- [x] Тренажёр аудирования (Listening) с проверкой понимания.
|
||||||
|
|
||||||
|
### ✅ Этап 6: Грамматика Murphy & Тестирование CEFR
|
||||||
|
- [x] 260 интерактивных заданий Cloze Tap по международным уровням A1–B2.
|
||||||
|
- [x] Автоматическая gap-ингестия: отправка ошибочных слов из тестов в активный тренажёр SRS.
|
||||||
|
- [x] Фоновый воркер майнинга тестов из загруженной литературы.
|
||||||
|
- [x] 15 базовых юнитов грамматики Murphy с формулами и теорией.
|
||||||
|
- [x] Синхронизация с рукописным бумажным конспектом.
|
||||||
|
|
||||||
|
### ✅ Этап 7: Конструктор комплексного урока & Сессионный раннер
|
||||||
|
- [x] Единый конструктор ежедневного урока: блоки слов (Flashcards/Quiz/Typing/Dictation), грамматики, аудирования, чтения.
|
||||||
|
- [x] Встроенные готовые шаблоны (Экспресс, Сбалансированный, Глубокий, Аудио+Чтение).
|
||||||
|
- [x] Адаптивный подбор темы грамматики («Самая слабая тема по ошибкам», «Предыдущая тема»).
|
||||||
|
- [x] Сквозная телеметрия ошибок сессии и интеллектуальный ИИ-дебрифинг от Qwen 2.5 3B.
|
||||||
|
- [x] Детерминированный фолбэк для мгновенного завершения сессии при выключенной Ollama.
|
||||||
|
|
||||||
|
### ✅ Этап 8: Глубокий технический рефакторинг и оптимизация (Beta-refactor)
|
||||||
|
- [x] **Этап 1 рефакторинга**: Удаление мертвого кода (`parse_text`), оптимизация SQL в фоновых задачах.
|
||||||
|
- [x] **Этап 2 рефакторинга**: Вынос 3 100+ строк захардкоженных датасетов в `data/seeds/*.json`.
|
||||||
|
- [x] **Этап 3 рефакторинга**: Keep-Alive пул соединений для Ollama (120s), офлайн-лемматизация в словаре.
|
||||||
|
- [x] **Этап 4 рефакторинга**: Модуляризация бэкенда (`app/main.py` сокращён с 2 240 до 142 строк, создано 11 APIRouters).
|
||||||
|
- [x] **Этап 5 рефакторинга**: Модуляризация CSS (`style.css` 8 746 строк разделен на 7 предметных файлов, обновлён прекэш SW v6.7).
|
||||||
|
- [x] 100% покрытие авто-тестами: 44/44 тестов проходят в Docker.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Этапы реализации (Roadmap)
|
## 3. Ближайшие планы и вектор развития (Future Enhancements)
|
||||||
|
|
||||||
### Этап 1 (Текущая итерация): Безопасное ядро (MVP) — Общий английский, перевод и фиксация
|
|
||||||
*Цель: создать защищённый, сверхбыстрый и надёжный персональный словарь для повседневного и профессионального английского языка.*
|
|
||||||
|
|
||||||
- [x] **1.1. Модуль защиты и авторизации**:
|
|
||||||
- Страница авторизации (минималистичный дизайн, вход по логину и паролю).
|
|
||||||
- Защита всех API эндпоинтов: неавторизованные запросы блокируются (`401 Unauthorized`).
|
|
||||||
- Надежное хранение паролей в SQLite (`PBKDF2-HMAC-SHA256` с уникальной солью).
|
|
||||||
- Rate Limiter на эндпоинт `/api/login` (не более 5 неудачных попыток в минуту с IP).
|
|
||||||
- Сессии через зашифрованные `HttpOnly` cookie.
|
|
||||||
- Кнопка «Выйти из аккаунта» в шапке сервиса.
|
|
||||||
- [x] **1.2. Быстрый поиск и каскадный перевод (General & Idiomatic English)**:
|
|
||||||
- Строка поиска в фокусе сразу при открытии страницы.
|
|
||||||
- Поддержка любых форм языка:
|
|
||||||
- Отдельные слова (существительные, глаголы, прилагательные);
|
|
||||||
- Фразовые глаголы (*run into*, *figure out*, *call off*);
|
|
||||||
- Идиомы и разговорные фразы (*once in a blue moon*, *take for granted*);
|
|
||||||
- Предложения целиком (из книг, сериалов, документации).
|
|
||||||
- Автоматическое получение:
|
|
||||||
- Фонетической транскрипции (IPA);
|
|
||||||
- Нативного аудио-произношения (US/UK);
|
|
||||||
- Частей речи и толкования на английском;
|
|
||||||
- Русского перевода.
|
|
||||||
- Кэширование словарных запросов в локальной базе для мгновенных повторных ответов.
|
|
||||||
- [x] **1.3. Контекстное сохранение слова в 1 клик**:
|
|
||||||
- Горячие клавиши:
|
|
||||||
- `Enter` — мгновенный перевод;
|
|
||||||
- `Ctrl + Enter` (или `Cmd + Enter`) — мгновенное сохранение в словарь;
|
|
||||||
- `Esc` — очистить ввод;
|
|
||||||
- `Ctrl + P` или клик на динамик — прослушать произношение.
|
|
||||||
- Возможность подправить перевод перед сохранением под свой контекст.
|
|
||||||
- Поле для ввода контекстного предложения (где встретилось слово/выражение).
|
|
||||||
- Выбор/ввод тегов (`#general`, `#phrasal-verb`, `#idiom`, `#daily`, `#networking`).
|
|
||||||
- [x] **1.4. Журнал личного словаря (History & Words List)**:
|
|
||||||
- Хронологическая лента всех сохраненных слов с датой и временем.
|
|
||||||
- Живой поиск по английским словам, русскому переводу и контексту.
|
|
||||||
- Фильтр по тегам в один клик.
|
|
||||||
- Воспроизведение аудио прямо из списка.
|
|
||||||
- Удаление / редактирование карточек.
|
|
||||||
- [x] **1.5. Инфраструктурное развертывание**:
|
|
||||||
- Конфигурация Nginx для `notes.qpid.pro` с proxy на бэкенд (`127.0.0.1:8002`).
|
|
||||||
- Systemd-сервис для автозапуска и перезапуска при сбоях.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Этап 2: Организация, теги, экспорт и PWA
|
|
||||||
*Цель: гибкое управление растущим словарным запасом и удобство на мобильных.*
|
|
||||||
|
|
||||||
- [x] **2.1. Расширенная организация и контроль качества**:
|
|
||||||
- Коллекции и папки (например, «Разговорный», «Работа», «Книги/Сериалы», «Сети/IT»).
|
|
||||||
- Быстрое массовое тегирование и групповые действия (удаление, перемещение).
|
|
||||||
- **Контроль и предотвращение дубликатов (Duplicate Prevention & Merging)**:
|
|
||||||
- Проверка при вводе и сохранении (case-insensitive trim).
|
|
||||||
- Уведомление, если слово/фраза уже есть в словаре.
|
|
||||||
- Возможность дописать новый контекстный пример / значение к существующей карточке вместо пложения дублей или перейти к её редактированию.
|
|
||||||
- [x] **2.2. Экспорт данных**:
|
|
||||||
- Экспорт базы в CSV (UTF-8 BOM), JSON, Markdown.
|
|
||||||
- Экспорт в колоду Anki (`.tsv`) с аудио, контекстными предложениями и поддержкой Cloze deletion (`{{c1::word}}`).
|
|
||||||
- [x] **2.3. PWA (Progressive Web App)**:
|
|
||||||
- Установка на экран смартфона (iOS / Android) как нативное приложение.
|
|
||||||
- Service Worker, Web Manifest, оффлайн-кэш статики.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Этап 3: Встроенный тренажер интервальных повторений (SRS / SM-2)
|
|
||||||
*Цель: перевод слов из пассивного словаря в активную долговременную память.*
|
|
||||||
|
|
||||||
- [x] **3.1. Движок SuperMemo-2 (SM-2) & Умная выборка**:
|
|
||||||
- Расчет интервалов забывания Эббингауза: `Again`, `Hard`, `Good`, `Easy`.
|
|
||||||
- **Умный микс дня (Daily Quiz Mix)**: баланс сессии (до 10 новых + 10 интервальных повторений + 5 проблемных слов в разнобой).
|
|
||||||
- **Детектор трудных слов (Leeches)**: автоматическая маркировка слов с частыми ошибками и их приоритетное подмешивание.
|
|
||||||
- [x] **3.2. Режим Flashcards**:
|
|
||||||
- Двусторонняя карточка En $\rightarrow$ Ru и Ru $\rightarrow$ EN с нативной озвучкой, подсказкой контекста и горячими клавишами `Space`, `1..4`.
|
|
||||||
- [x] **3.3. Режим Multiple Choice Quiz (4 варианта ответа)**:
|
|
||||||
- Выбор правильного перевода среди 4 вариантов со случайными дистракторами из базы.
|
|
||||||
- Мгновенная проверка, подсветка цветом (зеленый/красный) и горячие клавиши `1..4`.
|
|
||||||
- [x] **3.4. Режим активного набора (Active Typing / Ru $\rightarrow$ En)**:
|
|
||||||
- Видишь русский перевод / контекст с пропуском $\rightarrow$ вводишь английское слово с клавиатуры (прокачка моторной памяти и спеллинга).
|
|
||||||
- [x] **3.5. Режим Cloze Deletion (Контекстные пропуски)**:
|
|
||||||
- Заполнение пропущенного слова прямо в живом предложении.
|
|
||||||
- [x] **3.6. Аудио-тренажёр (Dictation)**:
|
|
||||||
- Слушаем слово $\rightarrow$ печатаем правильный спеллинг.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Этап 4: Интеллектуальный помощник и аналитика
|
|
||||||
*Цель: отслеживание динамики и поддержка мотивации.*
|
|
||||||
|
|
||||||
- [x] **4.1. Статистика, Daily Streak и Календарь активности**:
|
|
||||||
- Сетка активности за 180 дней с градацией интенсивности (серый, салатовый, зеленый, изумрудный).
|
|
||||||
- Учёт непрерывных дней занятий (Daily Streak 🔥) и рекорда серии.
|
|
||||||
- Сегментированный прогресс-бар стадий освоения (Новые, В процессе, Закрепление, Выучено).
|
|
||||||
- Оценка уровня владения языком (A1 - C1) по объёму активного словаря.
|
|
||||||
- Процент точности запоминания (Retention Rate %).
|
|
||||||
- [ ] **4.2. Быстрый захват из браузера**:
|
|
||||||
- Букмарклет для выделения текста на любой странице и отправки в словарь.
|
|
||||||
- [x] **4.3. Честный и строгий расчёт уровня CEFR (Active Vocabulary & Retention Weighting)**:
|
|
||||||
- Расчёт уровня владения исключительно по **активным выученным словам** (стадии `Mastered` и `Review` в алгоритме SM-2), исключая пассивные добавленные карточки, которые ещё не прошли интервальные повторения.
|
|
||||||
- Корректирующий коэффициент точности: учёт Retention Rate %, чтобы уровень отражал реальную память, а не просто количество выписанных слов.
|
|
||||||
- Индикатор пути к следующему уровню (например: «*До уровня B2 осталось закрепить 142 слова*»).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Этап 5: Интерактивная Читалка & Библиотека (Interactive Reader & Source-based Learning)
|
|
||||||
*Цель: чтение живых книг, статей и документации с мгновенным клик-переводом и авто-тегированием источников.*
|
|
||||||
|
|
||||||
- [x] **5.1. Хранилище текстов и парсер форматов (Bookshelf & Document Engine)**:
|
|
||||||
- Поддержка форматов: вставка текста из буфера, `.txt`, `.md`, электронные книги `.epub` (с разбивкой по главам и сохранением позиции чтения).
|
|
||||||
- Умная очистка веб-статей от лишней верстки и рекламы (Reader view).
|
|
||||||
- [x] **5.2. Автоматическое тегирование источников (Source & Metadata Auto-tagging)**:
|
|
||||||
- Каждое слово, сохранённое из книги/статьи, автоматически получает теги источника:
|
|
||||||
- Название произведения (например, `#fight-club`, `#the-martian`, `#designing-data-intensive-apps`).
|
|
||||||
- Тип/жанр материала (например, `#novel`, `#tech-article`, `#documentation`, `#cybersecurity`).
|
|
||||||
- Автор / Источник (например, `#author:chuck-palahniuk`, `#cloudflare-blog`).
|
|
||||||
- Автоматический захват предложения-контекста, в котором слово встретилось в книге.
|
|
||||||
- [x] **5.3. Запуск тестов и тренировок по источникам и тегам (Tag-scoped SRS Quizzes)**:
|
|
||||||
- Возможность запустить любую тренировку (Карточки, 4 варианта ответа, Набор слова, Диктант) **строго по словам конкретной книги, статьи или автора**.
|
|
||||||
- Быстрый запуск из читалки: *«Повторить все 35 новых слов из Fight Club»* прямо во время или после прочтения главы.
|
|
||||||
- [x] **5.4. Интерактивный клик-перевод и подсветка (Interactive Reader Layer)**:
|
|
||||||
- Клик по любому слову $\rightarrow$ моментальный перевод, транскрипция, нативное аудио и кнопка «+ В словарь» в 1 клик.
|
|
||||||
- Выделение фраз/связок (chunks) мышкой или тапом с литературным переводом всего выражения.
|
|
||||||
- Подсветка в тексте книги уже выученных (`mastered`) и изучаемых (`learning`) слов.
|
|
||||||
|
|
||||||
|
1. **Браузерный букмарклет для захвата слов**:
|
||||||
|
- Минималистичный JS-скрипт для браузера, позволяющий по клику отправить выделенное слово и контекст страницы в личный словарь `study.qpid.pro`.
|
||||||
|
2. **HTML5 Web Speech API fallback**:
|
||||||
|
- Дополнительный запасной канал озвучивания через встроенный `window.speechSynthesis` при мобильном режиме без связи.
|
||||||
|
3. **Расширение библиотеки Murphy**:
|
||||||
|
- Постепенное расширение учебных юнитов грамматики с 15 до 30+ тем (Passive Voice, Reported Speech, Phrasal Verbs in Context).
|
||||||
|
4. **Адаптивные интервалы по времени суток**:
|
||||||
|
- Учёт утренних и вечерних окон повторения в сессионном раннере для максимальной концентрации.
|
||||||
|
|||||||
@@ -1,46 +1,219 @@
|
|||||||
# Notes (notes.qpid.pro)
|
# Study Qpid (study.qpid.pro)
|
||||||
|
|
||||||
> **Защищённый персональный тренажёр и словарь английского языка.**
|
> **Автономный персональный тренажёр и интерактивная языковая среда английского языка.**
|
||||||
> Создан для быстрого и надёжного расширения словарного запаса (общий английский, фразовые глаголы, идиомы, профессиональные термины) без отвлечения от чтения и просмотра контента.
|
> Создан для быстрого и надёжного расширения активного словарного запаса, автоматизации грамматики и беглого чтения технической и художественной литературы с поддержкой локального искусственного интеллекта (Ollama) и локального синтеза речи (Piper TTS) без внешних платных API.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Концепция и методика
|
## 🎯 Концепция и философия системы
|
||||||
Чтение статей, книг или просмотр видео на английском часто тормозится незнакомыми словами и выражениями. Обычный переводчик даёт мгновенный ответ, но слово сразу забывается. Ручные списки превращаются в «кладбище информации».
|
|
||||||
|
|
||||||
Проект решает эту проблему по принципу **«автомата Калашникова»**:
|
Традиционное изучение языка разбивается о разрозненность: словарь в одном приложении, карточки в другом, книги в третьем, а грамматика в тетрадке.
|
||||||
1. **Безопасность**: сервис работает на белом IP, защищён авторизацией (сессии, хэширование паролей, защита от брутфорса).
|
|
||||||
2. **Мгновенный поиск и перевод**: фокус на строке ввода, каскадный перевод (словарная статья + толкование + русский перевод).
|
**Study Qpid** объединяет весь цикл в единый закрытый контур по принципу **«автомата Калашникова»**:
|
||||||
3. **Контекстный якорь**: сохранение слова вместе с живым предложением (где встретилось выражение).
|
1. **100% автономность и приватность**: веб-сервис развернут в Docker, защищён строгой авторизацией (сессии HttpOnly, хэширование PBKDF2-HMAC-SHA256, rate limiting). Локальный ИИ (Qwen 2.5 3B) и синтез речи (Piper) работают полностью на собственном сервере без передачи данных сторонним компаниям.
|
||||||
4. **Фонетическое закрепление**: нативное аудио-произношение (US/UK) и транскрипция IPA.
|
2. **Контекстный якорь и лексический подход**: слова изучаются в готовых блоках (*chunks*) и живых предложениях из прочитанных книг и документации.
|
||||||
5. **Интервальные повторения (SRS / SM-2)**: встроенные карточки для перевода слов в долговременную память.
|
3. **Многомодальный интервальный тренажер (SRS SM-2)**: 5 различных режимов работы с карточками (3D Flashcards, умная викторина с дистракторами по частям речи, активный ввод Typing, аудио-диктант, аудирование Q&A).
|
||||||
|
4. **Интерактивная читалка**: мгновенный перевод кликом по слову, захват предложений в карточки в 1 клик, поддержка форматов EPUB, PDF, TXT, Markdown, спецификаций IETF RFC и статей Wikipedia.
|
||||||
|
5. **Грамматический трек Murphy & CEFR**: 15 базовых юнитов грамматики с формулами, связка с бумажной рабочей тетрадью, 260 интерактивных тестов Cloze Tap и автоматическая отправка ошибок в активную очередь повторений.
|
||||||
|
6. **Конструктор комплексного урока**: единый сессионный раннер, собирающий ежедневный урок из слов, грамматики, аудирования, чтения и завершающийся педагогическим разбором ошибок от локальной нейросети.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📂 Документация проекта
|
## 🚀 Основной реализованный функционал
|
||||||
|
|
||||||
- 📚 [**Источники бесплатных книг (BOOKS_SOURCES.md)**](BOOKS_SOURCES.md) — проверенные каталоги адаптированных книг (A1–C1) и оригинальной классики в EPUB/TXT для читалки.
|
### 1. 🔍 Контекстный словарь и поиск
|
||||||
- 📋 [**План функционала и архитектура (PLAN.md)**](PLAN.md) — детальный план итераций: от защищённого MVP до умных интервальных тренажёров.
|
* **Локальная словарная база**: более 140 000 словарных статей в SQLite, дополненных морфологическим анализатором (лемматизация инфлексий `starts` → `start`, `studying` → `study`).
|
||||||
- 🎓 [**Методология и педагогика (PEDAGOGY.md)**](PEDAGOGY.md) — методические правила эффективного освоения языка: лексические связки (chunks), контекст, фонетика, кривая забывания Эббингауза.
|
* **Детектор фразовых глаголов и идиом**: при поиске слова в контексте предложения система автоматически определяет устойчивые связки (*look forward to*, *run into*, *figure out*) и предлагает сохранить именно целостное выражение.
|
||||||
- ✅ [**Очередь задач и бэклог (TODO.md)**](TODO.md) — текущий статус реализации оптимизаций и фичей.
|
* **Каскадный перевод**: офлайн-словарь + транскрипция IPA + толкование на английском + русский перевод.
|
||||||
|
* **Управление базой**: разбивка по коллекциям (*General*, *IT/Tech*, *Phrasal Verbs* и др.), система тегов, быстрый поиск и фильтрация, пакетные операции (перемещение, тегирование, удаление).
|
||||||
|
|
||||||
|
### 2. 🧠 Локальный ИИ (Ollama & Qwen 2.5 3B)
|
||||||
|
* **Контекстный перевод предложений**: перевод предложения с фокусом на точное значение целевого слова в данном контексте через локальную LLM (`qwen2.5:3b`).
|
||||||
|
* **Кэширование переводов**: все сгенерированные переводы сохраняются в SQLite (`sentence_translations`) с моментальной отдачей при повторных запросах.
|
||||||
|
* **Педагогический дебрифинг урока**: анализ ошибок сессии, определение паттернов (путаница времен, предлогов), рекомендации и выставление объективной оценки сессии.
|
||||||
|
* **Отказоустойчивость**: постоянный пул соединений с Keep-Alive (120s) в FastAPI lifespan; при недоступности Ollama система мгновенно переключается на детерминированный математический отчёт без зависаний.
|
||||||
|
|
||||||
|
### 3. 🔊 Автономная нейро-озвучка (Piper TTS)
|
||||||
|
* **Локальный VITS-инференс**: чистый нативный звук дикторов без внешних запросов к облакам (`en_US-lessac`, `ru_RU-dmitri` + поддержка альтернативных дикторов).
|
||||||
|
* **Проактивное фоновое пред-кэширование**: при добавлении слова в словарь аудиозапись слова и контекста синтезируется сервером в фоне, обеспечивая нулевую задержку при повторении в тренажере.
|
||||||
|
* **Аудио в читалке**: озвучивание отдельных слов, целых предложений оригинала и русского перевода.
|
||||||
|
|
||||||
|
### 4. 🎓 Тренажёр интервальных повторений (SRS SM-2)
|
||||||
|
* **Алгоритм SuperMemo SM-2**: адаптивный расчёт интервалов (1, 6, $I \times EF$ дней) на основе оценок пользователя (Снова, Трудно, Хорошо, Легко).
|
||||||
|
* **5 специализированных режимов повторений**:
|
||||||
|
1. *3D Flashcards* — классические двусторонние карточки с 3D-анимацией переворота.
|
||||||
|
2. *Smart Quiz* — множественный выбор из 4 вариантов с автоматическим подбором правдоподобных дистракторов из той же части речи.
|
||||||
|
3. *Active Typing* — активная продукция: печать английского слова по русскому переводу и контексту с пропуском.
|
||||||
|
4. *Audio Dictation* — аудиотренажёр: прослушивание произношения через Piper TTS и ввод слова на слух.
|
||||||
|
5. *Listening Comprehension* — интерактивные аудио-сценарии с вопросами на проверку понимания.
|
||||||
|
* **Smart Daily Mix**: сбалансированная ежедневная тренировка (10 новых слов + 10 плановых повторений + 5 проблемных слов-личей, перемешанных для интерливинга).
|
||||||
|
* **Аналитика и визуализация**: GitHub-style календарь активности за 180 дней, подсчёт непрерывного стрика, распределение по статусам (New, Learning, Review, Mastered), расчёт Retention Rate и оценка словарного уровня по CEFR.
|
||||||
|
|
||||||
|
### 5. 📚 Интерактивная читалка и библиотека
|
||||||
|
* **Поддержка форматов**: загрузка книг в форматах EPUB, PDF, TXT, Markdown (`.md`).
|
||||||
|
* **Импорт RFC-спецификаций**: загрузка технических стандартов IETF RFC по номеру или URL с автоматической разбивкой на структурированные главы.
|
||||||
|
* **Интеграция с Wikipedia**: автономный импорт статей по названию, теме и доступному бюджету времени на чтение.
|
||||||
|
* **Интерактивный клик-перевод**: каждое слово кликабельно с показом всплывающей карточки (на десктопе — всплывающее окно у курсора, на смартфоне — удобный Bottom Sheet).
|
||||||
|
* **Непрерывное чтение**: бесконечный скролл с навигационными карточками завершения глав, автоматическое сохранение позиции чтения (`current_chapter`, `current_position`).
|
||||||
|
* **Типографика и темы**: 6 цветовых схем (Light, Dark, Sepia, Night, Parchment, Hacker), регулировка ширины страницы, межстрочного интервала и шрифтов (Georgia, Inter, JetBrains Mono).
|
||||||
|
|
||||||
|
### 6. 🏆 Тесты CEFR (A1–B2) & Автоматическая Gap-ингестия
|
||||||
|
* **260 интерактивных заданий Cloze Tap**, распределённых по международным уровням владения языком (A1, A2, B1, B2).
|
||||||
|
* **Автоматическое устранение пробелов (Gap Ingestion)**: любая допущенная ошибка в тесте мгновенно конвертируется в словарную карточку и отправляется в активную очередь SRS со статусом `learning`.
|
||||||
|
* **Фоновый майнер тестов из книг**: асинхронный фоновый воркер для автоматического извлечения тестовых заданий из загруженных пользователем книг с фильтрацией по целевому уровню CEFR.
|
||||||
|
|
||||||
|
### 7. 📖 Грамматический трек Murphy & Тетрадь
|
||||||
|
* **15 базовых юнитов** классической методики Raymond Murphy (*Essential Grammar in Use*).
|
||||||
|
* **Структура каждого юнита**:
|
||||||
|
- Карточка юнита со статусом освоения и баллом.
|
||||||
|
- Полноэкранный теоретический модуль с формулами образования, русскими объяснениями и маркерами времени.
|
||||||
|
- 5 интерактивных упражнений Cloze Tap с мгновенной проверкой.
|
||||||
|
* **Интеграция с бумажным конспектом**: чекбокс ручной фиксации упражнений в физической тетради для задействования моторной памяти.
|
||||||
|
|
||||||
|
### 8. ⚡ Конструктор комплексного урока & Сессионный раннер
|
||||||
|
* **Гибкая структура занятия**: настройка порядка этапов, переключение тумблеров активности, выбор объема слов и упражнений.
|
||||||
|
* **Готовые пресеты**:
|
||||||
|
- ⚡ *Утренний экспресс (10 мин)*
|
||||||
|
- 🎯 *Сбалансированный урок (20 мин)*
|
||||||
|
- 📖 *Глубокое погружение (30 мин)*
|
||||||
|
- 🎧 *Аудио + Чтение (15 мин)*
|
||||||
|
- Возможность сохранения собственных шаблонов в базу SQLite.
|
||||||
|
* **Умный подбор грамматики**: адаптивный выбор темы грамматики на основе истории ошибок («Самая слабая тема», «Предыдущая тема» или ручной выбор).
|
||||||
|
* **Сквозной раннер**: бесшовный интерфейс с таймером, прогресс-баром и финальным ИИ-дебрифингом.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠 Технический стек
|
## 🛠 Техническая архитектура и стек
|
||||||
|
|
||||||
- **Backend**: Python 3.12 (FastAPI / Uvicorn)
|
```
|
||||||
- **Database**: SQLite (WAL mode — надёжность, автономность, бэкап в один файл)
|
https://study.qpid.pro
|
||||||
- **Безопасность**: Защищённые сессии (HttpOnly cookies), PBKDF2-HMAC-SHA256, Rate Limiting
|
│
|
||||||
- **Frontend**: Vanilla HTML5 / Modern CSS / ES6+ JavaScript (PWA, горячие клавиши, мгновенный отклик)
|
┌──────────────────▼──────────────────┐
|
||||||
- **Внешние API**: Free Dictionary API (IPA + audio), MyMemory Translation API
|
│ Nginx (Host SSL / Reverse Proxy) │
|
||||||
- **Веб-сервер**: Nginx Reverse Proxy + SSL Let's Encrypt (`notes.qpid.pro`)
|
└──────────────────┬──────────────────┘
|
||||||
- **Репозиторий проекта**: [Gitea (ai.qpid.pro/gitea/Antigravity/notes-qpid-pro)](https://ai.qpid.pro/gitea/Antigravity/notes-qpid-pro)
|
│ :8002
|
||||||
|
┌─────────────────────────────────────────▼────────────────────────────────────────┐
|
||||||
|
│ Docker Container: study-qpid-pro │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ FastAPI Application (app/main.py) │ │
|
||||||
|
│ │ • GZipMiddleware (≥1000B) • Security Headers Middleware │ │
|
||||||
|
│ │ • Persistent Lifespan Pool • Service Worker Static Precache (v6.7) │ │
|
||||||
|
│ └─────────────────────────────────────┬────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────────────────────────────────▼────────────────────────────────────┐ │
|
||||||
|
│ │ Модульные APIRouters │ │
|
||||||
|
│ │ • auth.py • cards.py • dictionary.py • srs.py │ │
|
||||||
|
│ │ • reader.py • cefr.py • grammar.py • lessons.py │ │
|
||||||
|
│ │ • listening.py • tasks.py • export.py │ │
|
||||||
|
│ └─────────────────┬───────────────────┬──────────────────┬─────────────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌─────────────────▼─────────┐ ┌───────▼────────┐ ┌───────▼─────────────────┐ │
|
||||||
|
│ │ SQLite WAL (data/notes) │ │ Piper TTS │ │ Seed Datasets (JSON) │ │
|
||||||
|
│ │ • Memory-Mapped I/O │ │ • Offline VITS │ │ • cefr_questions.json │ │
|
||||||
|
│ │ • Full Schema Integrity │ │ • Zero-Latency │ │ • grammar_units.json │ │
|
||||||
|
│ │ • Sub-millisecond reads │ │ • Voice Cache │ │ • phrasal_verbs.json │ │
|
||||||
|
│ └───────────────────────────┘ └────────────────┘ └─────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────┬────────────────────────────────────────┘
|
||||||
|
│ http://ollama:11434
|
||||||
|
┌─────────────────────────────────────────▼────────────────────────────────────────┐
|
||||||
|
│ Docker Container: study-ollama │
|
||||||
|
│ • Model: Qwen 2.5 3B Instruct (GGUF) │
|
||||||
|
│ • Connection Pool with Keep-Alive (120s) │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Backend**: Python 3.12, FastAPI, Uvicorn, Pydantic v2.
|
||||||
|
* **База данных**: SQLite в режиме `WAL` (Write-Ahead Logging) с `mmap_size = 256MB` и индексами по внешним ключам и выражениям.
|
||||||
|
* **Frontend**: Vanilla ES6+ модули, HTML5 PWA, CSS-переменные (7 предметных таблиц стилей), поддержка offline через Service Worker.
|
||||||
|
* **AI & TTS**: Ollama (`qwen2.5:3b`), Piper Neural TTS (локальный бинарник).
|
||||||
|
* **Тестирование**: Pytest + TestClient (44 автоматических интеграционных теста).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌐 Информация об окружении
|
## 📁 Структура проекта
|
||||||
|
|
||||||
- **URL сервиса**: [https://notes.qpid.pro](https://notes.qpid.pro)
|
```
|
||||||
- **IP сервера**: `31.77.203.213`
|
study.qpid.pro/
|
||||||
- **Каталог проекта**: [`/home/kopri/notes.qpid.pro`](file:///home/kopri/notes.qpid.pro)
|
├── app/
|
||||||
- **Git Remote**: `http://127.0.0.1:3000/Antigravity/notes-qpid-pro.git`
|
│ ├── main.py # Лаконичная точка входа FastAPI (lifespan, middleware, роутеры)
|
||||||
|
│ ├── auth.py # Безопасность, хэширование PBKDF2, сессии, rate limiting
|
||||||
|
│ ├── config.py # Переменные окружения и системные пути
|
||||||
|
│ ├── database.py # Инициализация SQLite WAL, индексы, миграции
|
||||||
|
│ ├── dictionary.py # Поиск в офлайн-словаре, лемматизация, каскадные запросы
|
||||||
|
│ ├── ai.py # Интеграция с Ollama (Keep-Alive пул, промпты, дебрифинг)
|
||||||
|
│ ├── tts.py # Синтез речи Piper TTS и управление кэшем аудио
|
||||||
|
│ ├── srs.py # SM-2 интервалы, викторины, аналитика, тепловая карта
|
||||||
|
│ ├── reader.py # Парсеры EPUB, PDF, TXT, Markdown, RFC и Wikipedia
|
||||||
|
│ ├── cefr.py # Уровневое тестирование A1–B2 и gap-ингестия
|
||||||
|
│ ├── grammar.py # Движок Murphy Grammar и журнал конспектов
|
||||||
|
│ ├── lessons.py # Конструктор уроков, пресеты, история сессий
|
||||||
|
│ ├── listening.py # Сценарии аудирования и оценка ответов
|
||||||
|
│ ├── miner.py # Фоновый майнинг предложений из литературы
|
||||||
|
│ ├── tasks.py # Управление фоновыми задачами SQLite
|
||||||
|
│ ├── export.py # Экспорт базы (CSV, JSON, Anki, Markdown)
|
||||||
|
│ ├── routers/ # Модульные роутеры FastAPI (11 файлов)
|
||||||
|
│ └── static/ # Статика веб-приложения
|
||||||
|
│ ├── index.html # Одностраничное приложение (PWA Shell)
|
||||||
|
│ ├── sw.js # Service Worker (кэширование v6.7)
|
||||||
|
│ ├── manifest.json # PWA-манифест
|
||||||
|
│ ├── css/ # Модульная таблица стилей
|
||||||
|
│ │ ├── style.css # Мастер-файл со структурой и @import
|
||||||
|
│ │ ├── base.css # Токены, темы, сброс, safe-areas
|
||||||
|
│ │ ├── components.css # Навбар, меню, кнопки, модалки, таб-бар
|
||||||
|
│ │ ├── dictionary.css # Поиск, карточки слов, коллекции, теги
|
||||||
|
│ │ ├── srs.css # Карточки 3D, квизы, ввод, аналитика
|
||||||
|
│ │ ├── reader.css # Читалка, темы оформления, Wikipedia
|
||||||
|
│ │ ├── grammar_cefr.css # CEFR и юниты Murphy
|
||||||
|
│ │ └── lesson.css # Конфигуратор и раннер уроков
|
||||||
|
│ └── js/ # ES6 модули фронтенда
|
||||||
|
│ ├── app.js # Точка входа SPA, роутинг, хоткеи
|
||||||
|
│ └── modules/ # Модули логики (ui, api, dictionary, srs, reader, cefr, grammar, lesson)
|
||||||
|
├── data/
|
||||||
|
│ ├── notes.db # Основная база данных SQLite
|
||||||
|
│ ├── offline_dict.tsv.gz # Локальный словарь (140k+ записей)
|
||||||
|
│ ├── audio_cache/ # Кэш предсинтезированных WAV-файлов
|
||||||
|
│ └── seeds/ # Кураторские JSON-датасеты (CEFR, Murphy, Phrasal, Articles)
|
||||||
|
├── scripts/
|
||||||
|
│ ├── deploy_beta.sh # Развертывание бета-ветки в 1 клик
|
||||||
|
│ ├── rollback_to_main.sh # Мгновенный откат на стабильный main
|
||||||
|
│ └── build_offline_dict.py # Сборщик офлайн-словаря
|
||||||
|
├── tests/
|
||||||
|
│ └── test_api.py # Набор интеграционных тестов (44 теста)
|
||||||
|
├── docker-compose.yml # Декларация контейнеров study-web и ollama
|
||||||
|
└── Dockerfile # Образ веб-приложения на Python 3.12-slim
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Развёртывание и запуск
|
||||||
|
|
||||||
|
### Запуск через Docker Compose (Рекомендуемый)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Клонирование репозитория
|
||||||
|
git clone https://ai.qpid.pro/gitea/Antigravity/notes-qpid-pro.git
|
||||||
|
cd notes-qpid-pro
|
||||||
|
|
||||||
|
# Запуск связки веб-сервера и локальной Ollama
|
||||||
|
DOCKER_HOST=tcp://127.0.0.1:2375 docker compose up -d
|
||||||
|
|
||||||
|
# Запуск тестов внутри контейнера
|
||||||
|
DOCKER_HOST=tcp://127.0.0.1:2375 docker exec -i -e PYTHONPATH=. study-qpid-pro pytest tests/test_api.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Управление ветками и откат:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Быстрый переход на бета-версию:
|
||||||
|
./scripts/deploy_beta.sh
|
||||||
|
|
||||||
|
# Мгновенный откат на стабильную ветку main:
|
||||||
|
./scripts/rollback_to_main.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 Лицензия и статус проекта
|
||||||
|
* Проект разрабатывается в активной фазе для повседневного практического освоения языка.
|
||||||
|
* 100% открытый исходный код без проприетарных платных облачных зависимостей.
|
||||||
|
|||||||
@@ -447,16 +447,105 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⏳ Coming Soon
|
### 20. 🏗️ Комплексный технический рефакторинг и оптимизация архитектуры (Completed)
|
||||||
|
- [x] **Этап 1: Очистка мертвого кода и оптимизация SQL**:
|
||||||
|
- Удалена старая незавершенная функция `parse_text` в `app/reader.py` (строки 290–326).
|
||||||
|
- Функция `update_task_progress` в `app/tasks.py` переписана с цикла на единый параметризованный SQL-запрос `UPDATE tasks SET progress = ?, current_item = ?, ...`.
|
||||||
|
- [x] **Этап 2: Вынос датасетов в JSON (`data/seeds/*.json`)**:
|
||||||
|
- Из исходников извлечено более 3 100 строк статических данных: `cefr_questions.json` (260 вопросов), `grammar_units.json` (15 юнитов), `phrasal_verbs.json` (157 глаголов), `reading_articles.json` (8 статей).
|
||||||
|
- Модули `app/cefr.py`, `app/grammar.py`, `app/phrasal.py`, `app/lessons.py` переведены на динамическую ленивую загрузку с сохранением полной обратной совместимости.
|
||||||
|
- [x] **Этап 3: Оптимизация сети и локального ИИ**:
|
||||||
|
- Внедрен постоянный пул соединений `httpx.AsyncClient` с Keep-Alive (120s) для локальной Ollama в `app/ai.py`, управляемый в жизненном цикле приложения (`lifespan`).
|
||||||
|
- Интегрирована встроенная офлайн-лемматизация (`lemmatize_word`) в `lookup_term` (`app/dictionary.py`), предотвращающая лишние сетевые запросы при поиске словоформ (`starts` -> `start`, `studying` -> `study`).
|
||||||
|
- [x] **Этап 4: Модуляризация бэкенда FastAPI**:
|
||||||
|
- Монолитный `app/main.py` сокращен с 2 240 до 142 строк.
|
||||||
|
- Создан пакет `app/routers/` с 11 тематическими APIRouter: `auth.py`, `cards.py`, `dictionary.py`, `srs.py`, `export.py`, `reader.py`, `cefr.py`, `grammar.py`, `tasks.py`, `lessons.py`, `listening.py`.
|
||||||
|
- [x] **Этап 5: Модуляризация CSS и Service Worker**:
|
||||||
|
- Монолитный `style.css` (8 746 строк) разделен на 7 предметных таблиц в `app/static/css/`: `base.css`, `components.css`, `dictionary.css`, `srs.css`, `reader.css`, `grammar_cefr.css`, `lesson.css`.
|
||||||
|
- Мастер-файл `style.css` загружает стили через `@import` и содержит полную архитектурную карту.
|
||||||
|
- Обновлен Service Worker (`notes-v6.7`) с добавлением всех CSS в `PRECACHE_URLS` для полной офлайн-работы PWA.
|
||||||
|
- [x] **Инфраструктура быстрого переключения и отката**:
|
||||||
|
- Добавлены скрипты `scripts/deploy_beta.sh` и `scripts/rollback_to_main.sh` для мгновенного отката или развертывания в 1 клик.
|
||||||
|
- Создан резервный бэкап базы данных `data/notes.db.backup-main-pre-beta`.
|
||||||
|
- [x] **100% успешное тестирование**:
|
||||||
|
- Все 44 интеграционных теста `pytest tests/test_api.py` успешно проходят в Docker-контейнере.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🐛 Устранённые баги (Решены и протестированы)
|
||||||
|
|
||||||
|
- [x] **BUG-01: Не открывается окно редактирования слова в словаре на главной**:
|
||||||
|
- **Симптом**: при клике на иконку ✏️ у карточки слова в списке словаря ничего не происходило.
|
||||||
|
- **Решение**: исправлен селектор ID в `app/static/js/modules/dictionary.js` с `'edit-card-modal'` на `'edit-modal'`. Модальное окно теперь стабильно открывается.
|
||||||
|
- **Статус**: [x] Решено.
|
||||||
|
- [x] **BUG-02: Отсутствует возможность прослушать слово и предложение из словаря**:
|
||||||
|
- **Симптом**: в списке карточек словаря на главной странице не было кнопок 🔊 для воспроизведения аудио слова и контекстного предложения.
|
||||||
|
- **Решение**: добавлены кнопки озвучки 🔊 в `app/static/js/modules/dictionary.js` для `term` (`playAudio`) и `context_sentence` (`playTextSpeech`) с адаптивными стилями в `dictionary.css`.
|
||||||
|
- **Статус**: [x] Решено.
|
||||||
|
- [x] **BUG-03: Спойлер корня слова в переводе грамматических форм в тестах (например, `(прош. вр. от explain)`)**:
|
||||||
|
- **Симптом**: при добавлении словоформ в словарь (например, `explained`) в переводе сохранялись пометки: `(прош. вр. от explain)`, спойлеря правильный ответ во время тестов.
|
||||||
|
- **Решение**: в `app/static/js/modules/ui.js` добавлены утилиты `cleanPromptTranslation` (вырезает подсказки корня в квизах, тайпинге, клоуз-тестах и диктантах в `srs.js` и `lesson.js`) и `formatTranslationWithSpoiler` (оборачивает пометку в интерактивный кликабельный бейдж-спойлер `[+ грамматика]` в карточках словаря).
|
||||||
|
- **Статус**: [x] Решено.
|
||||||
|
- [x] **BUG-04: В квизе «4 варианта» правильный ответ всегда находится на 1-м месте**:
|
||||||
|
- **Симптом**: в комплексном уроке и SRS при выборе режима «4 варианта» правильный ответ всегда гарантированно оказывался первым.
|
||||||
|
- **Решение**: в `app/static/js/modules/ui.js` функция `shuffleArray` переведена в мутирующий режим in-place, а в `lesson.js` и `srs.js` вызовы обновлены на явное переприсваивание `options = shuffleArray(options)`. Правильный ответ теперь распределяется равномерно случайно.
|
||||||
|
- **Статус**: [x] Решено.
|
||||||
|
- [x] **BUG-05: Поломка верстки и отображение сырых HTML-тегов/JSON при импорте статей Википедии (например, Linux)**:
|
||||||
|
- **Симптом**: при открытии статьи «Linux» отображался сырой JSON шаблонов MediaWiki (`"},"title":{"wt":"Linux"}...`) и сломанные теги `<tdTimes New Roman...`.
|
||||||
|
- **Решение**: в `app/reader.py` внедрена предварительная очистка атрибутов MediaWiki Parsoid (`data-mw`, `data-parsoid`, `about`), содержащих вложенные `<ref>` и литеральные `>`, ломавшие регулярные выражения тегов. В `clean_data_table` исправлен regex для поддержки вложенных кавычек `(?:"[^"]*"|\'[^\']*\')`. Статья 19 (Linux) в `data/notes.db` перепарсена и сохранена в идеально чистом виде. Добавлен регрессионный тест в `tests/test_api.py`.
|
||||||
|
- **Статус**: [x] Решено.
|
||||||
|
- [x] **BUG-06: Не работает поиск и обратный перевод с русского на английский в словаре**:
|
||||||
|
- **Симптом**: при вводе в поле поиска словаря русского слова перевод не находился, возвращался пустой результат.
|
||||||
|
- **Решение**: в `app/dictionary.py` реализовано автоматическое определение кириллицы `[\u0400-\u04FF]`. При русском вводе выполняется обратный поиск в `offline_dictionary` с приоритизацией одиночных слов и запрос MyMemory с `langpair="ru|en"`, обогащаемый фонетикой и частью речи. В `app/routers/dictionary.py` и `dictionary.js` поддержано заполнение карточки в обратном направлении (найденный английский термин в `term`, русский запрос в `translation`). Добавлен регрессионный тест в `tests/test_api.py`.
|
||||||
|
- **Статус**: [x] Решено.
|
||||||
|
- [x] **BUG-07: Ошибка загрузки этапа аудирования: `undefined is not an object (evaluating 'scenario.topic')`**:
|
||||||
|
- **Симптом**: при переходе к шагу аудирования в уроке возникала ошибка загрузки этапа из-за чтения `scenario.topic`.
|
||||||
|
- **Решение**: в `app/static/js/modules/lesson.js` строка 1659 обновлена на безопасное извлечение `const scenario = (data && (data.scenario || data)) || {};`, поддерживающее как прямой, так и вложенный формат ответа.
|
||||||
|
- **Статус**: [x] Решено.
|
||||||
|
- [x] **BUG-08: Бесконечный спиннер («Анализ ИИ длится бесконечно») на этапе дебрифинга урока + TTL 1h**:
|
||||||
|
- **Симптом**: после прохождения урока анализ ИИ висел бесконечно долго из-за холодного старта Ollama и обрыва HTTP-соединения.
|
||||||
|
- **Решение**:
|
||||||
|
1. Увеличен TTL модели в памяти с `5m` до `1h` (`OLLAMA_KEEP_ALIVE=1h` в `config.py` и `docker-compose.yml`) — модель остается теплой всю учебную сессию.
|
||||||
|
2. В `app/ai.py` таймаут к Ollama ограничен 10 секундами с мгновенным автоматическим фолбэком на детерминированный локальный разбор `generate_deterministic_debrief` (< 1 мс).
|
||||||
|
3. В `app/static/js/modules/api.js` добавлен жесткий 12-секундный клиентский таймаут с `AbortController`.
|
||||||
|
- **Статус**: [x] Решено.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⏳ Очередь новых функций (Feature Backlog)
|
||||||
|
|
||||||
|
- [x] **FEAT-01: 🧘 Фокус-режим в читалке (Focus Mode / Zen Reader — 100% vh & 100% vw)**:
|
||||||
|
- **Концепция**: чистый дзен-режим чтения на весь экран без отвлекающих панелей с сохранением 100% интерактивного перевода.
|
||||||
|
- **Интерфейс и верстка**:
|
||||||
|
- Скрытие шапки сайта (`navbar`), тулбара читалки, мобильной панели вкладок (`bottom tab bar`) и рамок.
|
||||||
|
- Текстовый контейнер расширяется на `100vw` и `100vh` (с учётом `env(safe-area-inset-*)`).
|
||||||
|
- Сохраняются все пользовательские настройки типографики (`Aa`): размер шрифта, гарнитура (Sans/Serif/Mono), ширина колонки и темы оформления (Dark, Sepia, Light, OLED).
|
||||||
|
- **Интерфейс перевода (Zero Loss)**:
|
||||||
|
- Клик / тап по любому слову по-прежнему открывает всплывающее окно перевода (десктопный попап или мобильный Bottom Sheet).
|
||||||
|
- Доступны контекстный ИИ-перевод предложений, словарные статьи и быстрое добавление `+ В словарь`.
|
||||||
|
- **Управление и HUD**:
|
||||||
|
- Иконка активации `🧘` в тулбаре читалки и поддержка горячей клавиши `F`.
|
||||||
|
- Ненавязчивый полупрозрачный плавающий оверлей в углу с индикатором прогресса чтения и кнопкой выхода `✕`.
|
||||||
|
- Выход по нажатию клавиши `Escape`, клику на `✕` или свайпу/тапу по верхнему краю экрана.
|
||||||
|
- Поддержка нативного Fullscreen API (`document.documentElement.requestFullscreen()`) для браузеров на десктопе и Android.
|
||||||
|
- [ ] **FEAT-02: 📝 Подробные конспекты для тетради и шпаргалки с таблицами (Murphy Detailed Notes & Markdown Tables)**:
|
||||||
|
- **Полноценные Markdown-таблицы**: отображение наглядных сравнительных таблиц в теории грамматики и подсказках (спряжения глаголов, формы неправильных глаголов, маркеры времени, предлоги места/времени).
|
||||||
|
- **Глубокая структурированная теория без воды**: развернутые объяснения правил по методике Raymond Murphy с живыми примерами и контрастными парами (вместо сжатых 1-2 предложений), готовые для записи в рабочую тетрадь.
|
||||||
|
- [ ] **FEAT-03: 🤖 Интеграция с Google NotebookLM для генерации инфографики и карточек (Низкий приоритет)**:
|
||||||
|
- Использование возможностей Google NotebookLM или мультимодальных моделей для автоматической генерации наглядной инфографики и подробных обучающих карточек, «разжевывающих» грамматические нюансы и идиомы.
|
||||||
|
- [ ] **FEAT-04: 🎯 Адаптивный объем тренировки (доведение до автоматизма) и умные рекомендации**:
|
||||||
|
- Настраиваемый объем заданий в блоках урока: выбор между 5, 10, 15, 20 заданиями или режим «тренировка до 90% точности / безошибочной серии».
|
||||||
|
- Персональные рекомендации при низком балле (< 75-80%): алгоритм анализирует ошибки и автоматически предлагает, какие конкретно статьи в читалке изучить и какие юниты грамматики Мерфи повторить.
|
||||||
|
- [ ] **FEAT-05: ✍️ Режим активного составления предложений (Sentence Builder / Production Mode)**:
|
||||||
|
- Тренировка активного словарного запаса: студенту предлагается слово из личного словаря (например, *«установленный» / «installed»*), на которое нужно составить и записать предложение на английском.
|
||||||
|
- Грамматические модификаторы сложности: задание условий по времени и конструкции (например: *«составь предложение в Past Simple»*, *«используй Future Continuous»*, *«составь вопрос с предлогом»*).
|
||||||
|
- Проверка через локальную языковую модель с мгновенным разбором правильности, стиля и грамматических ошибок.
|
||||||
- [ ] **🌐 Быстрый захват из браузера (Bookmarklet)**:
|
- [ ] **🌐 Быстрый захват из браузера (Bookmarklet)**:
|
||||||
- Создать JavaScript-букмарклет для добавления слова/фразы из любого окна браузера в личный словарь [`study.qpid.pro`](https://study.qpid.pro) (низший приоритет).
|
- Создать JavaScript-букмарклет для добавления слова/фразы из любого окна браузера в личный словарь [`study.qpid.pro`](https://study.qpid.pro) (низший приоритет).
|
||||||
- [ ] **Нативная автономная озвучка (HTML5 Web Speech API / `speechSynthesis`)**:
|
- [ ] **Нативная автономная озвучка (HTML5 Web Speech API / `speechSynthesis`)**:
|
||||||
- Использовать встроенный в браузер синтезатор речи `window.speechSynthesis` (en-US, en-GB).
|
- Использовать встроенный в браузер синтезатор речи `window.speechSynthesis` (en-US, en-GB) как дополнительный резервный фолбэк при работе на мобильных без связи.
|
||||||
- 0 сетевых запросов к серверу, 100% оффлайн-работа.
|
- [ ] **Расширение библиотеки Murphy**:
|
||||||
- Поддержка озвучивания отдельных слов, фраз, идиом и предложений-контекстов читалки.
|
- Добавление следующих 15 юнитов грамматики (юниты 16–30: пассивный залог, косвенная речь, модальные глаголы).
|
||||||
- [ ] **Возврат режима «Диктант» в SRS**:
|
|
||||||
- Возобновление работы аудиотренажёра на базе нативного браузерного синтезатора речи без зависимости от внешних аудиофайлов.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ async def translate_sentence_ai(
|
|||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"keep_alive": "24h",
|
"keep_alive": config.OLLAMA_KEEP_ALIVE,
|
||||||
"options": {
|
"options": {
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"num_predict": 300,
|
"num_predict": 300,
|
||||||
@@ -367,10 +367,10 @@ async def warmup_ollama_model() -> bool:
|
|||||||
client = get_ollama_client()
|
client = get_ollama_client()
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{config.OLLAMA_BASE_URL}/api/generate",
|
f"{config.OLLAMA_BASE_URL}/api/generate",
|
||||||
json={"model": config.OLLAMA_MODEL, "keep_alive": "24h"},
|
json={"model": config.OLLAMA_MODEL, "keep_alive": config.OLLAMA_KEEP_ALIVE},
|
||||||
)
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
logger.info(f"Ollama model '{config.OLLAMA_MODEL}' warmed up successfully (keep_alive=24h).")
|
logger.info(f"Ollama model '{config.OLLAMA_MODEL}' warmed up successfully (keep_alive={config.OLLAMA_KEEP_ALIVE}).")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Ollama warmup returned HTTP {resp.status_code}: {resp.text}")
|
logger.warning(f"Ollama warmup returned HTTP {resp.status_code}: {resp.text}")
|
||||||
@@ -567,7 +567,7 @@ async def generate_lesson_ai_debrief(
|
|||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"keep_alive": "24h",
|
"keep_alive": config.OLLAMA_KEEP_ALIVE,
|
||||||
"options": {
|
"options": {
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
"num_predict": 450,
|
"num_predict": 450,
|
||||||
@@ -576,7 +576,7 @@ async def generate_lesson_ai_debrief(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
client = get_ollama_client()
|
client = get_ollama_client()
|
||||||
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload, timeout=25.0)
|
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload, timeout=10.0)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
raw_response = resp.json().get("response", "{}")
|
raw_response = resp.json().get("response", "{}")
|
||||||
# Parse JSON
|
# Parse JSON
|
||||||
|
|||||||
@@ -43,4 +43,5 @@ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|||||||
|
|
||||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
|
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
|
||||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:3b")
|
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:3b")
|
||||||
|
OLLAMA_KEEP_ALIVE = os.getenv("OLLAMA_KEEP_ALIVE", "1h")
|
||||||
|
|
||||||
|
|||||||
@@ -331,6 +331,8 @@ def _migrate(conn: sqlite3.Connection):
|
|||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
subtitle TEXT DEFAULT '',
|
subtitle TEXT DEFAULT '',
|
||||||
golden_formula TEXT NOT NULL,
|
golden_formula TEXT NOT NULL,
|
||||||
|
cheatsheet_table TEXT DEFAULT '',
|
||||||
|
detailed_notes TEXT DEFAULT '',
|
||||||
contrast_explanation_json TEXT NOT NULL,
|
contrast_explanation_json TEXT NOT NULL,
|
||||||
journaling_prompt TEXT NOT NULL,
|
journaling_prompt TEXT NOT NULL,
|
||||||
exercises_json TEXT NOT NULL,
|
exercises_json TEXT NOT NULL,
|
||||||
@@ -340,6 +342,18 @@ def _migrate(conn: sqlite3.Connection):
|
|||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_grammar_units_num ON grammar_units(unit_num);")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_grammar_units_num ON grammar_units(unit_num);")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_grammar_units_level ON grammar_units(level);")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_grammar_units_level ON grammar_units(level);")
|
||||||
|
|
||||||
|
# Safe migration: add cheatsheet_table and detailed_notes columns if not present
|
||||||
|
existing_grammar_cols = {
|
||||||
|
row[1] for row in conn.execute("PRAGMA table_info(grammar_units)").fetchall()
|
||||||
|
}
|
||||||
|
grammar_migrations = [
|
||||||
|
("cheatsheet_table", "TEXT DEFAULT ''"),
|
||||||
|
("detailed_notes", "TEXT DEFAULT ''"),
|
||||||
|
]
|
||||||
|
for col, definition in grammar_migrations:
|
||||||
|
if col not in existing_grammar_cols:
|
||||||
|
conn.execute(f"ALTER TABLE grammar_units ADD COLUMN {col} {definition}")
|
||||||
|
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS grammar_progress (
|
CREATE TABLE IF NOT EXISTS grammar_progress (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -419,6 +433,24 @@ def _migrate(conn: sqlite3.Connection):
|
|||||||
""")
|
""")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_lesson_sess_user_date ON lesson_sessions(user_id, completed_at DESC);")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_lesson_sess_user_date ON lesson_sessions(user_id, completed_at DESC);")
|
||||||
|
|
||||||
|
# Ensure feedback_reports table exists (User In-App Bug & Feature Tracker)
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback_reports (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'bug',
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
context_url TEXT DEFAULT '',
|
||||||
|
context_data_json TEXT DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL DEFAULT 'new',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_feedback_user ON feedback_reports(user_id);")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_feedback_status ON feedback_reports(status);")
|
||||||
|
|
||||||
# Repair legacy cards with placeholder blanks or mismatched context
|
# Repair legacy cards with placeholder blanks or mismatched context
|
||||||
repair_mismatched_cefr_contexts(conn)
|
repair_mismatched_cefr_contexts(conn)
|
||||||
|
|
||||||
|
|||||||
+128
-4
@@ -171,11 +171,11 @@ async def fetch_wiktionary_definitions(word: str, client: Optional[httpx.AsyncCl
|
|||||||
return {"parts_of_speech": [], "definitions": [], "examples": []}
|
return {"parts_of_speech": [], "definitions": [], "examples": []}
|
||||||
|
|
||||||
|
|
||||||
async def fetch_mymemory(query: str, client: Optional[httpx.AsyncClient] = None) -> Dict[str, Any]:
|
async def fetch_mymemory(query: str, langpair: str = "en|ru", client: Optional[httpx.AsyncClient] = None) -> Dict[str, Any]:
|
||||||
url = "https://api.mymemory.translated.net/get"
|
url = "https://api.mymemory.translated.net/get"
|
||||||
params = {
|
params = {
|
||||||
"q": query,
|
"q": query,
|
||||||
"langpair": "en|ru",
|
"langpair": langpair,
|
||||||
"de": config.MYMEMORY_EMAIL
|
"de": config.MYMEMORY_EMAIL
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
@@ -294,11 +294,132 @@ async def lookup_term(query: str) -> Dict[str, Any]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Check offline dictionary first (Zero Daemon, < 0.1ms via SQLite mmap)
|
is_cyrillic = bool(re.search(r"[\u0400-\u04FF]", query_clean))
|
||||||
words = query_clean.split()
|
words = query_clean.split()
|
||||||
is_sentence = len(words) > 4
|
is_sentence = len(words) > 4
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Reverse Translation (Russian -> English)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
if is_cyrillic:
|
||||||
|
matched_term = None
|
||||||
|
phonetic = ""
|
||||||
|
pos = ""
|
||||||
|
clean_trans = query_clean
|
||||||
|
tr_list = [query_clean]
|
||||||
|
is_single = len(words) == 1
|
||||||
|
|
||||||
|
# 1. Search offline dictionary for exact reverse matches
|
||||||
if not is_sentence:
|
if not is_sentence:
|
||||||
try:
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE translation LIKE ?",
|
||||||
|
(f"%{query_clean}%",)
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
exact_matches = []
|
||||||
|
for r in rows:
|
||||||
|
raw_t = r["translation"] or ""
|
||||||
|
parts = [p.strip().lower() for p in raw_t.split(",")]
|
||||||
|
if query_clean.lower() in parts:
|
||||||
|
w_cnt = len(r["term"].split())
|
||||||
|
is_multi = 1 if (is_single and w_cnt > 1) else 0
|
||||||
|
is_inflection = 1 if " от " in raw_t else 0
|
||||||
|
exact_matches.append((is_multi, is_inflection, w_cnt, len(r["term"]), r))
|
||||||
|
if exact_matches:
|
||||||
|
exact_matches.sort(key=lambda x: (x[0], x[1], x[2], x[3]))
|
||||||
|
# Prefer single-word offline match if query was single word
|
||||||
|
if exact_matches[0][0] == 0:
|
||||||
|
best_row = exact_matches[0][4]
|
||||||
|
matched_term = best_row["term"]
|
||||||
|
phonetic = best_row["phonetic"] or ""
|
||||||
|
pos = best_row["pos"] or ""
|
||||||
|
clean_trans = clean_russian_translation(best_row["translation"] or "")
|
||||||
|
tr_list = [t.strip() for t in clean_trans.split(",") if t.strip()]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. If no direct offline match, query MyMemory with langpair="ru|en"
|
||||||
|
http_client = get_http_client()
|
||||||
|
if not matched_term:
|
||||||
|
try:
|
||||||
|
mm_data = await fetch_mymemory(query_clean, langpair="ru|en", client=http_client)
|
||||||
|
raw_cands = []
|
||||||
|
if mm_data.get("translation"):
|
||||||
|
raw_cands.append(mm_data["translation"])
|
||||||
|
for m in mm_data.get("matches", []):
|
||||||
|
if m and m not in raw_cands and m.lower() != query_clean.lower():
|
||||||
|
raw_cands.append(m)
|
||||||
|
|
||||||
|
cleaned_cands = []
|
||||||
|
for c in raw_cands:
|
||||||
|
c_clean = re.sub(r"^(?:to|an?|the)\s+", "", c.strip(), flags=re.IGNORECASE).strip(" *.,!?\"'")
|
||||||
|
if c_clean and c_clean not in cleaned_cands and not re.search(r"[\u0400-\u04FF]", c_clean):
|
||||||
|
cleaned_cands.append(c_clean)
|
||||||
|
|
||||||
|
if cleaned_cands:
|
||||||
|
matched_term = cleaned_cands[0]
|
||||||
|
# Enrich from offline dictionary if candidate exists
|
||||||
|
with get_db() as conn:
|
||||||
|
for cand in cleaned_cands:
|
||||||
|
off_row = conn.execute(
|
||||||
|
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
|
||||||
|
(cand,)
|
||||||
|
).fetchone()
|
||||||
|
if off_row:
|
||||||
|
matched_term = off_row["term"]
|
||||||
|
phonetic = off_row["phonetic"] or ""
|
||||||
|
pos = off_row["pos"] or ""
|
||||||
|
if not clean_trans or clean_trans == query_clean:
|
||||||
|
clean_trans = clean_russian_translation(off_row["translation"] or "") or query_clean
|
||||||
|
tr_list = [t.strip() for t in clean_trans.split(",") if t.strip()]
|
||||||
|
break
|
||||||
|
|
||||||
|
# If phonetic still missing and candidate is a single word, fetch from free dictionary API
|
||||||
|
if not phonetic and len(matched_term.split()) == 1:
|
||||||
|
fd = await fetch_free_dictionary_api(matched_term, client=http_client)
|
||||||
|
if fd.get("phonetic"):
|
||||||
|
phonetic = fd["phonetic"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not matched_term:
|
||||||
|
matched_term = query_clean
|
||||||
|
|
||||||
|
res = {
|
||||||
|
"query": query_clean,
|
||||||
|
"term": matched_term,
|
||||||
|
"english_term": matched_term,
|
||||||
|
"russian_query": query_clean,
|
||||||
|
"is_sentence": is_sentence,
|
||||||
|
"is_reverse": True,
|
||||||
|
"translation": clean_trans,
|
||||||
|
"translations_list": tr_list if tr_list else [clean_trans],
|
||||||
|
"translation_ru": clean_trans,
|
||||||
|
"phonetic": phonetic,
|
||||||
|
"audio_file": "",
|
||||||
|
"audio_url": "",
|
||||||
|
"part_of_speech": pos or ("Sentence" if is_sentence else "Word"),
|
||||||
|
"definitions": [],
|
||||||
|
"examples": [],
|
||||||
|
"source": "reverse_dictionary",
|
||||||
|
}
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO dictionary_cache (query, response_json) VALUES (?, ?)",
|
||||||
|
(query_clean, json.dumps(res, ensure_ascii=False))
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Forward Translation (English -> Russian)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Check offline dictionary first (Zero Daemon, < 0.1ms via SQLite mmap)
|
||||||
|
if not is_sentence:
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
off_row = conn.execute(
|
off_row = conn.execute(
|
||||||
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
|
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
|
||||||
(query_clean,)
|
(query_clean,)
|
||||||
@@ -329,7 +450,9 @@ async def lookup_term(query: str) -> Dict[str, Any]:
|
|||||||
tr_list = [t.strip() for t in clean_trans.split(",") if t.strip()]
|
tr_list = [t.strip() for t in clean_trans.split(",") if t.strip()]
|
||||||
res = {
|
res = {
|
||||||
"query": query_clean,
|
"query": query_clean,
|
||||||
|
"term": off_row["term"] or query_clean,
|
||||||
"is_sentence": False,
|
"is_sentence": False,
|
||||||
|
"is_reverse": False,
|
||||||
"translation": clean_trans,
|
"translation": clean_trans,
|
||||||
"translations_list": tr_list,
|
"translations_list": tr_list,
|
||||||
"translation_ru": clean_trans,
|
"translation_ru": clean_trans,
|
||||||
@@ -349,10 +472,11 @@ async def lookup_term(query: str) -> Dict[str, Any]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
result: Dict[str, Any] = {
|
result: Dict[str, Any] = {
|
||||||
"query": query_clean,
|
"query": query_clean,
|
||||||
|
"term": query_clean,
|
||||||
"is_sentence": is_sentence,
|
"is_sentence": is_sentence,
|
||||||
|
"is_reverse": False,
|
||||||
"translation": "",
|
"translation": "",
|
||||||
"translations_list": [],
|
"translations_list": [],
|
||||||
"phonetic": "",
|
"phonetic": "",
|
||||||
|
|||||||
+48
-14
@@ -51,7 +51,7 @@ CURATED_MURPHY_UNITS = _LazyUnitsList()
|
|||||||
def seed_grammar_units(conn: sqlite3.Connection) -> int:
|
def seed_grammar_units(conn: sqlite3.Connection) -> int:
|
||||||
"""
|
"""
|
||||||
Populates grammar_units table with curated Murphy units.
|
Populates grammar_units table with curated Murphy units.
|
||||||
Safe and idempotent (INSERT OR IGNORE).
|
Safe, idempotent upsert (ON CONFLICT DO UPDATE).
|
||||||
"""
|
"""
|
||||||
inserted = 0
|
inserted = 0
|
||||||
for u in CURATED_MURPHY_UNITS:
|
for u in CURATED_MURPHY_UNITS:
|
||||||
@@ -59,10 +59,22 @@ def seed_grammar_units(conn: sqlite3.Connection) -> int:
|
|||||||
exercises_json = json.dumps(u["exercises"], ensure_ascii=False)
|
exercises_json = json.dumps(u["exercises"], ensure_ascii=False)
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO grammar_units (
|
INSERT INTO grammar_units (
|
||||||
unit_num, level, category, title, subtitle, golden_formula,
|
unit_num, level, category, title, subtitle, golden_formula,
|
||||||
|
cheatsheet_table, detailed_notes,
|
||||||
contrast_explanation_json, journaling_prompt, exercises_json
|
contrast_explanation_json, journaling_prompt, exercises_json
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(unit_num) DO UPDATE SET
|
||||||
|
level = excluded.level,
|
||||||
|
category = excluded.category,
|
||||||
|
title = excluded.title,
|
||||||
|
subtitle = excluded.subtitle,
|
||||||
|
golden_formula = excluded.golden_formula,
|
||||||
|
cheatsheet_table = excluded.cheatsheet_table,
|
||||||
|
detailed_notes = excluded.detailed_notes,
|
||||||
|
contrast_explanation_json = excluded.contrast_explanation_json,
|
||||||
|
journaling_prompt = excluded.journaling_prompt,
|
||||||
|
exercises_json = excluded.exercises_json
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
u["unit_num"],
|
u["unit_num"],
|
||||||
@@ -71,6 +83,8 @@ def seed_grammar_units(conn: sqlite3.Connection) -> int:
|
|||||||
u["title"],
|
u["title"],
|
||||||
u.get("subtitle", ""),
|
u.get("subtitle", ""),
|
||||||
u["golden_formula"],
|
u["golden_formula"],
|
||||||
|
u.get("cheatsheet_table", ""),
|
||||||
|
u.get("detailed_notes", ""),
|
||||||
contrast_json,
|
contrast_json,
|
||||||
u["journaling_prompt"],
|
u["journaling_prompt"],
|
||||||
exercises_json,
|
exercises_json,
|
||||||
@@ -117,18 +131,22 @@ def get_grammar_units_list(conn: sqlite3.Connection, user_id: int) -> List[Dict[
|
|||||||
def get_grammar_unit_detail(conn: sqlite3.Connection, user_id: int, unit_id: int) -> Optional[Dict[str, Any]]:
|
def get_grammar_unit_detail(conn: sqlite3.Connection, user_id: int, unit_id: int) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Returns full content of a unit for lesson mode.
|
Returns full content of a unit for lesson mode.
|
||||||
|
Supports looking up by database PK or unit_num.
|
||||||
"""
|
"""
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
u.id, u.unit_num, u.level, u.category, u.title, u.subtitle,
|
u.id, u.unit_num, u.level, u.category, u.title, u.subtitle,
|
||||||
u.golden_formula, u.contrast_explanation_json, u.journaling_prompt, u.exercises_json,
|
u.golden_formula, u.cheatsheet_table, u.detailed_notes,
|
||||||
|
u.contrast_explanation_json, u.journaling_prompt, u.exercises_json,
|
||||||
p.is_completed, p.score, p.handwritten_logged, p.completed_at
|
p.is_completed, p.score, p.handwritten_logged, p.completed_at
|
||||||
FROM grammar_units u
|
FROM grammar_units u
|
||||||
LEFT JOIN grammar_progress p ON u.id = p.unit_id AND p.user_id = ?
|
LEFT JOIN grammar_progress p ON u.id = p.unit_id AND p.user_id = ?
|
||||||
WHERE u.id = ?
|
WHERE u.id = ? OR u.unit_num = ?
|
||||||
|
ORDER BY (u.id = ?) DESC
|
||||||
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(user_id, unit_id),
|
(user_id, unit_id, unit_id, unit_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
@@ -162,9 +180,12 @@ def get_grammar_unit_detail(conn: sqlite3.Connection, user_id: int, unit_id: int
|
|||||||
"level": row["level"],
|
"level": row["level"],
|
||||||
"category": row["category"],
|
"category": row["category"],
|
||||||
"title": row["title"],
|
"title": row["title"],
|
||||||
"subtitle": row["subtitle"],
|
"subtitle": row["subtitle"] or "",
|
||||||
"golden_formula": row["golden_formula"],
|
"golden_formula": row["golden_formula"],
|
||||||
|
"cheatsheet_table": row["cheatsheet_table"] or "",
|
||||||
|
"detailed_notes": row["detailed_notes"] or "",
|
||||||
"contrasts": contrasts,
|
"contrasts": contrasts,
|
||||||
|
"contrast_explanation": contrasts,
|
||||||
"journaling_prompt": row["journaling_prompt"],
|
"journaling_prompt": row["journaling_prompt"],
|
||||||
"exercises": exercises,
|
"exercises": exercises,
|
||||||
"is_completed": bool(row["is_completed"]),
|
"is_completed": bool(row["is_completed"]),
|
||||||
@@ -187,7 +208,8 @@ def process_grammar_answer(
|
|||||||
with tags 'grammar, murphy-unit-{unit_num}' and srs_state='learning'.
|
with tags 'grammar, murphy-unit-{unit_num}' and srs_state='learning'.
|
||||||
"""
|
"""
|
||||||
unit_row = conn.execute(
|
unit_row = conn.execute(
|
||||||
"SELECT * FROM grammar_units WHERE id = ?", (unit_id,)
|
"SELECT * FROM grammar_units WHERE id = ? OR unit_num = ? ORDER BY (id = ?) DESC LIMIT 1",
|
||||||
|
(unit_id, unit_id, unit_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not unit_row:
|
if not unit_row:
|
||||||
@@ -293,9 +315,15 @@ def record_grammar_completion(
|
|||||||
"""
|
"""
|
||||||
Saves unit completion score and handwritten journaling status.
|
Saves unit completion score and handwritten journaling status.
|
||||||
"""
|
"""
|
||||||
|
u_row = conn.execute(
|
||||||
|
"SELECT id FROM grammar_units WHERE id = ? OR unit_num = ? ORDER BY (id = ?) DESC LIMIT 1",
|
||||||
|
(unit_id, unit_id, unit_id),
|
||||||
|
).fetchone()
|
||||||
|
real_unit_id = u_row["id"] if u_row else unit_id
|
||||||
|
|
||||||
prog_row = conn.execute(
|
prog_row = conn.execute(
|
||||||
"SELECT id FROM grammar_progress WHERE user_id = ? AND unit_id = ?",
|
"SELECT id FROM grammar_progress WHERE user_id = ? AND unit_id = ?",
|
||||||
(user_id, unit_id),
|
(user_id, real_unit_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
hw_val = 1 if handwritten_logged else 0
|
hw_val = 1 if handwritten_logged else 0
|
||||||
@@ -320,10 +348,10 @@ def record_grammar_completion(
|
|||||||
user_id, unit_id, is_completed, score, handwritten_logged, handwritten_at, completed_at
|
user_id, unit_id, is_completed, score, handwritten_logged, handwritten_at, completed_at
|
||||||
) VALUES (?, ?, 1, ?, ?, CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END, CURRENT_TIMESTAMP)
|
) VALUES (?, ?, 1, ?, ?, CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END, CURRENT_TIMESTAMP)
|
||||||
""",
|
""",
|
||||||
(user_id, unit_id, score, hw_val, hw_val),
|
(user_id, real_unit_id, score, hw_val, hw_val),
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": "ok", "unit_id": unit_id, "score": score, "handwritten_logged": handwritten_logged}
|
return {"status": "ok", "unit_id": real_unit_id, "score": score, "handwritten_logged": handwritten_logged}
|
||||||
|
|
||||||
|
|
||||||
def update_handwritten_status(
|
def update_handwritten_status(
|
||||||
@@ -335,10 +363,16 @@ def update_handwritten_status(
|
|||||||
"""
|
"""
|
||||||
Toggles handwritten paper notebook checkbox for a unit.
|
Toggles handwritten paper notebook checkbox for a unit.
|
||||||
"""
|
"""
|
||||||
|
u_row = conn.execute(
|
||||||
|
"SELECT id FROM grammar_units WHERE id = ? OR unit_num = ? ORDER BY (id = ?) DESC LIMIT 1",
|
||||||
|
(unit_id, unit_id, unit_id),
|
||||||
|
).fetchone()
|
||||||
|
real_unit_id = u_row["id"] if u_row else unit_id
|
||||||
|
|
||||||
hw_val = 1 if handwritten else 0
|
hw_val = 1 if handwritten else 0
|
||||||
prog_row = conn.execute(
|
prog_row = conn.execute(
|
||||||
"SELECT id FROM grammar_progress WHERE user_id = ? AND unit_id = ?",
|
"SELECT id FROM grammar_progress WHERE user_id = ? AND unit_id = ?",
|
||||||
(user_id, unit_id),
|
(user_id, real_unit_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if prog_row:
|
if prog_row:
|
||||||
@@ -358,7 +392,7 @@ def update_handwritten_status(
|
|||||||
user_id, unit_id, is_completed, score, handwritten_logged, handwritten_at
|
user_id, unit_id, is_completed, score, handwritten_logged, handwritten_at
|
||||||
) VALUES (?, ?, 0, 0, ?, CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END)
|
) VALUES (?, ?, 0, 0, ?, CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END)
|
||||||
""",
|
""",
|
||||||
(user_id, unit_id, hw_val, hw_val),
|
(user_id, real_unit_id, hw_val, hw_val),
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": "ok", "unit_id": unit_id, "handwritten_logged": handwritten}
|
return {"status": "ok", "unit_id": real_unit_id, "handwritten_logged": handwritten}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.routers.grammar import GrammarAnswerRequest, GrammarCompleteRequest, Gr
|
|||||||
from app.routers.tasks import MineBooksRequest
|
from app.routers.tasks import MineBooksRequest
|
||||||
from app.routers.listening import ListeningCheckRequest
|
from app.routers.listening import ListeningCheckRequest
|
||||||
from app.routers.lessons import SavePresetRequest, LessonDebriefRequest
|
from app.routers.lessons import SavePresetRequest, LessonDebriefRequest
|
||||||
|
from app.routers.feedback import FeedbackCreateRequest, FeedbackStatusUpdateRequest
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|||||||
+5
-1
@@ -1182,6 +1182,10 @@ def clean_wiki_html(raw_html: str, article_title: str = "") -> Tuple[List[Dict[s
|
|||||||
body = re.sub(r'<script\b[^>]*>.*?</script>', '', body, flags=re.DOTALL | re.IGNORECASE)
|
body = re.sub(r'<script\b[^>]*>.*?</script>', '', body, flags=re.DOTALL | re.IGNORECASE)
|
||||||
body = re.sub(r'<noscript\b[^>]*>.*?</noscript>', '', body, flags=re.DOTALL | re.IGNORECASE)
|
body = re.sub(r'<noscript\b[^>]*>.*?</noscript>', '', body, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
|
||||||
|
# Pre-strip MediaWiki Parsoid metadata attributes (data-mw, data-parsoid, about)
|
||||||
|
# These contain raw template JSON with <ref> tags and literal '>' characters that break tag regexes
|
||||||
|
body = re.sub(r'''\s+(?:data-mw|data-parsoid|about)=(?:"[^"]*"|'[^']*')''', '', body, flags=re.DOTALL)
|
||||||
|
|
||||||
# 3. Strip unwanted wiki elements
|
# 3. Strip unwanted wiki elements
|
||||||
# Remove references: <sup class="reference">...</sup> and bracketed reference markers
|
# Remove references: <sup class="reference">...</sup> and bracketed reference markers
|
||||||
body = re.sub(r'<sup\b[^>]*class=["\x27][^"\x27]*reference[^"\x27]*["\x27][^>]*>.*?</sup>', '', body, flags=re.DOTALL | re.IGNORECASE)
|
body = re.sub(r'<sup\b[^>]*class=["\x27][^"\x27]*reference[^"\x27]*["\x27][^>]*>.*?</sup>', '', body, flags=re.DOTALL | re.IGNORECASE)
|
||||||
@@ -1229,7 +1233,7 @@ def clean_wiki_html(raw_html: str, article_title: str = "") -> Tuple[List[Dict[s
|
|||||||
if re.search(r'class=["\x27][^"\x27]*(?:navbox|ambox|metadata|nowraplinks|infobox)[^"\x27]*["\x27]', tbl_html, re.IGNORECASE):
|
if re.search(r'class=["\x27][^"\x27]*(?:navbox|ambox|metadata|nowraplinks|infobox)[^"\x27]*["\x27]', tbl_html, re.IGNORECASE):
|
||||||
return ""
|
return ""
|
||||||
cleaned_tbl = re.sub(
|
cleaned_tbl = re.sub(
|
||||||
r'\s+(?:style|bgcolor|align|valign|width|height|border|cellspacing|cellpadding|id)=["\x27][^"\x27]*["\x27]',
|
r'\s+(?:style|bgcolor|align|valign|width|height|border|cellspacing|cellpadding|id)=(?:"[^"]*"|\'[^\']*\')',
|
||||||
'',
|
'',
|
||||||
tbl_html,
|
tbl_html,
|
||||||
flags=re.IGNORECASE
|
flags=re.IGNORECASE
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.routers.cefr import router as cefr_router
|
|||||||
from app.routers.grammar import router as grammar_router
|
from app.routers.grammar import router as grammar_router
|
||||||
from app.routers.tasks import router as tasks_router
|
from app.routers.tasks import router as tasks_router
|
||||||
from app.routers.lessons import router as lessons_router
|
from app.routers.lessons import router as lessons_router
|
||||||
|
from app.routers.feedback import router as feedback_router
|
||||||
|
|
||||||
all_routers = [
|
all_routers = [
|
||||||
auth_router,
|
auth_router,
|
||||||
@@ -22,6 +23,7 @@ all_routers = [
|
|||||||
grammar_router,
|
grammar_router,
|
||||||
tasks_router,
|
tasks_router,
|
||||||
lessons_router,
|
lessons_router,
|
||||||
|
feedback_router,
|
||||||
]
|
]
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -37,4 +39,5 @@ __all__ = [
|
|||||||
"grammar_router",
|
"grammar_router",
|
||||||
"tasks_router",
|
"tasks_router",
|
||||||
"lessons_router",
|
"lessons_router",
|
||||||
|
"feedback_router",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -38,11 +38,15 @@ async def api_lookup(
|
|||||||
raise HTTPException(status_code=400, detail="Query term cannot be empty")
|
raise HTTPException(status_code=400, detail="Query term cannot be empty")
|
||||||
data = await lookup_term(query_str)
|
data = await lookup_term(query_str)
|
||||||
|
|
||||||
|
# Ensure term is always present in response
|
||||||
|
target_term = data.get("term") or data.get("english_term") or query_str
|
||||||
|
data["term"] = target_term
|
||||||
|
|
||||||
# Check if this term already exists in user's dictionary
|
# Check if this term already exists in user's dictionary
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT id, term, translation, context_sentence, tags, collection, srs_state, srs_reps FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))",
|
"SELECT id, term, translation, context_sentence, tags, collection, srs_state, srs_reps FROM vocabulary_cards WHERE user_id = ? AND (lower(trim(term)) = lower(trim(?)) OR lower(trim(term)) = lower(trim(?)))",
|
||||||
(user["id"], query_str),
|
(user["id"], target_term, query_str),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
data["in_library"] = True
|
data["in_library"] = True
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.auth import require_user
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/feedback", tags=["Feedback & Bug Tracker"])
|
||||||
|
|
||||||
|
# Paths to markdown reports files for seamless Antigravity AI reading
|
||||||
|
DATA_REPORTS_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "USER_REPORTS.md"
|
||||||
|
MEMORY_REPORTS_FILE = Path("/home/ai-projects/memory/USER_REPORTS.md")
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCreateRequest(BaseModel):
|
||||||
|
category: str = Field(default="bug", description="Category: 'bug', 'feature', 'note'")
|
||||||
|
message: str = Field(..., min_length=1, max_length=5000)
|
||||||
|
context_url: Optional[str] = Field(default="", max_length=500)
|
||||||
|
context_data: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackStatusUpdateRequest(BaseModel):
|
||||||
|
status: str = Field(..., pattern="^(new|in_progress|resolved|closed)$")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_reports_to_markdown(conn):
|
||||||
|
"""
|
||||||
|
Synchronizes feedback_reports from SQLite to /home/ai-projects/memory/USER_REPORTS.md.
|
||||||
|
This enables Antigravity and AI agents to instantly inspect user-reported bugs and feature requests.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT f.id, f.category, f.message, f.context_url, f.context_data_json, f.status, f.created_at, f.updated_at, u.username
|
||||||
|
FROM feedback_reports f
|
||||||
|
JOIN users u ON f.user_id = u.id
|
||||||
|
ORDER BY f.id DESC
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# 📋 Пользовательские репорты & Запросы на доработку (study.qpid.pro)",
|
||||||
|
"",
|
||||||
|
"> 🔄 Данный файл автоматически синхронизируется с виджетом обратной связи на сайте.",
|
||||||
|
"> AI-ассистент (Antigravity) считывает этот файл для анализа найденных багов и планирования задач.",
|
||||||
|
"",
|
||||||
|
f"**Всего репортов:** {len(rows)} | **Последняя синхронизация:** сейчас",
|
||||||
|
"",
|
||||||
|
"## 🔴 Активные репорты (Требуют внимания / В работе)",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
active_rows = [r for r in rows if r["status"] in ("new", "in_progress")]
|
||||||
|
resolved_rows = [r for r in rows if r["status"] in ("resolved", "closed")]
|
||||||
|
|
||||||
|
if not active_rows:
|
||||||
|
lines.append("🎉 *Активных багов или задач нет! Все известные репорты закрыты.*")
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
for r in active_rows:
|
||||||
|
cat_emoji = "🐛 БАГ" if r["category"] == "bug" else ("💡 ФИЧА" if r["category"] == "feature" else "📝 ЗАМЕТКА")
|
||||||
|
status_icon = "🆕 Новый" if r["status"] == "new" else "⏳ В работе"
|
||||||
|
|
||||||
|
ctx_extra = ""
|
||||||
|
if r["context_data_json"]:
|
||||||
|
try:
|
||||||
|
c_dict = json.loads(r["context_data_json"])
|
||||||
|
parts = []
|
||||||
|
if "screen" in c_dict: parts.append(f"Экран: {c_dict['screen']}")
|
||||||
|
if "device" in c_dict: parts.append(f"Устройство: {c_dict['device']}")
|
||||||
|
if parts: ctx_extra = f" ({', '.join(parts)})"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lines.append(f"### #{r['id']} [{cat_emoji}] — {status_icon} (`{r['created_at']}`)")
|
||||||
|
lines.append(f"- **Пользователь**: `{r['username']}`")
|
||||||
|
if r["context_url"]:
|
||||||
|
lines.append(f"- **Раздел/Страница**: `{r['context_url']}`{ctx_extra}")
|
||||||
|
lines.append(f"- **Сообщение**:\n> {r['message'].strip()}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"## ✅ Решенные / Закрытые задачи",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
if not resolved_rows:
|
||||||
|
lines.append("*Пока нет закрытых репортов.*")
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
for r in resolved_rows:
|
||||||
|
cat_emoji = "🐛" if r["category"] == "bug" else ("💡" if r["category"] == "feature" else "📝")
|
||||||
|
lines.append(f"- [x] **#{r['id']} {cat_emoji} ({r['created_at']})**: {r['message'].strip()} *(Страница: {r['context_url'] or 'общая'})*")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
content = "\n".join(lines)
|
||||||
|
for target in [DATA_REPORTS_FILE, MEMORY_REPORTS_FILE]:
|
||||||
|
try:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(content, encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not write to {target}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not sync reports to markdown: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[Dict[str, Any]])
|
||||||
|
def list_feedback(
|
||||||
|
category: Optional[str] = Query(None),
|
||||||
|
status_filter: Optional[str] = Query(None, alias="status"),
|
||||||
|
user: dict = Depends(require_user),
|
||||||
|
):
|
||||||
|
"""Lists feedback reports. Admins see all reports; regular users see their own."""
|
||||||
|
is_admin = bool(user.get("is_admin"))
|
||||||
|
with get_db() as conn:
|
||||||
|
query = """
|
||||||
|
SELECT f.id, f.user_id, f.category, f.message, f.context_url, f.context_data_json, f.status, f.created_at, f.updated_at, u.username
|
||||||
|
FROM feedback_reports f
|
||||||
|
JOIN users u ON f.user_id = u.id
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
if not is_admin:
|
||||||
|
query += " AND f.user_id = ?"
|
||||||
|
params.append(user["id"])
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query += " AND f.category = ?"
|
||||||
|
params.append(category)
|
||||||
|
|
||||||
|
if status_filter:
|
||||||
|
query += " AND f.status = ?"
|
||||||
|
params.append(status_filter)
|
||||||
|
|
||||||
|
query += " ORDER BY f.id DESC LIMIT 100"
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
ctx_data = {}
|
||||||
|
if r["context_data_json"]:
|
||||||
|
try:
|
||||||
|
ctx_data = json.loads(r["context_data_json"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"id": r["id"],
|
||||||
|
"user_id": r["user_id"],
|
||||||
|
"username": r["username"],
|
||||||
|
"category": r["category"],
|
||||||
|
"message": r["message"],
|
||||||
|
"context_url": r["context_url"],
|
||||||
|
"context_data": ctx_data,
|
||||||
|
"status": r["status"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"updated_at": r["updated_at"],
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_feedback(
|
||||||
|
payload: FeedbackCreateRequest,
|
||||||
|
user: dict = Depends(require_user),
|
||||||
|
):
|
||||||
|
"""Submits a new bug, feature request, or note from the website chat widget."""
|
||||||
|
allowed_categories = {"bug", "feature", "note"}
|
||||||
|
category = payload.category if payload.category in allowed_categories else "bug"
|
||||||
|
ctx_json = json.dumps(payload.context_data or {}, ensure_ascii=False)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO feedback_reports (user_id, category, message, context_url, context_data_json, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'new')
|
||||||
|
""",
|
||||||
|
(user["id"], category, payload.message.strip(), payload.context_url or "", ctx_json),
|
||||||
|
)
|
||||||
|
report_id = cursor.lastrowid
|
||||||
|
sync_reports_to_markdown(conn)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"id": report_id,
|
||||||
|
"category": category,
|
||||||
|
"status": "new",
|
||||||
|
"message": "Репорт успешно отправлен и передан в систему задач",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{report_id}")
|
||||||
|
def update_feedback_status(
|
||||||
|
report_id: int,
|
||||||
|
payload: FeedbackStatusUpdateRequest,
|
||||||
|
user: dict = Depends(require_user),
|
||||||
|
):
|
||||||
|
"""Updates the status of a report (e.g. 'in_progress', 'resolved')."""
|
||||||
|
with get_db() as conn:
|
||||||
|
existing = conn.execute("SELECT id, user_id FROM feedback_reports WHERE id = ?", (report_id,)).fetchone()
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Репорт не найден")
|
||||||
|
|
||||||
|
is_admin = bool(user.get("is_admin"))
|
||||||
|
if not is_admin and existing["user_id"] != user["id"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE feedback_reports SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
(payload.status, report_id),
|
||||||
|
)
|
||||||
|
sync_reports_to_markdown(conn)
|
||||||
|
|
||||||
|
return {"ok": True, "id": report_id, "status": payload.status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{report_id}")
|
||||||
|
def delete_feedback(
|
||||||
|
report_id: int,
|
||||||
|
user: dict = Depends(require_user),
|
||||||
|
):
|
||||||
|
"""Deletes a report."""
|
||||||
|
with get_db() as conn:
|
||||||
|
existing = conn.execute("SELECT id, user_id FROM feedback_reports WHERE id = ?", (report_id,)).fetchone()
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Репорт не найден")
|
||||||
|
|
||||||
|
is_admin = bool(user.get("is_admin"))
|
||||||
|
if not is_admin and existing["user_id"] != user["id"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM feedback_reports WHERE id = ?", (report_id,))
|
||||||
|
sync_reports_to_markdown(conn)
|
||||||
|
|
||||||
|
return {"ok": True, "id": report_id}
|
||||||
+18
-2
@@ -24,6 +24,7 @@ async def api_srs_queue(
|
|||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
limit: int = 25,
|
limit: int = 25,
|
||||||
mix: bool = False,
|
mix: bool = False,
|
||||||
|
exclude_grammar: bool = True,
|
||||||
user: dict = Depends(require_user),
|
user: dict = Depends(require_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -46,6 +47,13 @@ async def api_srs_queue(
|
|||||||
col_filter += " AND tags LIKE ?"
|
col_filter += " AND tags LIKE ?"
|
||||||
col_params.append(f"%{tag.strip()}%")
|
col_params.append(f"%{tag.strip()}%")
|
||||||
|
|
||||||
|
# Exclude grammar cards from standard SRS vocabulary training unless grammar collection/tag is specifically requested
|
||||||
|
wants_grammar = (collection and ("грамматика" in collection.lower() or "grammar" in collection.lower())) or \
|
||||||
|
(tag and "grammar" in tag.lower())
|
||||||
|
if exclude_grammar and not wants_grammar:
|
||||||
|
col_filter += " AND (collection IS NULL OR (collection != 'Грамматика Мерфи' AND collection NOT LIKE '%grammar%'))"
|
||||||
|
col_filter += " AND (tags IS NULL OR tags NOT LIKE '%grammar%')"
|
||||||
|
|
||||||
if mix:
|
if mix:
|
||||||
# 1. New cards (up to 10)
|
# 1. New cards (up to 10)
|
||||||
new_rows = conn.execute(
|
new_rows = conn.execute(
|
||||||
@@ -117,6 +125,7 @@ async def api_srs_quiz(
|
|||||||
collection: Optional[str] = None,
|
collection: Optional[str] = None,
|
||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
count: int = 10,
|
count: int = 10,
|
||||||
|
exclude_grammar: bool = True,
|
||||||
user: dict = Depends(require_user),
|
user: dict = Depends(require_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -132,15 +141,22 @@ async def api_srs_quiz(
|
|||||||
col_filter += " AND tags LIKE ?"
|
col_filter += " AND tags LIKE ?"
|
||||||
col_params.append(f"%{tag.strip()}%")
|
col_params.append(f"%{tag.strip()}%")
|
||||||
|
|
||||||
|
wants_grammar = (collection and ("грамматика" in collection.lower() or "grammar" in collection.lower())) or \
|
||||||
|
(tag and "grammar" in tag.lower())
|
||||||
|
if exclude_grammar and not wants_grammar:
|
||||||
|
col_filter += " AND (collection IS NULL OR (collection != 'Грамматика Мерфи' AND collection NOT LIKE '%grammar%'))"
|
||||||
|
col_filter += " AND (tags IS NULL OR tags NOT LIKE '%grammar%')"
|
||||||
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"SELECT * FROM vocabulary_cards WHERE user_id = ?{col_filter} ORDER BY RANDOM() LIMIT ?",
|
f"SELECT * FROM vocabulary_cards WHERE user_id = ?{col_filter} ORDER BY RANDOM() LIMIT ?",
|
||||||
[user["id"]] + col_params + [count],
|
[user["id"]] + col_params + [count],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
target_cards = [dict(r) for r in rows]
|
target_cards = [dict(r) for r in rows]
|
||||||
|
|
||||||
# Fetch pool of all user cards for generating smart distractors
|
# Fetch pool of user cards for generating smart distractors (exclude grammar)
|
||||||
|
distractor_filter = " AND (collection IS NULL OR (collection != 'Грамматика Мерфи' AND collection NOT LIKE '%grammar%')) AND (tags IS NULL OR tags NOT LIKE '%grammar%')" if exclude_grammar and not wants_grammar else ""
|
||||||
all_rows = conn.execute(
|
all_rows = conn.execute(
|
||||||
"SELECT id, term, translation, part_of_speech FROM vocabulary_cards WHERE user_id = ?",
|
f"SELECT id, term, translation, part_of_speech FROM vocabulary_cards WHERE user_id = ?{distractor_filter}",
|
||||||
(user["id"],),
|
(user["id"],),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
all_cards = [dict(r) for r in all_rows]
|
all_cards = [dict(r) for r in all_rows]
|
||||||
|
|||||||
@@ -40,6 +40,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slideUpSheet {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.auth-header,
|
.auth-header,
|
||||||
.login-header {
|
.login-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -336,6 +347,25 @@
|
|||||||
will-change: transform;
|
will-change: transform;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-color) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-drawer.active {
|
.app-drawer.active {
|
||||||
@@ -673,9 +703,11 @@ body.drawer-open {
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
|
max-height: min(90vh, 90dvh);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@@ -684,6 +716,7 @@ body.drawer-open {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h3 {
|
.modal-header h3 {
|
||||||
@@ -696,6 +729,19 @@ body.drawer-open {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-color) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
@@ -704,12 +750,44 @@ body.drawer-open {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 14px 20px;
|
padding: 14px 20px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-sm {
|
.modal-sm {
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Audio Control Group & Slow Speed Controls */
|
||||||
|
.audio-ctrl-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-slow-btn {
|
||||||
|
font-size: 11px !important;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 7px !important;
|
||||||
|
height: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-slow-btn:hover {
|
||||||
|
background: var(--bg-surface-elevated);
|
||||||
|
color: var(--text-main);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Navigation Additions */
|
/* Navigation Additions */
|
||||||
.nav-center {
|
.nav-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1145,3 +1223,399 @@ body.drawer-open {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
ChipDip-Style Floating Feedback & Dev Bug Report Widget
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.feedback-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 9995;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating Launcher Button */
|
||||||
|
.feedback-trigger-btn {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 18px rgba(99, 102, 241, 0.42), 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease, opacity 0.2s ease;
|
||||||
|
z-index: 9995;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-trigger-btn:hover {
|
||||||
|
transform: scale(1.08) translateY(-2px);
|
||||||
|
box-shadow: 0 6px 24px rgba(99, 102, 241, 0.55), 0 3px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-trigger-btn:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-trigger-icon {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid var(--bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating Chat Window */
|
||||||
|
.feedback-widget {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 84px;
|
||||||
|
right: 24px;
|
||||||
|
width: 380px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
height: 520px;
|
||||||
|
max-height: calc(100dvh - 120px);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-focus);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 16px 40px -8px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 9998;
|
||||||
|
animation: fadeInScale 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-widget-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12), rgba(99, 102, 241, 0.08));
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-header-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-header-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-header-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-close-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category Switcher Tabs */
|
||||||
|
.feedback-category-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-cat-chip {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-cat-chip:hover {
|
||||||
|
color: var(--text-main);
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-cat-chip.active {
|
||||||
|
background: rgba(99, 102, 241, 0.16);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Context Indicator */
|
||||||
|
.feedback-context-indicator {
|
||||||
|
padding: 5px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat Messages List */
|
||||||
|
.feedback-messages-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-empty-state {
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-empty-state .empty-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-empty-state p {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-empty-state small {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message Card in Feed */
|
||||||
|
.feedback-msg-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
animation: fadeIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-msg-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-msg-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-msg-badge.bug {
|
||||||
|
background: rgba(239, 68, 68, 0.14);
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-msg-badge.feature {
|
||||||
|
background: rgba(168, 85, 247, 0.14);
|
||||||
|
color: #c084fc;
|
||||||
|
border: 1px solid rgba(168, 85, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-msg-badge.note {
|
||||||
|
background: rgba(59, 130, 246, 0.14);
|
||||||
|
color: #60a5fa;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-msg-status {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-msg-status.new {
|
||||||
|
color: #facc15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-msg-status.in_progress {
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-msg-status.resolved {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-msg-text {
|
||||||
|
color: var(--text-main);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-msg-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Form */
|
||||||
|
.feedback-input-form {
|
||||||
|
padding: 10px 14px 12px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-main);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.4;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-textarea:focus {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-hint {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-send-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--primary);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-send-btn:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-send-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zen mode override: completely hide feedback button & widget */
|
||||||
|
body.zen-mode-active #feedback-container,
|
||||||
|
body.zen-mode-active .feedback-trigger-btn,
|
||||||
|
body.zen-mode-active .feedback-widget {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.feedback-trigger-btn {
|
||||||
|
bottom: calc(68px + env(safe-area-inset-bottom, 0px));
|
||||||
|
right: 16px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-trigger-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-widget {
|
||||||
|
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||||
|
right: 10px;
|
||||||
|
left: 10px;
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
height: calc(85dvh - 70px);
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -757,56 +757,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* 7. Bottom Sheet for Word Translation Popup on Mobile */
|
|
||||||
.reader-word-popup {
|
|
||||||
position: fixed !important;
|
|
||||||
top: auto !important;
|
|
||||||
bottom: calc(58px + env(safe-area-inset-bottom, 0px)) !important;
|
|
||||||
left: 8px !important;
|
|
||||||
right: 8px !important;
|
|
||||||
width: auto !important;
|
|
||||||
max-width: none !important;
|
|
||||||
border-radius: 18px !important;
|
|
||||||
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5) !important;
|
|
||||||
border: 1px solid var(--border-focus) !important;
|
|
||||||
background-color: var(--bg-surface) !important;
|
|
||||||
z-index: 10005 !important;
|
|
||||||
padding: 14px 16px !important;
|
|
||||||
animation: slideUpSheet 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-settings-panel {
|
|
||||||
position: fixed !important;
|
|
||||||
top: auto !important;
|
|
||||||
bottom: calc(58px + env(safe-area-inset-bottom, 0px)) !important;
|
|
||||||
left: 8px !important;
|
|
||||||
right: 8px !important;
|
|
||||||
width: auto !important;
|
|
||||||
max-height: calc(85dvh - 60px) !important;
|
|
||||||
overflow-y: auto !important;
|
|
||||||
-webkit-overflow-scrolling: touch !important;
|
|
||||||
border-radius: 20px !important;
|
|
||||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.55) !important;
|
|
||||||
border: 1px solid var(--border-focus) !important;
|
|
||||||
background-color: var(--bg-surface) !important;
|
|
||||||
color: var(--text-main) !important;
|
|
||||||
padding: 16px 18px 24px !important;
|
|
||||||
z-index: 10006 !important;
|
|
||||||
animation: slideUpSheet 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUpSheet {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* --- Responsive Mobile Overrides (max-width: 768px) --- */
|
/* --- Responsive Mobile Overrides (max-width: 768px) --- */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
/* 4. Dictionary & Search */
|
/* 4. Dictionary & Search */
|
||||||
@@ -924,6 +874,54 @@
|
|||||||
.card-bottom {
|
.card-bottom {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grammar Hint Badge & Audio buttons in Cards */
|
||||||
|
.grammar-hint-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
|
||||||
|
color: var(--text-muted, #94a3b8);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.12));
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-hint-badge:hover {
|
||||||
|
background: var(--bg-active, rgba(59, 130, 246, 0.15));
|
||||||
|
color: var(--primary, #38bdf8);
|
||||||
|
border-color: var(--primary, #38bdf8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-hint-badge .badge-body {
|
||||||
|
display: none;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-primary, #f1f5f9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-hint-badge.active .badge-body,
|
||||||
|
.grammar-hint-badge:hover .badge-body {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-hint-badge.active .badge-label {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-context .play-context-audio-btn {
|
||||||
|
padding: 1px 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 6px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-context .play-context-audio-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1688,3 +1688,151 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
FEAT-02: Murphy Cheatsheet Tables & Rich Detailed Notes
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.grammar-table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-card);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
margin: 12px 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-cheatsheet-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-cheatsheet-table thead th {
|
||||||
|
background: var(--bg-surface-hover);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-cheatsheet-table tbody td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-cheatsheet-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-cheatsheet-table tbody tr:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-inline-code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-notes-box {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px 24px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-notes-box .grammar-note-h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-notes-box .grammar-note-h4 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-notes-box .grammar-note-h5 {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 12px 0 6px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-notes-box .grammar-note-p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-notes-box .grammar-note-quote {
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
background: rgba(99, 102, 241, 0.06);
|
||||||
|
padding: 8px 14px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-notes-list {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammar-notes-list li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsible Practice Cheatsheet Drawer */
|
||||||
|
.practice-cheatsheet-toggle-wrap {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.practice-cheatsheet-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.practice-cheatsheet-btn:hover {
|
||||||
|
color: var(--text-main);
|
||||||
|
background: var(--bg-surface-hover);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.practice-cheatsheet-drawer {
|
||||||
|
margin-top: 10px;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+240
-3
@@ -1549,9 +1549,11 @@
|
|||||||
background-color: var(--bg-surface);
|
background-color: var(--bg-surface);
|
||||||
border: 1px solid var(--border-focus);
|
border: 1px solid var(--border-focus);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 12px 24px -4px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 12px 28px -4px rgba(0, 0, 0, 0.55);
|
||||||
width: 320px;
|
width: 340px;
|
||||||
max-width: calc(100vw - 32px);
|
max-width: 360px;
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
|
overflow-y: auto;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
animation: fadeInScale 0.15s ease-out;
|
animation: fadeInScale 0.15s ease-out;
|
||||||
}
|
}
|
||||||
@@ -2062,5 +2064,240 @@
|
|||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile Bottom Sheet for Word Translation Popup */
|
||||||
|
.reader-word-popup {
|
||||||
|
position: fixed !important;
|
||||||
|
top: auto !important;
|
||||||
|
bottom: calc(58px + env(safe-area-inset-bottom, 0px)) !important;
|
||||||
|
left: 8px !important;
|
||||||
|
right: 8px !important;
|
||||||
|
width: auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: calc(85dvh - 60px) !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
border-radius: 18px !important;
|
||||||
|
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5) !important;
|
||||||
|
border: 1px solid var(--border-focus) !important;
|
||||||
|
background-color: var(--bg-surface) !important;
|
||||||
|
z-index: 10005 !important;
|
||||||
|
padding: 14px 16px !important;
|
||||||
|
animation: slideUpSheet 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.zen-mode-active .reader-word-popup {
|
||||||
|
bottom: calc(12px + env(safe-area-inset-bottom, 0px)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Bottom Sheet for Reader Settings */
|
||||||
|
.reader-settings-panel {
|
||||||
|
position: fixed !important;
|
||||||
|
top: auto !important;
|
||||||
|
bottom: calc(58px + env(safe-area-inset-bottom, 0px)) !important;
|
||||||
|
left: 8px !important;
|
||||||
|
right: 8px !important;
|
||||||
|
width: auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: calc(85dvh - 60px) !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
-webkit-overflow-scrolling: touch !important;
|
||||||
|
border-radius: 20px !important;
|
||||||
|
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.55) !important;
|
||||||
|
border: 1px solid var(--border-focus) !important;
|
||||||
|
background-color: var(--bg-surface) !important;
|
||||||
|
color: var(--text-main) !important;
|
||||||
|
padding: 16px 18px 24px !important;
|
||||||
|
z-index: 10006 !important;
|
||||||
|
animation: slideUpSheet 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.zen-mode-active .reader-settings-panel {
|
||||||
|
bottom: calc(12px + env(safe-area-inset-bottom, 0px)) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
FEAT-01: Focus Mode in Reader (Zen Reader — 100% vh & 100% vw)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.reader-zen-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-main);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-zen-btn:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
background-color: var(--primary-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fullscreen / Zen Viewport Override */
|
||||||
|
body.zen-mode-active {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.zen-mode-active #app-header,
|
||||||
|
body.zen-mode-active #bottom-tab-bar,
|
||||||
|
body.zen-mode-active .reader-toolbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.zen-mode-active .reader-view {
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.zen-mode-active #reader-interface {
|
||||||
|
position: fixed !important;
|
||||||
|
inset: 0 !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 100dvh !important;
|
||||||
|
z-index: 9999 !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
background-color: var(--reader-bg) !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.zen-mode-active #reader-scroll-area {
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 100dvh !important;
|
||||||
|
padding-top: max(24px, env(safe-area-inset-top)) !important;
|
||||||
|
padding-bottom: max(60px, env(safe-area-inset-bottom)) !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating Minimal HUD in Zen Mode */
|
||||||
|
.reader-zen-hud {
|
||||||
|
position: fixed;
|
||||||
|
top: max(16px, env(safe-area-inset-top));
|
||||||
|
right: max(20px, env(safe-area-inset-right));
|
||||||
|
z-index: 10002;
|
||||||
|
transition: opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1), transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-zen-hud.hud-idle {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-hud-content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(15, 23, 42, 0.82);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-hud-title {
|
||||||
|
max-width: 160px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-hud-sep {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-hud-chapter {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-hud-progress {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #38bdf8;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-hud-exit-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
color: #cbd5e1;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-hud-exit-btn:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.35);
|
||||||
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
|
color: #fca5a5;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme-adaptive HUD styling for Sepia & Light themes */
|
||||||
|
.reader-scroll-area.reader-theme-sepia ~ #reader-zen-hud .zen-hud-content {
|
||||||
|
background: rgba(247, 237, 214, 0.88);
|
||||||
|
border-color: rgba(146, 64, 14, 0.25);
|
||||||
|
color: #453422;
|
||||||
|
box-shadow: 0 4px 20px rgba(70, 50, 30, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-scroll-area.reader-theme-sepia ~ #reader-zen-hud .zen-hud-title {
|
||||||
|
color: #291e14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-scroll-area.reader-theme-sepia ~ #reader-zen-hud .zen-hud-progress {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-scroll-area.reader-theme-sepia ~ #reader-zen-hud .zen-hud-exit-btn {
|
||||||
|
background: rgba(146, 64, 14, 0.12);
|
||||||
|
border-color: rgba(146, 64, 14, 0.2);
|
||||||
|
color: #453422;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-scroll-area.reader-theme-light ~ #reader-zen-hud .zen-hud-content {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-color: rgba(0, 0, 0, 0.12);
|
||||||
|
color: #1e293b;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-scroll-area.reader-theme-light ~ #reader-zen-hud .zen-hud-title {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-scroll-area.reader-theme-light ~ #reader-zen-hud .zen-hud-progress {
|
||||||
|
color: #0284c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-scroll-area.reader-theme-light ~ #reader-zen-hud .zen-hud-exit-btn {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border-color: rgba(0, 0, 0, 0.15);
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+109
-4
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
||||||
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=6.7">
|
<link rel="stylesheet" href="/static/css/style.css?v=6.9">
|
||||||
|
|
||||||
<!-- PWA meta and icons -->
|
<!-- PWA meta and icons -->
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
@@ -322,7 +322,10 @@
|
|||||||
<div class="fc-back-detail-box" id="fc-back-context-box">
|
<div class="fc-back-detail-box" id="fc-back-context-box">
|
||||||
<div class="fc-detail-header-row" style="display: flex; align-items: center; justify-content: space-between;">
|
<div class="fc-detail-header-row" style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
<span class="detail-label">Контекст:</span>
|
<span class="detail-label">Контекст:</span>
|
||||||
<button type="button" id="fc-back-context-audio-btn" class="icon-btn audio-xs-btn" title="Озвучить предложение контекста">🔊</button>
|
<span class="audio-ctrl-group">
|
||||||
|
<button type="button" id="fc-back-context-audio-btn" class="icon-btn audio-xs-btn" title="Озвучить пример (1.0x)">🔊</button>
|
||||||
|
<button type="button" id="fc-back-context-audio-slow-btn" class="icon-btn audio-xs-btn audio-slow-btn" title="Озвучить медленно (0.7x)">🐢 0.7x</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p id="fc-back-context" class="detail-text"></p>
|
<p id="fc-back-context" class="detail-text"></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -768,6 +771,10 @@
|
|||||||
<span style="font-weight: 700; font-family: serif; font-size: 13px;">Aa</span>
|
<span style="font-weight: 700; font-family: serif; font-size: 13px;">Aa</span>
|
||||||
<span class="btn-subtext-inline">Шрифт</span>
|
<span class="btn-subtext-inline">Шрифт</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="reader-zen-btn" class="btn btn-xs btn-outline reader-zen-btn" title="Фокус-режим чтения (Zen Mode, клавиша F)">
|
||||||
|
<span>🧘</span>
|
||||||
|
<span class="btn-subtext-inline">Фокус</span>
|
||||||
|
</button>
|
||||||
<button id="reader-toggle-highlight-btn" class="btn btn-xs btn-outline" title="Вкл/Выкл подсветку изучаемых слов">✨ Подсветка</button>
|
<button id="reader-toggle-highlight-btn" class="btn btn-xs btn-outline" title="Вкл/Выкл подсветку изучаемых слов">✨ Подсветка</button>
|
||||||
<button id="reader-train-btn" class="btn btn-xs btn-trainer" title="Тренировать слова из этой книги">🎓 Слова книги</button>
|
<button id="reader-train-btn" class="btn btn-xs btn-trainer" title="Тренировать слова из этой книги">🎓 Слова книги</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -777,6 +784,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Floating Zen HUD (Discreet, auto-fading, fixed in top-right) -->
|
||||||
|
<div id="reader-zen-hud" class="reader-zen-hud hidden">
|
||||||
|
<div class="zen-hud-content">
|
||||||
|
<span id="zen-hud-title" class="zen-hud-title">Книга</span>
|
||||||
|
<span class="zen-hud-sep">•</span>
|
||||||
|
<span id="zen-hud-chapter" class="zen-hud-chapter">Глава 1</span>
|
||||||
|
<span class="zen-hud-sep">•</span>
|
||||||
|
<span id="zen-hud-progress" class="zen-hud-progress">0%</span>
|
||||||
|
<button type="button" id="zen-hud-exit-btn" class="zen-hud-exit-btn" title="Выйти из фокус-режима (Esc или F)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Reading Container -->
|
<!-- Reading Container -->
|
||||||
<div class="reader-scroll-area" id="reader-scroll-area">
|
<div class="reader-scroll-area" id="reader-scroll-area">
|
||||||
<div class="reader-content-body" id="reader-content-body">
|
<div class="reader-content-body" id="reader-content-body">
|
||||||
@@ -946,6 +965,18 @@
|
|||||||
<p id="grammar-lesson-sub" class="lesson-sub-title">Unit Subtitle</p>
|
<p id="grammar-lesson-sub" class="lesson-sub-title">Unit Subtitle</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cheatsheet Markdown Table -->
|
||||||
|
<div id="grammar-cheatsheet-section" class="grammar-section-block">
|
||||||
|
<h3 class="section-title"><span class="title-icon">📊</span> Наглядная шпаргалка и правила (Таблица)</h3>
|
||||||
|
<div id="grammar-cheatsheet-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detailed Murphy Notes -->
|
||||||
|
<div id="grammar-detailed-notes-section" class="grammar-section-block">
|
||||||
|
<h3 class="section-title"><span class="title-icon">📖</span> Подробный конспект темы (Raymond Murphy)</h3>
|
||||||
|
<div id="grammar-detailed-notes-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Contrast Explanation Cards -->
|
<!-- Contrast Explanation Cards -->
|
||||||
<div class="grammar-section-block">
|
<div class="grammar-section-block">
|
||||||
<h3 class="section-title"><span class="title-icon">⚖️</span> Метод контрастов (В чём суть и когда применять)</h3>
|
<h3 class="section-title"><span class="title-icon">⚖️</span> Метод контрастов (В чём суть и когда применять)</h3>
|
||||||
@@ -999,6 +1030,15 @@
|
|||||||
<div id="grammar-practice-dots" class="practice-dots"></div>
|
<div id="grammar-practice-dots" class="practice-dots"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Practice Mode Cheatsheet Toggle & Drawer -->
|
||||||
|
<div class="practice-cheatsheet-toggle-wrap">
|
||||||
|
<button type="button" id="grammar-practice-cheatsheet-toggle" class="btn btn-sm btn-ghost practice-cheatsheet-btn">
|
||||||
|
<span>📋 Шпаргалка по теме</span>
|
||||||
|
<i class="ph-caret-down"></i>
|
||||||
|
</button>
|
||||||
|
<div id="grammar-practice-cheatsheet-drawer" class="practice-cheatsheet-drawer hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grammar-cloze-box">
|
<div class="grammar-cloze-box">
|
||||||
<div id="grammar-cloze-sentence" class="cloze-sentence-display"></div>
|
<div id="grammar-cloze-sentence" class="cloze-sentence-display"></div>
|
||||||
<div id="grammar-cloze-clue" class="cloze-clue-display"></div>
|
<div id="grammar-cloze-clue" class="cloze-clue-display"></div>
|
||||||
@@ -1980,9 +2020,12 @@
|
|||||||
<button type="button" id="popup-translate-sentence-btn" class="btn btn-xs btn-outline popup-sentence-btn" title="Перевести всё предложение через локальный ИИ">
|
<button type="button" id="popup-translate-sentence-btn" class="btn btn-xs btn-outline popup-sentence-btn" title="Перевести всё предложение через локальный ИИ">
|
||||||
🌐 Перевести предложение
|
🌐 Перевести предложение
|
||||||
</button>
|
</button>
|
||||||
<button type="button" id="popup-audio-sentence-btn" class="btn btn-xs btn-outline popup-sentence-audio-btn" title="Озвучить всё предложение нейросетью">
|
<button type="button" id="popup-audio-sentence-btn" class="btn btn-xs btn-outline popup-sentence-audio-btn" title="Озвучить всё предложение (1.0x)">
|
||||||
🔊 Озвучить
|
🔊 Озвучить
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" id="popup-audio-sentence-slow-btn" class="btn btn-xs btn-outline popup-sentence-audio-btn audio-slow-btn" title="Озвучить предложение медленно (0.7x)">
|
||||||
|
🐢 0.7x
|
||||||
|
</button>
|
||||||
<span id="popup-ai-source-badge" class="popup-ai-source-badge hidden">⚡ Local AI</span>
|
<span id="popup-ai-source-badge" class="popup-ai-source-badge hidden">⚡ Local AI</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="popup-sentence-loading" class="popup-sentence-loading hidden">
|
<div id="popup-sentence-loading" class="popup-sentence-loading hidden">
|
||||||
@@ -2009,10 +2052,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ChipDip-style Floating Feedback & Bug Report Widget -->
|
||||||
|
<div id="feedback-container" class="feedback-container">
|
||||||
|
<!-- Floating Launcher Button -->
|
||||||
|
<button id="feedback-trigger-btn" class="feedback-trigger-btn" title="Сообщить о баге или предложить идею (Dev Chat)" aria-label="Обратная связь">
|
||||||
|
<span class="feedback-trigger-icon">💬</span>
|
||||||
|
<span id="feedback-badge" class="feedback-badge hidden">0</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Floating Chat Window -->
|
||||||
|
<div id="feedback-widget" class="feedback-widget hidden" role="dialog" aria-labelledby="feedback-widget-title">
|
||||||
|
<div class="feedback-widget-header">
|
||||||
|
<div class="feedback-header-left">
|
||||||
|
<span class="feedback-header-icon">💬</span>
|
||||||
|
<div>
|
||||||
|
<div id="feedback-widget-title" class="feedback-header-title">Dev Feedback & Баги</div>
|
||||||
|
<div class="feedback-header-sub">Синхронизируется с AI-ассистентом</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="feedback-close-btn" class="feedback-close-btn" title="Закрыть (Esc)">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category Selector Tabs -->
|
||||||
|
<div class="feedback-category-bar">
|
||||||
|
<button type="button" class="feedback-cat-chip active" data-category="bug">🐛 Баг</button>
|
||||||
|
<button type="button" class="feedback-cat-chip" data-category="feature">💡 Фича</button>
|
||||||
|
<button type="button" class="feedback-cat-chip" data-category="note">📝 Заметка</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto Context Indicator -->
|
||||||
|
<div class="feedback-context-indicator" id="feedback-context-indicator">
|
||||||
|
<span class="context-icon">📍</span>
|
||||||
|
<span id="feedback-context-text" class="context-text">Определение контекста...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat / Reports Feed -->
|
||||||
|
<div id="feedback-messages-list" class="feedback-messages-list">
|
||||||
|
<div class="feedback-empty-state" id="feedback-empty-state">
|
||||||
|
<span class="empty-icon">📝</span>
|
||||||
|
<p>Нашли баг или есть идея для фичи?</p>
|
||||||
|
<small>Напишите сообщение ниже — оно сразу попадет в систему задач AI-разработчика.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input Area -->
|
||||||
|
<form id="feedback-form" class="feedback-input-form">
|
||||||
|
<textarea
|
||||||
|
id="feedback-input"
|
||||||
|
class="feedback-textarea"
|
||||||
|
placeholder="Опишите баг или идею... (Enter для отправки, Shift+Enter для новой строки)"
|
||||||
|
rows="2"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
<div class="feedback-form-bottom">
|
||||||
|
<span class="feedback-hint">Enter — отправить</span>
|
||||||
|
<button type="submit" id="feedback-send-btn" class="feedback-send-btn" title="Отправить репорт">
|
||||||
|
<span class="send-icon">➤</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Audio Element for native streaming -->
|
<!-- Audio Element for native streaming -->
|
||||||
<audio id="global-audio-player" preload="none"></audio>
|
<audio id="global-audio-player" preload="none"></audio>
|
||||||
|
|
||||||
<script type="module" src="/static/js/app.js?v=6.4"></script>
|
<script type="module" src="/static/js/app.js?v=6.6"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
+65
-3
@@ -7,10 +7,11 @@ import { api } from './modules/api.js';
|
|||||||
import { initTheme, openModal, closeModal, showToast, playTextSpeech, switchView } from './modules/ui.js';
|
import { initTheme, openModal, closeModal, showToast, playTextSpeech, switchView } from './modules/ui.js';
|
||||||
import { initDictionary, loadInitialData } from './modules/dictionary.js';
|
import { initDictionary, loadInitialData } from './modules/dictionary.js';
|
||||||
import { initSrs, startSrsTrainingSession, openStatsDashboard } from './modules/srs.js';
|
import { initSrs, startSrsTrainingSession, openStatsDashboard } from './modules/srs.js';
|
||||||
import { initReader, loadBookshelf, openBook } from './modules/reader.js';
|
import { initReader, loadBookshelf, openBook, openWordPopup, extractContextSentence } from './modules/reader.js';
|
||||||
import { initCefr, loadCefrOverview, startCefrQuiz } from './modules/cefr.js';
|
import { initCefr, loadCefrOverview, startCefrQuiz } from './modules/cefr.js';
|
||||||
import { initGrammar, loadGrammarUnits, openGrammarUnit } from './modules/grammar.js';
|
import { initGrammar, loadGrammarUnits, openGrammarUnit } from './modules/grammar.js';
|
||||||
import { initLessonModule, openLessonConfigurator } from './modules/lesson.js';
|
import { initLessonModule, openLessonConfigurator } from './modules/lesson.js';
|
||||||
|
import { initFeedbackWidget } from './modules/feedback.js';
|
||||||
|
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let isRouting = false;
|
let isRouting = false;
|
||||||
@@ -415,11 +416,71 @@ export function initNavigationDrawer() {
|
|||||||
if (drawerCloseBtn) drawerCloseBtn.addEventListener('click', closeDrawer);
|
if (drawerCloseBtn) drawerCloseBtn.addEventListener('click', closeDrawer);
|
||||||
if (drawerBackdrop) drawerBackdrop.addEventListener('click', closeDrawer);
|
if (drawerBackdrop) drawerBackdrop.addEventListener('click', closeDrawer);
|
||||||
|
|
||||||
// Escape Key Support
|
// Global Escape Key & Modal Dismiss Support
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape' && drawer && drawer.classList.contains('active')) {
|
if (e.key === 'Escape') {
|
||||||
|
// 1. If navigation drawer is open, close it
|
||||||
|
if (drawer && drawer.classList.contains('active')) {
|
||||||
closeDrawer();
|
closeDrawer();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// 2. Close any open modal backdrop (topmost visible modal)
|
||||||
|
const openModals = Array.from(document.querySelectorAll('.modal-backdrop:not(.hidden)'));
|
||||||
|
if (openModals.length > 0) {
|
||||||
|
const topModal = openModals[openModals.length - 1];
|
||||||
|
topModal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dismiss modals when clicking on dark backdrop outside modal window
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target && e.target.classList && e.target.classList.contains('modal-backdrop')) {
|
||||||
|
e.target.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Global Text Selection Lookup & Add-to-Dictionary (Anywhere on Page)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
function handleGlobalTextSelection(e) {
|
||||||
|
if (e && e.target && e.target.closest('#reader-word-popup, button, input, textarea, select, .modal-window, #feedback-widget, .icon-btn, .opt-btn, .tab-item, .drawer-nav-item')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.isCollapsed) return;
|
||||||
|
const raw = selection.toString().trim();
|
||||||
|
if (!raw || raw.length < 2 || raw.length > 50) return;
|
||||||
|
|
||||||
|
// Clean punctuation / quotes
|
||||||
|
const clean = raw.replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim();
|
||||||
|
// Require Latin characters (English vocabulary)
|
||||||
|
if (!clean || !/^[A-Za-z\s'-]+$/.test(clean)) return;
|
||||||
|
|
||||||
|
if (selection.rangeCount === 0) return;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
if (rect.width === 0 && rect.height === 0) return;
|
||||||
|
|
||||||
|
const containerElem = range.startContainer?.nodeType === 1 ? range.startContainer : range.startContainer?.parentElement;
|
||||||
|
// Avoid re-triggering if already inside reader word span (which handles its own click)
|
||||||
|
if (containerElem?.closest('.reader-word')) return;
|
||||||
|
|
||||||
|
const fullText = containerElem?.innerText || containerElem?.textContent || '';
|
||||||
|
const contextSentence = extractContextSentence(fullText, clean);
|
||||||
|
|
||||||
|
const virtualTarget = {
|
||||||
|
getBoundingClientRect: () => range.getBoundingClientRect(),
|
||||||
|
isConnected: true
|
||||||
|
};
|
||||||
|
|
||||||
|
openWordPopup(clean, contextSentence, virtualTarget, fullText);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', handleGlobalTextSelection);
|
||||||
|
document.addEventListener('touchend', (e) => {
|
||||||
|
setTimeout(() => handleGlobalTextSelection(e), 150);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Touch Swipe Left to Dismiss (Mobile & Tablet)
|
// Touch Swipe Left to Dismiss (Mobile & Tablet)
|
||||||
@@ -686,6 +747,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
initCefr();
|
initCefr();
|
||||||
initGrammar();
|
initGrammar();
|
||||||
initLessonModule();
|
initLessonModule();
|
||||||
|
initFeedbackWidget();
|
||||||
|
|
||||||
// 5. Global Navigation & Header Actions
|
// 5. Global Navigation & Header Actions
|
||||||
const navBrandHome = document.getElementById('nav-brand-home');
|
const navBrandHome = document.getElementById('nav-brand-home');
|
||||||
|
|||||||
@@ -492,19 +492,70 @@ export const api = {
|
|||||||
throw new Error(`Failed to get articles: ${resp.status}`);
|
throw new Error(`Failed to get articles: ${resp.status}`);
|
||||||
},
|
},
|
||||||
async submitDebrief(payload) {
|
async submitDebrief(payload) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
||||||
|
try {
|
||||||
const resp = await fetch('/api/lessons/debrief', {
|
const resp = await fetch('/api/lessons/debrief', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
if (resp.ok) return await resp.json();
|
if (resp.ok) return await resp.json();
|
||||||
throw new Error(`Failed to submit debrief: ${resp.status}`);
|
throw new Error(`Failed to submit debrief: ${resp.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async getHistory(limit = 15) {
|
async getHistory(limit = 15) {
|
||||||
const resp = await fetch(`/api/lessons/history?limit=${limit}`);
|
const resp = await fetch(`/api/lessons/history?limit=${limit}`);
|
||||||
if (resp.ok) return await resp.json();
|
if (resp.ok) return await resp.json();
|
||||||
throw new Error(`Failed to get lesson history: ${resp.status}`);
|
throw new Error(`Failed to get lesson history: ${resp.status}`);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// In-app Feedback & Bug Tracker
|
||||||
|
feedback: {
|
||||||
|
async getAll(params = {}) {
|
||||||
|
let url = '/api/feedback';
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.category) qs.set('category', params.category);
|
||||||
|
if (params.status) qs.set('status', params.status);
|
||||||
|
if (qs.toString()) url += `?${qs.toString()}`;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (resp.ok) return await resp.json();
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
async create(category, message, contextUrl = '', contextData = {}) {
|
||||||
|
const resp = await fetch('/api/feedback', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
category,
|
||||||
|
message,
|
||||||
|
context_url: contextUrl,
|
||||||
|
context_data: contextData
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (resp.ok) return await resp.json();
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || 'Не удалось отправить репорт');
|
||||||
|
},
|
||||||
|
async updateStatus(id, status) {
|
||||||
|
const resp = await fetch(`/api/feedback/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status })
|
||||||
|
});
|
||||||
|
if (resp.ok) return await resp.json();
|
||||||
|
throw new Error('Не удалось обновить статус');
|
||||||
|
},
|
||||||
|
async delete(id) {
|
||||||
|
const resp = await fetch(`/api/feedback/${id}`, { method: 'DELETE' });
|
||||||
|
if (resp.ok) return await resp.json();
|
||||||
|
throw new Error('Не удалось удалить');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,19 +4,22 @@
|
|||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { showToast, escapeHtml } from './ui.js';
|
import { showToast, escapeHtml, playTextSpeech } from './ui.js';
|
||||||
|
|
||||||
let currentLevel = 'A1';
|
let currentLevel = 'A1';
|
||||||
let currentMode = 'all';
|
let currentMode = 'all';
|
||||||
let currentQuiz = [];
|
let currentQuiz = [];
|
||||||
let currentQuestionIdx = 0;
|
let currentQuestionIdx = 0;
|
||||||
let isAnswered = false;
|
let isAnswered = false;
|
||||||
|
let hintUsedThisQuestion = false;
|
||||||
|
|
||||||
const sessionStats = {
|
const sessionStats = {
|
||||||
total: 0,
|
total: 0,
|
||||||
correct: 0,
|
correct: 0,
|
||||||
mistakes: 0,
|
mistakes: 0,
|
||||||
ingested: 0,
|
ingested: 0,
|
||||||
|
score: 0,
|
||||||
|
hintsUsed: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -391,6 +394,8 @@ export async function startCefrQuiz(level = 'A1', mode = 'all') {
|
|||||||
sessionStats.correct = 0;
|
sessionStats.correct = 0;
|
||||||
sessionStats.mistakes = 0;
|
sessionStats.mistakes = 0;
|
||||||
sessionStats.ingested = 0;
|
sessionStats.ingested = 0;
|
||||||
|
sessionStats.score = 0;
|
||||||
|
sessionStats.hintsUsed = 0;
|
||||||
|
|
||||||
showCefrScreen('quiz');
|
showCefrScreen('quiz');
|
||||||
|
|
||||||
@@ -459,6 +464,8 @@ function renderCurrentQuestion() {
|
|||||||
const cardContainer = document.getElementById('cefr-cloze-card');
|
const cardContainer = document.getElementById('cefr-cloze-card');
|
||||||
if (!cardContainer) return;
|
if (!cardContainer) return;
|
||||||
|
|
||||||
|
hintUsedThisQuestion = false;
|
||||||
|
|
||||||
// Stylize the sentence blank: replace ___ with a visible blank node
|
// Stylize the sentence blank: replace ___ with a visible blank node
|
||||||
const formattedSentence = escapeHtml(q.sentence).replace(/___/g, '<span class="cefr-blank" id="cefr-blank-target">_____</span>');
|
const formattedSentence = escapeHtml(q.sentence).replace(/___/g, '<span class="cefr-blank" id="cefr-blank-target">_____</span>');
|
||||||
|
|
||||||
@@ -473,11 +480,16 @@ function renderCurrentQuestion() {
|
|||||||
<p class="cefr-sentence-text">${formattedSentence}</p>
|
<p class="cefr-sentence-text">${formattedSentence}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cefr-clue-box">
|
<div class="cefr-clue-box" id="cefr-clue-box">
|
||||||
|
<button type="button" class="btn btn-xs btn-outline cefr-hint-btn" id="cefr-hint-btn" title="Показать подсказку с переводом слова (-0.2 балла)">
|
||||||
|
💡 Подсказка (-0.2 балла)
|
||||||
|
</button>
|
||||||
|
<div id="cefr-hint-content" class="cefr-hint-content hidden">
|
||||||
<span class="clue-icon">💡</span>
|
<span class="clue-icon">💡</span>
|
||||||
<span class="clue-label">Значение:</span>
|
<span class="clue-label">Значение:</span>
|
||||||
<strong class="clue-term">${escapeHtml(q.clue)}</strong>
|
<strong class="clue-term">${escapeHtml(q.clue)}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="cefr-options-grid" id="cefr-options-grid">
|
<div class="cefr-options-grid" id="cefr-options-grid">
|
||||||
${q.options.map((opt, idx) => `
|
${q.options.map((opt, idx) => `
|
||||||
@@ -491,6 +503,17 @@ function renderCurrentQuestion() {
|
|||||||
<div id="cefr-feedback-box" class="cefr-feedback-box hidden"></div>
|
<div id="cefr-feedback-box" class="cefr-feedback-box hidden"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Hook up hint toggle button
|
||||||
|
const hintBtn = cardContainer.querySelector('#cefr-hint-btn');
|
||||||
|
const hintContent = cardContainer.querySelector('#cefr-hint-content');
|
||||||
|
if (hintBtn && hintContent) {
|
||||||
|
hintBtn.addEventListener('click', () => {
|
||||||
|
hintUsedThisQuestion = true;
|
||||||
|
hintBtn.classList.add('hidden');
|
||||||
|
hintContent.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Attach click listeners to option buttons
|
// Attach click listeners to option buttons
|
||||||
cardContainer.querySelectorAll('.cefr-opt-btn').forEach(btn => {
|
cardContainer.querySelectorAll('.cefr-opt-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
@@ -524,6 +547,10 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
|||||||
|
|
||||||
if (res.correct) {
|
if (res.correct) {
|
||||||
sessionStats.correct++;
|
sessionStats.correct++;
|
||||||
|
const pts = hintUsedThisQuestion ? 0.8 : 1.0;
|
||||||
|
sessionStats.score += pts;
|
||||||
|
if (hintUsedThisQuestion) sessionStats.hintsUsed++;
|
||||||
|
|
||||||
clickedBtn.classList.add('opt-correct');
|
clickedBtn.classList.add('opt-correct');
|
||||||
if (blankTarget) {
|
if (blankTarget) {
|
||||||
blankTarget.textContent = res.correct_option;
|
blankTarget.textContent = res.correct_option;
|
||||||
@@ -533,16 +560,28 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
|||||||
if (feedbackBox) {
|
if (feedbackBox) {
|
||||||
feedbackBox.className = 'cefr-feedback-box feedback-correct';
|
feedbackBox.className = 'cefr-feedback-box feedback-correct';
|
||||||
feedbackBox.innerHTML = `
|
feedbackBox.innerHTML = `
|
||||||
<div class="fb-title">✓ Верно! Отличный выбор.</div>
|
<div class="fb-title">✓ Верно! ${pts === 0.8 ? 'Получено: <strong>+0.8 балла</strong> (с подсказкой)' : 'Получено: <strong>+1.0 балл</strong>'}</div>
|
||||||
<div class="fb-meta">
|
<div class="fb-meta" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||||
<strong class="fb-term">${escapeHtml(res.correct_option)}</strong>
|
<strong class="fb-term">${escapeHtml(res.correct_option)}</strong>
|
||||||
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
||||||
|
<span class="audio-ctrl-group">
|
||||||
|
<button type="button" class="icon-btn audio-xs-btn play-cefr-word-audio-btn" title="Озвучить слово (1.0x)">🔊</button>
|
||||||
|
<button type="button" class="icon-btn audio-xs-btn audio-slow-btn play-cefr-word-slow-btn" title="Озвучить медленно (0.7x)">🐢 0.7x</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
${res.source_book_title ? `<div class="fb-source-citation">📖 Пример из книги «${escapeHtml(res.source_book_title)}»${res.source_chapter_title ? ` (${escapeHtml(res.source_chapter_title)})` : ''}</div>` : ''}
|
${res.source_book_title ? `<div class="fb-source-citation">📖 Пример из книги «${escapeHtml(res.source_book_title)}»${res.source_chapter_title ? ` (${escapeHtml(res.source_chapter_title)})` : ''}</div>` : ''}
|
||||||
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
||||||
${renderOptionsBreakdown(res.options_breakdown)}
|
${renderOptionsBreakdown(res.options_breakdown)}
|
||||||
`;
|
`;
|
||||||
feedbackBox.classList.remove('hidden');
|
feedbackBox.classList.remove('hidden');
|
||||||
|
feedbackBox.querySelector('.play-cefr-word-audio-btn')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
playTextSpeech(res.correct_option, 'en', 1.0);
|
||||||
|
});
|
||||||
|
feedbackBox.querySelector('.play-cefr-word-slow-btn')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
playTextSpeech(res.correct_option, 'en', 0.75);
|
||||||
|
});
|
||||||
attachBreakdownListeners(feedbackBox, sentence);
|
attachBreakdownListeners(feedbackBox, sentence);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -550,6 +589,12 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
|||||||
// Two words were ingested: the correct target word and the clicked distractor
|
// Two words were ingested: the correct target word and the clicked distractor
|
||||||
sessionStats.ingested += 2;
|
sessionStats.ingested += 2;
|
||||||
|
|
||||||
|
// Automatically reveal clue on mistake so user sees the meaning
|
||||||
|
const hBtn = document.getElementById('cefr-hint-btn');
|
||||||
|
const hCnt = document.getElementById('cefr-hint-content');
|
||||||
|
if (hBtn) hBtn.classList.add('hidden');
|
||||||
|
if (hCnt) hCnt.classList.remove('hidden');
|
||||||
|
|
||||||
clickedBtn.classList.add('opt-wrong');
|
clickedBtn.classList.add('opt-wrong');
|
||||||
|
|
||||||
// Highlight the true correct option
|
// Highlight the true correct option
|
||||||
@@ -623,7 +668,8 @@ function renderQuizSummary() {
|
|||||||
const correct = sessionStats.correct;
|
const correct = sessionStats.correct;
|
||||||
const mistakes = sessionStats.mistakes;
|
const mistakes = sessionStats.mistakes;
|
||||||
const ingested = sessionStats.ingested;
|
const ingested = sessionStats.ingested;
|
||||||
const pct = Math.round((correct / total) * 100);
|
const earnedScore = Math.round(sessionStats.score * 10) / 10;
|
||||||
|
const pct = Math.round((earnedScore / total) * 100);
|
||||||
|
|
||||||
const sumScore = document.getElementById('cefr-sum-score');
|
const sumScore = document.getElementById('cefr-sum-score');
|
||||||
const sumLead = document.getElementById('cefr-sum-lead');
|
const sumLead = document.getElementById('cefr-sum-lead');
|
||||||
@@ -633,8 +679,8 @@ function renderQuizSummary() {
|
|||||||
const sumSrsBtn = document.getElementById('cefr-sum-srs-btn');
|
const sumSrsBtn = document.getElementById('cefr-sum-srs-btn');
|
||||||
|
|
||||||
if (sumScore) sumScore.textContent = `${pct}%`;
|
if (sumScore) sumScore.textContent = `${pct}%`;
|
||||||
if (sumCorrect) sumCorrect.textContent = `${correct} / ${total}`;
|
if (sumCorrect) sumCorrect.textContent = `${earnedScore} / ${total} баллов (${correct} верных)`;
|
||||||
if (sumMistakes) sumMistakes.textContent = mistakes;
|
if (sumMistakes) sumMistakes.innerHTML = `${mistakes} ${sessionStats.hintsUsed > 0 ? `<span style="font-size:12px;opacity:0.8;">(подсказок: ${sessionStats.hintsUsed})</span>` : ''}`;
|
||||||
if (sumIngested) sumIngested.textContent = ingested;
|
if (sumIngested) sumIngested.textContent = ingested;
|
||||||
|
|
||||||
if (sumLead) {
|
if (sumLead) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { escapeHtml, showToast, openModal, closeModal, playAudio } from './ui.js';
|
import { escapeHtml, showToast, openModal, closeModal, playAudio, playTextSpeech, formatTranslationWithSpoiler } from './ui.js';
|
||||||
|
|
||||||
export let allCards = [];
|
export let allCards = [];
|
||||||
export let collectionsList = ['General', 'Разговорный', 'Работа & IT', 'Книги & Сериалы', 'Идиомы'];
|
export let collectionsList = ['General', 'Разговорный', 'Работа & IT', 'Книги & Сериалы', 'Идиомы'];
|
||||||
@@ -67,7 +67,7 @@ export function initDictionary() {
|
|||||||
const batchCancelBtn = document.getElementById('batch-cancel-btn');
|
const batchCancelBtn = document.getElementById('batch-cancel-btn');
|
||||||
|
|
||||||
// Elements - Modals
|
// Elements - Modals
|
||||||
const editModal = document.getElementById('edit-card-modal');
|
const editModal = document.getElementById('edit-modal') || document.getElementById('edit-card-modal');
|
||||||
const closeModalBtn = document.getElementById('close-modal-btn');
|
const closeModalBtn = document.getElementById('close-modal-btn');
|
||||||
const modalCancelBtn = document.getElementById('modal-cancel-btn');
|
const modalCancelBtn = document.getElementById('modal-cancel-btn');
|
||||||
const modalSaveBtn = document.getElementById('modal-save-btn');
|
const modalSaveBtn = document.getElementById('modal-save-btn');
|
||||||
@@ -165,9 +165,9 @@ export function initDictionary() {
|
|||||||
|
|
||||||
if (audioPlayBtn) {
|
if (audioPlayBtn) {
|
||||||
audioPlayBtn.addEventListener('click', () => {
|
audioPlayBtn.addEventListener('click', () => {
|
||||||
if (!currentLookupData || !currentLookupData.term) return;
|
const term = currentLookupData?.term || currentLookupData?.english_term;
|
||||||
const url = currentLookupData.audio_url || `/api/tts?text=${encodeURIComponent(currentLookupData.term)}&lang=en`;
|
if (!term) return;
|
||||||
playAudio(url);
|
playTextSpeech(term, 'en');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ export function initDictionary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function displayLookupResult(data) {
|
function displayLookupResult(data) {
|
||||||
resTerm.textContent = data.term;
|
resTerm.textContent = data.term || data.english_term || data.query || '';
|
||||||
resPhonetic.textContent = data.phonetic || '';
|
resPhonetic.textContent = data.phonetic || '';
|
||||||
resPhonetic.style.display = data.phonetic ? 'inline-block' : 'none';
|
resPhonetic.style.display = data.phonetic ? 'inline-block' : 'none';
|
||||||
resPos.textContent = data.part_of_speech || 'Phrase';
|
resPos.textContent = data.part_of_speech || 'Phrase';
|
||||||
@@ -210,6 +210,8 @@ export function initDictionary() {
|
|||||||
resInLibrary.classList.toggle('hidden', !inLib);
|
resInLibrary.classList.toggle('hidden', !inLib);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetTrans = (data.is_reverse ? (data.russian_query || data.translation) : data.translation) || '';
|
||||||
|
|
||||||
if (inLib) {
|
if (inLib) {
|
||||||
saveCardBtn.innerHTML = '⭐ Обновить в словаре <span class="btn-subtext">(Ctrl + Enter)</span>';
|
saveCardBtn.innerHTML = '⭐ Обновить в словаре <span class="btn-subtext">(Ctrl + Enter)</span>';
|
||||||
const lib = data.library_card || foundDuplicateCard;
|
const lib = data.library_card || foundDuplicateCard;
|
||||||
@@ -220,11 +222,11 @@ export function initDictionary() {
|
|||||||
if (lib.tags) editTags.value = lib.tags;
|
if (lib.tags) editTags.value = lib.tags;
|
||||||
if (lib.notes) editNotes.value = lib.notes;
|
if (lib.notes) editNotes.value = lib.notes;
|
||||||
} else {
|
} else {
|
||||||
editTranslation.value = data.translation || '';
|
editTranslation.value = targetTrans;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
saveCardBtn.innerHTML = '💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>';
|
saveCardBtn.innerHTML = '💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>';
|
||||||
editTranslation.value = data.translation || '';
|
editTranslation.value = targetTrans;
|
||||||
editContext.value = '';
|
editContext.value = '';
|
||||||
editNotes.value = '';
|
editNotes.value = '';
|
||||||
editCollection.value = selectedCollection || 'General';
|
editCollection.value = selectedCollection || 'General';
|
||||||
@@ -247,7 +249,7 @@ export function initDictionary() {
|
|||||||
|
|
||||||
async function saveNewCard() {
|
async function saveNewCard() {
|
||||||
if (!currentLookupData) return;
|
if (!currentLookupData) return;
|
||||||
const term = currentLookupData.term.trim();
|
const term = (currentLookupData.term || currentLookupData.english_term || currentLookupData.query || '').trim();
|
||||||
const translation = editTranslation.value.trim();
|
const translation = editTranslation.value.trim();
|
||||||
if (!translation) {
|
if (!translation) {
|
||||||
alert('Пожалуйста, укажите русский перевод');
|
alert('Пожалуйста, укажите русский перевод');
|
||||||
@@ -388,14 +390,22 @@ export function initDictionary() {
|
|||||||
<span class="card-term">${escapeHtml(c.term)}</span>
|
<span class="card-term">${escapeHtml(c.term)}</span>
|
||||||
${c.phonetic ? `<span class="card-phonetic">${escapeHtml(c.phonetic)}</span>` : ''}
|
${c.phonetic ? `<span class="card-phonetic">${escapeHtml(c.phonetic)}</span>` : ''}
|
||||||
${c.part_of_speech ? `<span class="card-pos">${escapeHtml(c.part_of_speech)}</span>` : ''}
|
${c.part_of_speech ? `<span class="card-pos">${escapeHtml(c.part_of_speech)}</span>` : ''}
|
||||||
|
<button class="icon-btn audio-play-btn play-term-audio-btn" data-id="${c.id}" title="Озвучить слово">🔊</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button class="icon-btn edit-card-btn" data-id="${c.id}" title="Редактировать">✏️</button>
|
<button class="icon-btn edit-card-btn" data-id="${c.id}" title="Редактировать">✏️</button>
|
||||||
<button class="icon-btn delete-card-btn" data-id="${c.id}" title="Удалить">🗑️</button>
|
<button class="icon-btn delete-card-btn" data-id="${c.id}" title="Удалить">🗑️</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-translation">${escapeHtml(c.translation)}</div>
|
<div class="card-translation">${formatTranslationWithSpoiler(c.translation)}</div>
|
||||||
${c.context_sentence ? `<div class="card-context">“${escapeHtml(c.context_sentence)}”</div>` : ''}
|
${c.context_sentence ? `
|
||||||
|
<div class="card-context">
|
||||||
|
“${escapeHtml(c.context_sentence)}”
|
||||||
|
<span class="audio-ctrl-group">
|
||||||
|
<button class="icon-btn audio-play-btn play-context-audio-btn" data-id="${c.id}" title="Озвучить пример (1.0x)">🔊</button>
|
||||||
|
<button class="icon-btn audio-slow-btn play-context-slow-btn" data-id="${c.id}" title="Озвучить медленно (0.7x)">🐢 0.7x</button>
|
||||||
|
</span>
|
||||||
|
</div>` : ''}
|
||||||
<div class="card-bottom">
|
<div class="card-bottom">
|
||||||
<div class="card-tags">
|
<div class="card-tags">
|
||||||
<span class="card-tag">📁 ${escapeHtml(c.collection || 'General')}</span>
|
<span class="card-tag">📁 ${escapeHtml(c.collection || 'General')}</span>
|
||||||
@@ -405,6 +415,22 @@ export function initDictionary() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Audio buttons
|
||||||
|
cardEl.querySelector('.play-term-audio-btn')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
playTextSpeech(c.term, 'en', 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
cardEl.querySelector('.play-context-audio-btn')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
playTextSpeech(c.context_sentence, 'en', 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
cardEl.querySelector('.play-context-slow-btn')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
playTextSpeech(c.context_sentence, 'en', 0.75);
|
||||||
|
});
|
||||||
|
|
||||||
// Edit button
|
// Edit button
|
||||||
cardEl.querySelector('.edit-card-btn').addEventListener('click', (e) => {
|
cardEl.querySelector('.edit-card-btn').addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// =======================================================================
|
||||||
|
// Notes Application - Feedback & Dev Bug Tracker Widget (Native ES Module)
|
||||||
|
// ChipDip-style floating chat for instant bug reporting & feature requests
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
import { api } from './api.js';
|
||||||
|
import { escapeHtml, showToast } from './ui.js';
|
||||||
|
|
||||||
|
let activeCategory = 'bug';
|
||||||
|
let isWidgetOpen = false;
|
||||||
|
|
||||||
|
function getCurrentContext() {
|
||||||
|
const hash = window.location.hash || '#dictionary';
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
const isMobile = width <= 768;
|
||||||
|
|
||||||
|
let friendlyName = 'Главная';
|
||||||
|
if (hash.startsWith('#reader/book/')) {
|
||||||
|
const titleEl = document.getElementById('reader-book-title');
|
||||||
|
const bookTitle = titleEl ? titleEl.textContent : 'Книга';
|
||||||
|
friendlyName = `Читалка: ${bookTitle}`;
|
||||||
|
} else if (hash.startsWith('#reader')) {
|
||||||
|
friendlyName = 'Полка книг';
|
||||||
|
} else if (hash.startsWith('#srs')) {
|
||||||
|
friendlyName = 'SRS Тренировка';
|
||||||
|
} else if (hash.startsWith('#grammar')) {
|
||||||
|
friendlyName = 'Грамматика';
|
||||||
|
} else if (hash.startsWith('#cefr')) {
|
||||||
|
friendlyName = 'CEFR Тестирование';
|
||||||
|
} else if (hash.startsWith('#lesson')) {
|
||||||
|
friendlyName = 'Урок';
|
||||||
|
} else if (hash.startsWith('#dictionary')) {
|
||||||
|
friendlyName = 'Словарь & Поиск';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: hash,
|
||||||
|
friendlyName: friendlyName,
|
||||||
|
data: {
|
||||||
|
screen: `${width}×${height}`,
|
||||||
|
device: isMobile ? 'Mobile' : 'Desktop',
|
||||||
|
userAgent: navigator.userAgent.slice(0, 150),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateContextDisplay() {
|
||||||
|
const indicator = document.getElementById('feedback-context-text');
|
||||||
|
if (!indicator) return;
|
||||||
|
const ctx = getCurrentContext();
|
||||||
|
indicator.textContent = `${ctx.friendlyName} • ${ctx.data.device} (${ctx.data.screen})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(isoString) {
|
||||||
|
if (!isoString) return '';
|
||||||
|
try {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
return date.toLocaleDateString('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return isoString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReportCard(r) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'feedback-msg-card';
|
||||||
|
card.dataset.reportId = r.id;
|
||||||
|
|
||||||
|
const catLabels = {
|
||||||
|
bug: { label: '🐛 Баг', cls: 'bug' },
|
||||||
|
feature: { label: '💡 Фича', cls: 'feature' },
|
||||||
|
note: { label: '📝 Заметка', cls: 'note' }
|
||||||
|
};
|
||||||
|
const cat = catLabels[r.category] || catLabels.bug;
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
new: { label: '🆕 Новый', cls: 'new' },
|
||||||
|
in_progress: { label: '⏳ В работе', cls: 'in_progress' },
|
||||||
|
resolved: { label: '✅ Исправлено', cls: 'resolved' },
|
||||||
|
closed: { label: '✕ Закрыт', cls: 'resolved' }
|
||||||
|
};
|
||||||
|
const st = statusLabels[r.status] || statusLabels.new;
|
||||||
|
|
||||||
|
const timeStr = formatRelativeTime(r.created_at);
|
||||||
|
const contextStr = r.context_url ? escapeHtml(r.context_url) : '';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="feedback-msg-header">
|
||||||
|
<span class="feedback-msg-badge ${cat.cls}">${cat.label}</span>
|
||||||
|
<span class="feedback-msg-status ${st.cls}">${st.label}</span>
|
||||||
|
</div>
|
||||||
|
<div class="feedback-msg-text">${escapeHtml(r.message)}</div>
|
||||||
|
<div class="feedback-msg-meta">
|
||||||
|
<span>${timeStr}</span>
|
||||||
|
${contextStr ? `<span>${contextStr}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadFeedbackList() {
|
||||||
|
const listEl = document.getElementById('feedback-messages-list');
|
||||||
|
const badgeEl = document.getElementById('feedback-badge');
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reports = await api.feedback.getAll();
|
||||||
|
const activeCount = reports.filter(r => r.status === 'new' || r.status === 'in_progress').length;
|
||||||
|
|
||||||
|
if (badgeEl) {
|
||||||
|
if (activeCount > 0) {
|
||||||
|
badgeEl.textContent = activeCount;
|
||||||
|
badgeEl.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
badgeEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (reports.length === 0) {
|
||||||
|
listEl.innerHTML = `
|
||||||
|
<div class="feedback-empty-state">
|
||||||
|
<span class="empty-icon">📝</span>
|
||||||
|
<p>Нашли баг или есть идея для фичи?</p>
|
||||||
|
<small>Напишите сообщение ниже — оно сразу синхронизируется с AI-разработчиком.</small>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reports.forEach(r => {
|
||||||
|
listEl.appendChild(createReportCard(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll to bottom
|
||||||
|
listEl.scrollTop = listEl.scrollHeight;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load feedback reports:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openFeedbackWidget() {
|
||||||
|
const widget = document.getElementById('feedback-widget');
|
||||||
|
const input = document.getElementById('feedback-input');
|
||||||
|
if (!widget) return;
|
||||||
|
|
||||||
|
isWidgetOpen = true;
|
||||||
|
widget.classList.remove('hidden');
|
||||||
|
updateContextDisplay();
|
||||||
|
loadFeedbackList();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (input) input.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeFeedbackWidget() {
|
||||||
|
const widget = document.getElementById('feedback-widget');
|
||||||
|
if (!widget) return;
|
||||||
|
isWidgetOpen = false;
|
||||||
|
widget.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleFeedbackWidget() {
|
||||||
|
if (isWidgetOpen) {
|
||||||
|
closeFeedbackWidget();
|
||||||
|
} else {
|
||||||
|
openFeedbackWidget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initFeedbackWidget() {
|
||||||
|
const triggerBtn = document.getElementById('feedback-trigger-btn');
|
||||||
|
const closeBtn = document.getElementById('feedback-close-btn');
|
||||||
|
const widget = document.getElementById('feedback-widget');
|
||||||
|
const form = document.getElementById('feedback-form');
|
||||||
|
const input = document.getElementById('feedback-input');
|
||||||
|
const sendBtn = document.getElementById('feedback-send-btn');
|
||||||
|
const catChips = document.querySelectorAll('.feedback-cat-chip');
|
||||||
|
|
||||||
|
if (triggerBtn) {
|
||||||
|
triggerBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFeedbackWidget();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeFeedbackWidget();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category chips selection
|
||||||
|
catChips.forEach(chip => {
|
||||||
|
chip.addEventListener('click', () => {
|
||||||
|
catChips.forEach(c => c.classList.remove('active'));
|
||||||
|
chip.classList.add('active');
|
||||||
|
activeCategory = chip.dataset.category || 'bug';
|
||||||
|
if (input) input.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && isWidgetOpen) {
|
||||||
|
closeFeedbackWidget();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter to send, Shift+Enter for new line
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (form) form.requestSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form submit
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = input ? input.value.trim() : '';
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const ctx = getCurrentContext();
|
||||||
|
|
||||||
|
if (sendBtn) sendBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.feedback.create(activeCategory, text, ctx.url, ctx.data);
|
||||||
|
showToast('✅ Репорт отправлен в бэклог AI-разработчику!', 'success');
|
||||||
|
if (input) input.value = '';
|
||||||
|
await loadFeedbackList();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Feedback submit error:', err);
|
||||||
|
showToast(err.message || 'Ошибка отправки репорта', 'error');
|
||||||
|
} finally {
|
||||||
|
if (sendBtn) sendBtn.disabled = false;
|
||||||
|
if (input) input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash change updates context indicator if open
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
if (isWidgetOpen) updateContextDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial check of badge count
|
||||||
|
loadFeedbackList();
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { showToast, escapeHtml } from './ui.js';
|
import { showToast, escapeHtml, renderMarkdownTable, renderMarkdownNotes } from './ui.js';
|
||||||
|
|
||||||
let allUnits = [];
|
let allUnits = [];
|
||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
@@ -29,6 +29,18 @@ export function initGrammar() {
|
|||||||
const sumUnitsBtn = document.getElementById('grammar-sum-units-btn');
|
const sumUnitsBtn = document.getElementById('grammar-sum-units-btn');
|
||||||
const sumSrsBtn = document.getElementById('grammar-sum-srs-btn');
|
const sumSrsBtn = document.getElementById('grammar-sum-srs-btn');
|
||||||
const handwrittenToggle = document.getElementById('grammar-handwritten-toggle');
|
const handwrittenToggle = document.getElementById('grammar-handwritten-toggle');
|
||||||
|
const cheatsheetToggle = document.getElementById('grammar-practice-cheatsheet-toggle');
|
||||||
|
const cheatsheetDrawer = document.getElementById('grammar-practice-cheatsheet-drawer');
|
||||||
|
|
||||||
|
if (cheatsheetToggle && cheatsheetDrawer) {
|
||||||
|
cheatsheetToggle.addEventListener('click', () => {
|
||||||
|
const isNowHidden = !cheatsheetDrawer.classList.contains('hidden');
|
||||||
|
cheatsheetDrawer.classList.toggle('hidden', isNowHidden);
|
||||||
|
cheatsheetToggle.innerHTML = isNowHidden
|
||||||
|
? '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>'
|
||||||
|
: '<span>📋 Скрыть шпаргалку</span><i class="ph-caret-up"></i>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (backBtn) {
|
if (backBtn) {
|
||||||
backBtn.addEventListener('click', () => {
|
backBtn.addEventListener('click', () => {
|
||||||
@@ -314,6 +326,50 @@ function renderUnitTheory() {
|
|||||||
handwrittenToggle.checked = Boolean(currentUnit.handwritten_logged);
|
handwrittenToggle.checked = Boolean(currentUnit.handwritten_logged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cheatsheet Markdown Table
|
||||||
|
const cheatsheetSec = document.getElementById('grammar-cheatsheet-section');
|
||||||
|
const cheatsheetBox = document.getElementById('grammar-cheatsheet-content');
|
||||||
|
if (cheatsheetSec && cheatsheetBox) {
|
||||||
|
if (currentUnit.cheatsheet_table && currentUnit.cheatsheet_table.trim()) {
|
||||||
|
cheatsheetBox.innerHTML = renderMarkdownTable(currentUnit.cheatsheet_table);
|
||||||
|
cheatsheetSec.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
cheatsheetBox.innerHTML = '';
|
||||||
|
cheatsheetSec.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detailed Murphy Notes
|
||||||
|
const notesSec = document.getElementById('grammar-detailed-notes-section');
|
||||||
|
const notesBox = document.getElementById('grammar-detailed-notes-content');
|
||||||
|
if (notesSec && notesBox) {
|
||||||
|
if (currentUnit.detailed_notes && currentUnit.detailed_notes.trim()) {
|
||||||
|
notesBox.innerHTML = renderMarkdownNotes(currentUnit.detailed_notes);
|
||||||
|
notesSec.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
notesBox.innerHTML = '';
|
||||||
|
notesSec.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Practice Cheatsheet Drawer content
|
||||||
|
const practiceDrawer = document.getElementById('grammar-practice-cheatsheet-drawer');
|
||||||
|
const practiceToggleWrap = document.querySelector('.practice-cheatsheet-toggle-wrap');
|
||||||
|
if (practiceDrawer && practiceToggleWrap) {
|
||||||
|
if (currentUnit.cheatsheet_table && currentUnit.cheatsheet_table.trim()) {
|
||||||
|
practiceDrawer.innerHTML = renderMarkdownTable(currentUnit.cheatsheet_table);
|
||||||
|
practiceToggleWrap.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
practiceDrawer.innerHTML = '';
|
||||||
|
practiceToggleWrap.classList.add('hidden');
|
||||||
|
}
|
||||||
|
practiceDrawer.classList.add('hidden');
|
||||||
|
const practiceToggle = document.getElementById('grammar-practice-cheatsheet-toggle');
|
||||||
|
if (practiceToggle) {
|
||||||
|
practiceToggle.innerHTML = '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Golden formula
|
// Golden formula
|
||||||
const formulaEl = document.getElementById('grammar-golden-formula-text');
|
const formulaEl = document.getElementById('grammar-golden-formula-text');
|
||||||
if (formulaEl) {
|
if (formulaEl) {
|
||||||
@@ -368,6 +424,12 @@ function startUnitPractice() {
|
|||||||
sessionCorrect = 0;
|
sessionCorrect = 0;
|
||||||
sessionIngestedCards = [];
|
sessionIngestedCards = [];
|
||||||
|
|
||||||
|
// Ensure cheatsheet drawer is collapsed at practice start
|
||||||
|
const practiceDrawer = document.getElementById('grammar-practice-cheatsheet-drawer');
|
||||||
|
const practiceToggle = document.getElementById('grammar-practice-cheatsheet-toggle');
|
||||||
|
if (practiceDrawer) practiceDrawer.classList.add('hidden');
|
||||||
|
if (practiceToggle) practiceToggle.innerHTML = '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>';
|
||||||
|
|
||||||
renderExercise();
|
renderExercise();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { ui, escapeHtml, showToast, switchView, shuffleArray, escapeRegex, playAudio, playTextSpeech } from './ui.js';
|
import { ui, escapeHtml, showToast, switchView, shuffleArray, escapeRegex, playAudio, playTextSpeech, cleanPromptTranslation, renderMarkdownTable, renderMarkdownNotes } from './ui.js';
|
||||||
|
|
||||||
let presets = [];
|
let presets = [];
|
||||||
let currentPreset = null;
|
let currentPreset = null;
|
||||||
@@ -762,20 +762,20 @@ async function runVocabStep(step, container) {
|
|||||||
// Mode A: 4 Options Quiz
|
// Mode A: 4 Options Quiz
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
function renderQuizCard(card) {
|
function renderQuizCard(card) {
|
||||||
const correctText = card.translation;
|
const correctText = cleanPromptTranslation(card.translation);
|
||||||
const candidates = [
|
const candidates = [
|
||||||
...cards.filter(c => c.id !== card.id && c.translation).map(c => c.translation),
|
...cards.filter(c => c.id !== card.id && c.translation).map(c => cleanPromptTranslation(c.translation)),
|
||||||
...distractorPool.filter(t => t && t !== correctText)
|
...distractorPool.filter(t => t && cleanPromptTranslation(t) !== correctText).map(t => cleanPromptTranslation(t))
|
||||||
];
|
];
|
||||||
const uniqueCandidates = [...new Set(candidates.filter(t => t !== correctText))];
|
const uniqueCandidates = [...new Set(candidates.filter(t => t !== correctText))];
|
||||||
shuffleArray(uniqueCandidates);
|
shuffleArray(uniqueCandidates);
|
||||||
const options = [correctText, ...uniqueCandidates.slice(0, 3)];
|
let options = [correctText, ...uniqueCandidates.slice(0, 3)];
|
||||||
const fallbackOptions = ['определять', 'проверять', 'исследовать', 'сохранять', 'показывать', 'понимать', 'находить', 'создавать'];
|
const fallbackOptions = ['определять', 'проверять', 'исследовать', 'сохранять', 'показывать', 'понимать', 'находить', 'создавать'];
|
||||||
while (options.length < 4) {
|
while (options.length < 4) {
|
||||||
const f = fallbackOptions.find(o => !options.includes(o)) || `Вариант ${options.length + 1}`;
|
const f = fallbackOptions.find(o => !options.includes(o)) || `Вариант ${options.length + 1}`;
|
||||||
options.push(f);
|
options.push(f);
|
||||||
}
|
}
|
||||||
shuffleArray(options);
|
options = shuffleArray(options);
|
||||||
|
|
||||||
let answered = false;
|
let answered = false;
|
||||||
|
|
||||||
@@ -1050,7 +1050,7 @@ async function runVocabStep(step, container) {
|
|||||||
|
|
||||||
<div class="step-prompt-display text-center mt-3">
|
<div class="step-prompt-display text-center mt-3">
|
||||||
<span class="text-muted text-sm">Переведите на английский:</span>
|
<span class="text-muted text-sm">Переведите на английский:</span>
|
||||||
<h2 class="display-term display-prompt">${escapeHtml(card.translation)}</h2>
|
<h2 class="display-term display-prompt">${escapeHtml(cleanPromptTranslation(card.translation))}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${card.context_sentence ? `
|
${card.context_sentence ? `
|
||||||
@@ -1171,7 +1171,7 @@ async function runVocabStep(step, container) {
|
|||||||
<button id="dictation-big-audio-btn" class="btn btn-secondary btn-lg dictation-play-btn" type="button">
|
<button id="dictation-big-audio-btn" class="btn btn-secondary btn-lg dictation-play-btn" type="button">
|
||||||
🔊 Прослушать слово (Пробел)
|
🔊 Прослушать слово (Пробел)
|
||||||
</button>
|
</button>
|
||||||
${card.translation ? `<p class="dictation-hint text-muted mt-2">Подсказка перевода: ${escapeHtml(card.translation)}</p>` : ''}
|
${card.translation ? `<p class="dictation-hint text-muted mt-2">Подсказка перевода: ${escapeHtml(cleanPromptTranslation(card.translation))}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="lesson-dictation-form" class="mt-4">
|
<form id="lesson-dictation-form" class="mt-4">
|
||||||
@@ -1332,6 +1332,18 @@ async function runGrammarReviewStep(step, container) {
|
|||||||
<span class="badge-formula">${escapeHtml(unitDetail.golden_formula || '')}</span>
|
<span class="badge-formula">${escapeHtml(unitDetail.golden_formula || '')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${unitDetail.cheatsheet_table ? `
|
||||||
|
<div class="practice-cheatsheet-toggle-wrap mt-2">
|
||||||
|
<button type="button" id="review-cheatsheet-toggle-btn" class="btn btn-sm btn-ghost practice-cheatsheet-btn">
|
||||||
|
<span>📋 Шпаргалка по теме</span>
|
||||||
|
<i class="ph-caret-down"></i>
|
||||||
|
</button>
|
||||||
|
<div id="review-cheatsheet-drawer" class="practice-cheatsheet-drawer hidden">
|
||||||
|
${renderMarkdownTable(unitDetail.cheatsheet_table)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<div class="grammar-question-box mt-3">
|
<div class="grammar-question-box mt-3">
|
||||||
<p class="grammar-sentence-prompt">${escapeHtml(ex.sentence)}</p>
|
<p class="grammar-sentence-prompt">${escapeHtml(ex.sentence)}</p>
|
||||||
${ex.clue ? `<div class="clue-tag">💡 Подсказка: ${escapeHtml(ex.clue)}</div>` : ''}
|
${ex.clue ? `<div class="clue-tag">💡 Подсказка: ${escapeHtml(ex.clue)}</div>` : ''}
|
||||||
@@ -1350,6 +1362,18 @@ async function runGrammarReviewStep(step, container) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const reviewCsBtn = document.getElementById('review-cheatsheet-toggle-btn');
|
||||||
|
const reviewCsDrawer = document.getElementById('review-cheatsheet-drawer');
|
||||||
|
if (reviewCsBtn && reviewCsDrawer) {
|
||||||
|
reviewCsBtn.addEventListener('click', () => {
|
||||||
|
const isHidden = reviewCsDrawer.classList.contains('hidden');
|
||||||
|
reviewCsDrawer.classList.toggle('hidden', !isHidden);
|
||||||
|
reviewCsBtn.innerHTML = !isHidden
|
||||||
|
? '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>'
|
||||||
|
: '<span>📋 Скрыть шпаргалку</span><i class="ph-caret-up"></i>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
container.querySelectorAll('.grammar-opt-btn').forEach(btn => {
|
container.querySelectorAll('.grammar-opt-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
// Disable all options
|
// Disable all options
|
||||||
@@ -1432,7 +1456,11 @@ async function runGrammarNewStep(step, container) {
|
|||||||
let stage = 'theory'; // theory -> exercises
|
let stage = 'theory'; // theory -> exercises
|
||||||
|
|
||||||
function renderTheory() {
|
function renderTheory() {
|
||||||
const contrasts = unitDetail.contrast_explanation || [];
|
const contrasts = unitDetail.contrasts || unitDetail.contrast_explanation || [];
|
||||||
|
const hasCheatsheet = Boolean(unitDetail.cheatsheet_table && unitDetail.cheatsheet_table.trim());
|
||||||
|
const hasNotes = Boolean(unitDetail.detailed_notes && unitDetail.detailed_notes.trim());
|
||||||
|
const hasPrompt = Boolean(unitDetail.journaling_prompt && unitDetail.journaling_prompt.trim());
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="step-card-box">
|
<div class="step-card-box">
|
||||||
<div class="step-mini-header">
|
<div class="step-mini-header">
|
||||||
@@ -1449,9 +1477,28 @@ async function runGrammarNewStep(step, container) {
|
|||||||
<pre class="formula-code">${escapeHtml(unitDetail.golden_formula)}</pre>
|
<pre class="formula-code">${escapeHtml(unitDetail.golden_formula)}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cheatsheet Markdown Table -->
|
||||||
|
${hasCheatsheet ? `
|
||||||
|
<div class="lesson-grammar-section mt-4">
|
||||||
|
<div class="section-title"><span class="title-icon">📊</span> Наглядная шпаргалка и правила (Таблица):</div>
|
||||||
|
${renderMarkdownTable(unitDetail.cheatsheet_table)}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Detailed Notes -->
|
||||||
|
${hasNotes ? `
|
||||||
|
<div class="lesson-grammar-section mt-4">
|
||||||
|
<div class="section-title"><span class="title-icon">📖</span> Подробный конспект темы (Raymond Murphy):</div>
|
||||||
|
${renderMarkdownNotes(unitDetail.detailed_notes)}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<!-- Contrast Situations -->
|
<!-- Contrast Situations -->
|
||||||
|
${contrasts.length > 0 ? `
|
||||||
|
<div class="lesson-grammar-section mt-4">
|
||||||
|
<div class="section-title"><span class="title-icon">⚖️</span> Метод контрастов (В чём суть и когда применять):</div>
|
||||||
${contrasts.map(c => `
|
${contrasts.map(c => `
|
||||||
<div class="contrast-card mt-3">
|
<div class="contrast-card mt-2">
|
||||||
<div class="contrast-row">
|
<div class="contrast-row">
|
||||||
<div class="contrast-col a">
|
<div class="contrast-col a">
|
||||||
<div class="col-title">${escapeHtml(c.situation_a)}</div>
|
<div class="col-title">${escapeHtml(c.situation_a)}</div>
|
||||||
@@ -1467,11 +1514,23 @@ async function runGrammarNewStep(step, container) {
|
|||||||
<div class="contrast-diff mt-2">💡 <strong>Ключевая разница:</strong> ${escapeHtml(c.key_difference)}</div>
|
<div class="contrast-diff mt-2">💡 <strong>Ключевая разница:</strong> ${escapeHtml(c.key_difference)}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Handwritten Journaling Prompt -->
|
||||||
|
${hasPrompt ? `
|
||||||
|
<div class="grammar-journaling-card mt-4">
|
||||||
|
<div class="journaling-prompt-box">
|
||||||
|
<div class="formula-label">📓 Персональный дневник от руки (Motor Memory):</div>
|
||||||
|
<p class="journaling-prompt-text mt-1">${escapeHtml(unitDetail.journaling_prompt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<!-- Handwritten Journaling Checkbox -->
|
<!-- Handwritten Journaling Checkbox -->
|
||||||
<div class="journaling-box mt-4">
|
<div class="journaling-box mt-4">
|
||||||
<label class="checkbox-container">
|
<label class="checkbox-container">
|
||||||
<input type="checkbox" id="hw-checkbox">
|
<input type="checkbox" id="hw-checkbox" ${unitDetail.handwritten_logged ? 'checked' : ''}>
|
||||||
<span class="checkmark"></span>
|
<span class="checkmark"></span>
|
||||||
<span class="hw-text">✍️ Я записал формулу и пример в бумажную тетрадь</span>
|
<span class="hw-text">✍️ Я записал формулу и пример в бумажную тетрадь</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -1541,6 +1600,18 @@ async function runGrammarNewStep(step, container) {
|
|||||||
<span class="badge">Практика темы (${exIdx + 1} из ${selectedExercises.length})</span>
|
<span class="badge">Практика темы (${exIdx + 1} из ${selectedExercises.length})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${unitDetail.cheatsheet_table ? `
|
||||||
|
<div class="practice-cheatsheet-toggle-wrap mt-2">
|
||||||
|
<button type="button" id="lesson-cheatsheet-toggle-btn" class="btn btn-sm btn-ghost practice-cheatsheet-btn">
|
||||||
|
<span>📋 Шпаргалка по теме</span>
|
||||||
|
<i class="ph-caret-down"></i>
|
||||||
|
</button>
|
||||||
|
<div id="lesson-cheatsheet-drawer" class="practice-cheatsheet-drawer hidden">
|
||||||
|
${renderMarkdownTable(unitDetail.cheatsheet_table)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<div class="grammar-question-box mt-3">
|
<div class="grammar-question-box mt-3">
|
||||||
<p class="grammar-sentence-prompt">${escapeHtml(ex.sentence)}</p>
|
<p class="grammar-sentence-prompt">${escapeHtml(ex.sentence)}</p>
|
||||||
${ex.clue ? `<div class="clue-tag">💡 Подсказка: ${escapeHtml(ex.clue)}</div>` : ''}
|
${ex.clue ? `<div class="clue-tag">💡 Подсказка: ${escapeHtml(ex.clue)}</div>` : ''}
|
||||||
@@ -1559,6 +1630,18 @@ async function runGrammarNewStep(step, container) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const lessonCsBtn = document.getElementById('lesson-cheatsheet-toggle-btn');
|
||||||
|
const lessonCsDrawer = document.getElementById('lesson-cheatsheet-drawer');
|
||||||
|
if (lessonCsBtn && lessonCsDrawer) {
|
||||||
|
lessonCsBtn.addEventListener('click', () => {
|
||||||
|
const isHidden = lessonCsDrawer.classList.contains('hidden');
|
||||||
|
lessonCsDrawer.classList.toggle('hidden', !isHidden);
|
||||||
|
lessonCsBtn.innerHTML = !isHidden
|
||||||
|
? '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>'
|
||||||
|
: '<span>📋 Скрыть шпаргалку</span><i class="ph-caret-up"></i>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
container.querySelectorAll('.grammar-opt-btn').forEach(btn => {
|
container.querySelectorAll('.grammar-opt-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
container.querySelectorAll('.grammar-opt-btn').forEach(b => b.disabled = true);
|
container.querySelectorAll('.grammar-opt-btn').forEach(b => b.disabled = true);
|
||||||
@@ -1656,7 +1739,7 @@ async function runListeningStep(step, container) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await api.listening.getExercise(step.topic || 'all');
|
const data = await api.listening.getExercise(step.topic || 'all');
|
||||||
const scenario = data.scenario;
|
const scenario = (data && (data.scenario || data)) || {};
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="step-card-box">
|
<div class="step-card-box">
|
||||||
|
|||||||
+171
-17
@@ -3,7 +3,7 @@
|
|||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { escapeHtml, escapeRegex, slugify, showToast, playAudio } from './ui.js';
|
import { escapeHtml, escapeRegex, slugify, showToast, playAudio, playTextSpeech } from './ui.js';
|
||||||
import { getAllCards, fetchCards } from './dictionary.js';
|
import { getAllCards, fetchCards } from './dictionary.js';
|
||||||
|
|
||||||
// Reader State & Appearance Settings
|
// Reader State & Appearance Settings
|
||||||
@@ -28,6 +28,13 @@ let popupCurrentData = null;
|
|||||||
let readerScrollSaveTimer = null;
|
let readerScrollSaveTimer = null;
|
||||||
let popupScrollRaf = null;
|
let popupScrollRaf = null;
|
||||||
|
|
||||||
|
let isZenMode = false;
|
||||||
|
let zenHudIdleTimer = null;
|
||||||
|
|
||||||
|
export function getIsZenMode() {
|
||||||
|
return isZenMode;
|
||||||
|
}
|
||||||
|
|
||||||
export function getCurrentBook() {
|
export function getCurrentBook() {
|
||||||
return currentBook;
|
return currentBook;
|
||||||
}
|
}
|
||||||
@@ -134,6 +141,7 @@ function createBookCard(b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadBookshelf() {
|
export async function loadBookshelf() {
|
||||||
|
if (isZenMode) toggleZenMode(false);
|
||||||
const bookshelfContainer = document.getElementById('bookshelf-container');
|
const bookshelfContainer = document.getElementById('bookshelf-container');
|
||||||
const readerInterface = document.getElementById('reader-interface');
|
const readerInterface = document.getElementById('reader-interface');
|
||||||
const bookshelfGrid = document.getElementById('bookshelf-grid');
|
const bookshelfGrid = document.getElementById('bookshelf-grid');
|
||||||
@@ -1301,31 +1309,40 @@ export function positionWordPopup(targetElement) {
|
|||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
readerWordPopup.style.top = '';
|
readerWordPopup.style.top = '';
|
||||||
readerWordPopup.style.left = '';
|
readerWordPopup.style.left = '';
|
||||||
|
readerWordPopup.style.right = '';
|
||||||
|
readerWordPopup.style.bottom = '';
|
||||||
|
readerWordPopup.style.width = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rect = targetElement.getBoundingClientRect();
|
const rect = targetElement.getBoundingClientRect();
|
||||||
const popupWidth = 320;
|
const popupWidth = (readerWordPopup.offsetWidth && readerWordPopup.offsetWidth >= 280 && readerWordPopup.offsetWidth <= 380)
|
||||||
|
? readerWordPopup.offsetWidth
|
||||||
|
: 340;
|
||||||
const popupHeight = readerWordPopup.offsetHeight || 220;
|
const popupHeight = readerWordPopup.offsetHeight || 220;
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
let left = rect.left + (rect.width / 2) - (popupWidth / 2);
|
let left = rect.left + (rect.width / 2) - (popupWidth / 2);
|
||||||
if (left + popupWidth > viewportWidth - 12) {
|
if (left + popupWidth > viewportWidth - 16) {
|
||||||
left = viewportWidth - popupWidth - 12;
|
left = viewportWidth - popupWidth - 16;
|
||||||
}
|
}
|
||||||
if (left < 12) {
|
if (left < 16) {
|
||||||
left = 12;
|
left = 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const minTop = isZenMode ? 16 : 65;
|
||||||
let top = rect.bottom + 8;
|
let top = rect.bottom + 8;
|
||||||
if (top + popupHeight > viewportHeight - 12) {
|
if (top + popupHeight > viewportHeight - 16) {
|
||||||
if (rect.top - popupHeight - 8 >= 65 || rect.top > viewportHeight / 2) {
|
if (rect.top - popupHeight - 8 >= minTop || rect.top > viewportHeight / 2) {
|
||||||
top = Math.max(65, rect.top - popupHeight - 8);
|
top = Math.max(minTop, rect.top - popupHeight - 8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
readerWordPopup.style.top = `${Math.round(top)}px`;
|
readerWordPopup.style.top = `${Math.round(top)}px`;
|
||||||
readerWordPopup.style.left = `${Math.round(left)}px`;
|
readerWordPopup.style.left = `${Math.round(left)}px`;
|
||||||
|
readerWordPopup.style.right = 'auto';
|
||||||
|
readerWordPopup.style.bottom = 'auto';
|
||||||
|
readerWordPopup.style.width = `${popupWidth}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openWordPopup(term, contextSentence, targetElement, paragraphContext = '') {
|
export async function openWordPopup(term, contextSentence, targetElement, paragraphContext = '') {
|
||||||
@@ -1610,6 +1627,26 @@ export function updateReaderProgressBar() {
|
|||||||
} else if (readerScrollPctBadge) {
|
} else if (readerScrollPctBadge) {
|
||||||
readerScrollPctBadge.textContent = `${pct}%`;
|
readerScrollPctBadge.textContent = `${pct}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update Zen HUD elements if present
|
||||||
|
const zenHudTitle = document.getElementById('zen-hud-title');
|
||||||
|
const zenHudChapter = document.getElementById('zen-hud-chapter');
|
||||||
|
const zenHudProgress = document.getElementById('zen-hud-progress');
|
||||||
|
|
||||||
|
if (zenHudTitle && currentBook) {
|
||||||
|
zenHudTitle.textContent = currentBook.title || 'Книга';
|
||||||
|
zenHudTitle.title = currentBook.title || 'Книга';
|
||||||
|
}
|
||||||
|
if (zenHudChapter && currentBook && currentBook.chapters) {
|
||||||
|
const totalCh = currentBook.chapters.length;
|
||||||
|
const ch = currentBook.chapters[currentChapterIndex];
|
||||||
|
const chTitle = ch ? (ch.title || `Глава ${currentChapterIndex + 1}`) : `Глава ${currentChapterIndex + 1}`;
|
||||||
|
zenHudChapter.textContent = totalCh > 1 ? `Гл. ${currentChapterIndex + 1}/${totalCh}` : chTitle;
|
||||||
|
zenHudChapter.title = chTitle;
|
||||||
|
}
|
||||||
|
if (zenHudProgress) {
|
||||||
|
zenHudProgress.textContent = `${pct}%`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleReaderScroll() {
|
export function handleReaderScroll() {
|
||||||
@@ -1836,6 +1873,62 @@ export function positionSettingsPanel() {
|
|||||||
readerSettingsPanel.style.maxHeight = `${maxH}px`;
|
readerSettingsPanel.style.maxHeight = `${maxH}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Zen Focus Mode
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
export function wakeZenHud() {
|
||||||
|
if (!isZenMode) return;
|
||||||
|
const hud = document.getElementById('reader-zen-hud');
|
||||||
|
if (!hud) return;
|
||||||
|
hud.classList.remove('hud-idle');
|
||||||
|
if (zenHudIdleTimer) {
|
||||||
|
clearTimeout(zenHudIdleTimer);
|
||||||
|
}
|
||||||
|
zenHudIdleTimer = setTimeout(() => {
|
||||||
|
if (isZenMode && hud) {
|
||||||
|
hud.classList.add('hud-idle');
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleZenMode(forceState = null) {
|
||||||
|
const newState = (forceState !== null) ? Boolean(forceState) : !isZenMode;
|
||||||
|
if (isZenMode === newState) return;
|
||||||
|
isZenMode = newState;
|
||||||
|
|
||||||
|
const hud = document.getElementById('reader-zen-hud');
|
||||||
|
const zenBtn = document.getElementById('reader-zen-btn');
|
||||||
|
const readerSettingsPanel = document.getElementById('reader-settings-panel');
|
||||||
|
|
||||||
|
if (isZenMode) {
|
||||||
|
document.body.classList.add('zen-mode-active');
|
||||||
|
if (zenBtn) zenBtn.classList.add('active');
|
||||||
|
if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden');
|
||||||
|
if (hud) {
|
||||||
|
hud.classList.remove('hidden');
|
||||||
|
hud.classList.remove('hud-idle');
|
||||||
|
wakeZenHud();
|
||||||
|
}
|
||||||
|
if (!document.fullscreenElement && document.documentElement.requestFullscreen) {
|
||||||
|
document.documentElement.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('zen-mode-active');
|
||||||
|
if (zenBtn) zenBtn.classList.remove('active');
|
||||||
|
if (hud) {
|
||||||
|
hud.classList.add('hidden');
|
||||||
|
hud.classList.remove('hud-idle');
|
||||||
|
}
|
||||||
|
if (zenHudIdleTimer) {
|
||||||
|
clearTimeout(zenHudIdleTimer);
|
||||||
|
zenHudIdleTimer = null;
|
||||||
|
}
|
||||||
|
if (document.fullscreenElement && document.exitFullscreen) {
|
||||||
|
document.exitFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Reader Module Initialization
|
// Reader Module Initialization
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -1865,6 +1958,62 @@ export function initReader() {
|
|||||||
const readerBackBtn = document.getElementById('reader-back-btn');
|
const readerBackBtn = document.getElementById('reader-back-btn');
|
||||||
const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn');
|
const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn');
|
||||||
const readerTrainBtn = document.getElementById('reader-train-btn');
|
const readerTrainBtn = document.getElementById('reader-train-btn');
|
||||||
|
const readerZenBtn = document.getElementById('reader-zen-btn');
|
||||||
|
const zenHudExitBtn = document.getElementById('zen-hud-exit-btn');
|
||||||
|
|
||||||
|
if (readerZenBtn) {
|
||||||
|
readerZenBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleZenMode();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zenHudExitBtn) {
|
||||||
|
zenHudExitBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleZenMode(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wake HUD on user activity during zen mode
|
||||||
|
['mousemove', 'touchstart', 'scroll'].forEach(evt => {
|
||||||
|
window.addEventListener(evt, () => {
|
||||||
|
if (isZenMode) wakeZenHud();
|
||||||
|
}, { passive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard shortcut for Zen Mode (F / f / а / А) and exit on Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
const tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
||||||
|
if (tag === 'input' || tag === 'textarea' || (e.target && e.target.isContentEditable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readerView = document.getElementById('reader-view');
|
||||||
|
const readerInterface = document.getElementById('reader-interface');
|
||||||
|
const isReaderActive = readerView && !readerView.classList.contains('hidden') &&
|
||||||
|
readerInterface && !readerInterface.classList.contains('hidden');
|
||||||
|
|
||||||
|
if (!isReaderActive) return;
|
||||||
|
|
||||||
|
if (e.key === 'Escape' && isZenMode) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleZenMode(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((e.key === 'f' || e.key === 'F' || e.key === 'а' || e.key === 'А') && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleZenMode();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Native fullscreen sync
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
if (!document.fullscreenElement && isZenMode) {
|
||||||
|
toggleZenMode(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const popupCloseBtn = document.getElementById('popup-close-btn');
|
const popupCloseBtn = document.getElementById('popup-close-btn');
|
||||||
const popupSaveBtn = document.getElementById('popup-save-btn');
|
const popupSaveBtn = document.getElementById('popup-save-btn');
|
||||||
@@ -2102,6 +2251,7 @@ export function initReader() {
|
|||||||
|
|
||||||
const popupAudioBtn = document.getElementById('popup-audio-btn');
|
const popupAudioBtn = document.getElementById('popup-audio-btn');
|
||||||
const popupAudioSentenceBtn = document.getElementById('popup-audio-sentence-btn');
|
const popupAudioSentenceBtn = document.getElementById('popup-audio-sentence-btn');
|
||||||
|
const popupAudioSentenceSlowBtn = document.getElementById('popup-audio-sentence-slow-btn');
|
||||||
const popupAudioRuBtn = document.getElementById('popup-audio-ru-btn');
|
const popupAudioRuBtn = document.getElementById('popup-audio-ru-btn');
|
||||||
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
|
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
|
||||||
const popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
|
const popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
|
||||||
@@ -2116,9 +2266,7 @@ export function initReader() {
|
|||||||
popupAudioBtn.addEventListener('click', (e) => {
|
popupAudioBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!popupCurrentData || !popupCurrentData.term) return;
|
if (!popupCurrentData || !popupCurrentData.term) return;
|
||||||
const term = popupCurrentData.term;
|
playTextSpeech(popupCurrentData.term, 'en');
|
||||||
const url = popupCurrentData.audioUrl || `/api/tts?text=${encodeURIComponent(term)}&lang=en`;
|
|
||||||
playAudio(url);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2126,9 +2274,15 @@ export function initReader() {
|
|||||||
popupAudioSentenceBtn.addEventListener('click', (e) => {
|
popupAudioSentenceBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
|
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
|
||||||
const sentence = popupCurrentData.contextSentence;
|
playTextSpeech(popupCurrentData.contextSentence, 'en', 1.0);
|
||||||
const url = `/api/tts?text=${encodeURIComponent(sentence)}&lang=en`;
|
});
|
||||||
playAudio(url);
|
}
|
||||||
|
|
||||||
|
if (popupAudioSentenceSlowBtn) {
|
||||||
|
popupAudioSentenceSlowBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
|
||||||
|
playTextSpeech(popupCurrentData.contextSentence, 'en', 0.75);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2137,8 +2291,7 @@ export function initReader() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const ruText = (popupSentenceRu && popupSentenceRu.textContent || '').trim();
|
const ruText = (popupSentenceRu && popupSentenceRu.textContent || '').trim();
|
||||||
if (!ruText || ruText.startsWith('(') || ruText.startsWith('Ошибка')) return;
|
if (!ruText || ruText.startsWith('(') || ruText.startsWith('Ошибка')) return;
|
||||||
const url = `/api/tts?text=${encodeURIComponent(ruText)}&lang=ru`;
|
playTextSpeech(ruText, 'ru', 1.0);
|
||||||
playAudio(url);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2450,6 +2603,7 @@ export function initReader() {
|
|||||||
|
|
||||||
if (readerBackBtn) {
|
if (readerBackBtn) {
|
||||||
readerBackBtn.addEventListener('click', () => {
|
readerBackBtn.addEventListener('click', () => {
|
||||||
|
if (isZenMode) toggleZenMode(false);
|
||||||
window.location.hash = '#reader';
|
window.location.hash = '#reader';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { escapeHtml, escapeRegex, shuffleArray, showToast, openModal, closeModal, playAudio } from './ui.js';
|
import { escapeHtml, escapeRegex, shuffleArray, showToast, openModal, closeModal, playAudio, playTextSpeech, cleanPromptTranslation } from './ui.js';
|
||||||
import { getAllCards } from './dictionary.js';
|
import { getAllCards } from './dictionary.js';
|
||||||
|
|
||||||
let srsQueue = [];
|
let srsQueue = [];
|
||||||
@@ -53,6 +53,7 @@ export function initSrs() {
|
|||||||
const fcBackContextBox = document.getElementById('fc-back-context-box');
|
const fcBackContextBox = document.getElementById('fc-back-context-box');
|
||||||
const fcBackContext = document.getElementById('fc-back-context');
|
const fcBackContext = document.getElementById('fc-back-context');
|
||||||
const fcBackContextAudioBtn = document.getElementById('fc-back-context-audio-btn');
|
const fcBackContextAudioBtn = document.getElementById('fc-back-context-audio-btn');
|
||||||
|
const fcBackContextAudioSlowBtn = document.getElementById('fc-back-context-audio-slow-btn');
|
||||||
const fcBackNotesBox = document.getElementById('fc-back-notes-box');
|
const fcBackNotesBox = document.getElementById('fc-back-notes-box');
|
||||||
const fcBackNotes = document.getElementById('fc-back-notes');
|
const fcBackNotes = document.getElementById('fc-back-notes');
|
||||||
const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap');
|
const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap');
|
||||||
@@ -330,7 +331,7 @@ export function initSrs() {
|
|||||||
fcBackPos.style.display = card.part_of_speech ? 'inline-block' : 'none';
|
fcBackPos.style.display = card.part_of_speech ? 'inline-block' : 'none';
|
||||||
} else {
|
} else {
|
||||||
fcFrontDirBadge.textContent = 'RU ➔ EN';
|
fcFrontDirBadge.textContent = 'RU ➔ EN';
|
||||||
fcFrontMain.textContent = card.translation;
|
fcFrontMain.textContent = cleanPromptTranslation(card.translation);
|
||||||
fcFrontPos.textContent = card.part_of_speech || '';
|
fcFrontPos.textContent = card.part_of_speech || '';
|
||||||
fcFrontPhoneticWrap.style.display = 'none';
|
fcFrontPhoneticWrap.style.display = 'none';
|
||||||
fcHintBtn.style.display = 'none';
|
fcHintBtn.style.display = 'none';
|
||||||
@@ -352,6 +353,9 @@ export function initSrs() {
|
|||||||
if (fcBackContextAudioBtn) {
|
if (fcBackContextAudioBtn) {
|
||||||
fcBackContextAudioBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
|
fcBackContextAudioBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
|
||||||
}
|
}
|
||||||
|
if (fcBackContextAudioSlowBtn) {
|
||||||
|
fcBackContextAudioSlowBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fcFrontAudioBtn) {
|
if (fcFrontAudioBtn) {
|
||||||
@@ -359,8 +363,7 @@ export function initSrs() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const card = srsQueue[srsCurrentIndex];
|
const card = srsQueue[srsCurrentIndex];
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
|
playTextSpeech(card.term, 'en', 1.0);
|
||||||
playAudio(url);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,8 +372,7 @@ export function initSrs() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const card = srsQueue[srsCurrentIndex];
|
const card = srsQueue[srsCurrentIndex];
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
|
playTextSpeech(card.term, 'en', 1.0);
|
||||||
playAudio(url);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,8 +381,16 @@ export function initSrs() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const card = srsQueue[srsCurrentIndex];
|
const card = srsQueue[srsCurrentIndex];
|
||||||
if (!card || !card.context_sentence) return;
|
if (!card || !card.context_sentence) return;
|
||||||
const url = card.context_audio_url || `/api/tts?text=${encodeURIComponent(card.context_sentence)}&lang=en`;
|
playTextSpeech(card.context_sentence, 'en', 1.0);
|
||||||
playAudio(url);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fcBackContextAudioSlowBtn) {
|
||||||
|
fcBackContextAudioSlowBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const card = srsQueue[srsCurrentIndex];
|
||||||
|
if (!card || !card.context_sentence) return;
|
||||||
|
playTextSpeech(card.context_sentence, 'en', 0.75);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,18 +457,18 @@ export function initSrs() {
|
|||||||
|
|
||||||
if (quizAudioBtn) quizAudioBtn.classList.remove('hidden');
|
if (quizAudioBtn) quizAudioBtn.classList.remove('hidden');
|
||||||
|
|
||||||
const correctText = card.translation;
|
const correctText = cleanPromptTranslation(card.translation);
|
||||||
const all = getAllCards();
|
const all = getAllCards();
|
||||||
const candidates = all
|
const candidates = all
|
||||||
.filter(c => c.id !== card.id && c.translation && c.translation !== correctText)
|
.filter(c => c.id !== card.id && c.translation && cleanPromptTranslation(c.translation) !== correctText)
|
||||||
.map(c => c.translation);
|
.map(c => cleanPromptTranslation(c.translation));
|
||||||
|
|
||||||
shuffleArray(candidates);
|
shuffleArray(candidates);
|
||||||
const options = [correctText, ...candidates.slice(0, 3)];
|
let options = [correctText, ...candidates.slice(0, 3)];
|
||||||
while (options.length < 4) {
|
while (options.length < 4) {
|
||||||
options.push(`Вариант ${options.length + 1}`);
|
options.push(`Вариант ${options.length + 1}`);
|
||||||
}
|
}
|
||||||
shuffleArray(options);
|
options = shuffleArray(options);
|
||||||
quizCurrentOptions = options;
|
quizCurrentOptions = options;
|
||||||
|
|
||||||
quizOptionsGrid.innerHTML = '';
|
quizOptionsGrid.innerHTML = '';
|
||||||
@@ -491,7 +501,7 @@ export function initSrs() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (card.audio_url) playAudio(card.audio_url);
|
playTextSpeech(card.term, 'en');
|
||||||
|
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
quizFeedbackBox.className = 'trainer-feedback-box feedback-correct';
|
quizFeedbackBox.className = 'trainer-feedback-box feedback-correct';
|
||||||
@@ -523,8 +533,7 @@ export function initSrs() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const card = srsQueue[srsCurrentIndex];
|
const card = srsQueue[srsCurrentIndex];
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
|
playTextSpeech(card.term, 'en', 1.0);
|
||||||
playAudio(url);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,7 +552,7 @@ export function initSrs() {
|
|||||||
typingRatingBar.classList.add('hidden');
|
typingRatingBar.classList.add('hidden');
|
||||||
|
|
||||||
typingPos.textContent = card.part_of_speech || '';
|
typingPos.textContent = card.part_of_speech || '';
|
||||||
typingPromptRu.textContent = card.translation;
|
typingPromptRu.textContent = cleanPromptTranslation(card.translation);
|
||||||
if (card.context_sentence) {
|
if (card.context_sentence) {
|
||||||
typingContextHint.textContent = `Контекст: “${card.context_sentence}”`;
|
typingContextHint.textContent = `Контекст: “${card.context_sentence}”`;
|
||||||
typingContextHint.classList.remove('hidden');
|
typingContextHint.classList.remove('hidden');
|
||||||
@@ -565,7 +574,7 @@ export function initSrs() {
|
|||||||
const target = card.term.trim().toLowerCase();
|
const target = card.term.trim().toLowerCase();
|
||||||
const isMatch = userInput === target;
|
const isMatch = userInput === target;
|
||||||
|
|
||||||
if (card.audio_url) playAudio(card.audio_url);
|
playTextSpeech(card.term, 'en');
|
||||||
typingInput.disabled = true;
|
typingInput.disabled = true;
|
||||||
|
|
||||||
if (isMatch) {
|
if (isMatch) {
|
||||||
@@ -590,7 +599,7 @@ export function initSrs() {
|
|||||||
clozeRatingBar.classList.add('hidden');
|
clozeRatingBar.classList.add('hidden');
|
||||||
|
|
||||||
clozePos.textContent = card.part_of_speech || '';
|
clozePos.textContent = card.part_of_speech || '';
|
||||||
clozeTargetTranslation.textContent = card.translation;
|
clozeTargetTranslation.textContent = cleanPromptTranslation(card.translation);
|
||||||
|
|
||||||
const context = card.context_sentence || `The word is ${card.term}.`;
|
const context = card.context_sentence || `The word is ${card.term}.`;
|
||||||
const re = new RegExp(escapeRegex(card.term), 'gi');
|
const re = new RegExp(escapeRegex(card.term), 'gi');
|
||||||
@@ -611,7 +620,7 @@ export function initSrs() {
|
|||||||
const target = card.term.trim().toLowerCase();
|
const target = card.term.trim().toLowerCase();
|
||||||
const isMatch = userInput === target;
|
const isMatch = userInput === target;
|
||||||
|
|
||||||
if (card.audio_url) playAudio(card.audio_url);
|
playTextSpeech(card.term, 'en');
|
||||||
clozeInput.disabled = true;
|
clozeInput.disabled = true;
|
||||||
|
|
||||||
if (isMatch) {
|
if (isMatch) {
|
||||||
@@ -635,12 +644,12 @@ export function initSrs() {
|
|||||||
dictationDetailsBox.classList.add('hidden');
|
dictationDetailsBox.classList.add('hidden');
|
||||||
dictationRatingBar.classList.add('hidden');
|
dictationRatingBar.classList.add('hidden');
|
||||||
dictationHintText.classList.add('hidden');
|
dictationHintText.classList.add('hidden');
|
||||||
dictationHintText.textContent = card.translation;
|
dictationHintText.textContent = cleanPromptTranslation(card.translation);
|
||||||
|
|
||||||
dictationInput.value = '';
|
dictationInput.value = '';
|
||||||
dictationInput.disabled = false;
|
dictationInput.disabled = false;
|
||||||
|
|
||||||
const play = () => { if (card.audio_url) playAudio(card.audio_url); };
|
const play = () => { playTextSpeech(card.term, 'en'); };
|
||||||
dictationBigPlayBtn.onclick = play;
|
dictationBigPlayBtn.onclick = play;
|
||||||
dictationReplayBtn.onclick = play;
|
dictationReplayBtn.onclick = play;
|
||||||
play();
|
play();
|
||||||
|
|||||||
+133
-4
@@ -27,12 +27,131 @@ export function slugify(text) {
|
|||||||
|
|
||||||
export function shuffleArray(array) {
|
export function shuffleArray(array) {
|
||||||
if (!array || !Array.isArray(array)) return [];
|
if (!array || !Array.isArray(array)) return [];
|
||||||
const arr = [...array];
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
for (let i = arr.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
[array[i], array[j]] = [array[j], array[i]];
|
||||||
}
|
}
|
||||||
return arr;
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanPromptTranslation(text) {
|
||||||
|
if (!text || typeof text !== 'string') return '';
|
||||||
|
// Strip grammatical inflection notes that contain Latin words to prevent spoilers in tests
|
||||||
|
return text.replace(/\s*\([а-яА-ЯёЁ0-9\s.,-]*\bот\s+[a-zA-Z\x27-]+\)/gi, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTranslationWithSpoiler(text) {
|
||||||
|
if (!text || typeof text !== 'string') return '';
|
||||||
|
const noteMatch = text.match(/\s*\(([а-яА-ЯёЁ0-9\s.,-]*\bот\s+[a-zA-Z\x27-]+)\)/i);
|
||||||
|
if (!noteMatch) return escapeHtml(text);
|
||||||
|
const cleanText = text.replace(noteMatch[0], '').trim();
|
||||||
|
const noteText = noteMatch[1];
|
||||||
|
return `${escapeHtml(cleanText)} <span class="grammar-hint-badge" title="Нажмите, чтобы показать форму" onclick="this.classList.toggle('active')"><span class="badge-label">ℹ️ форма</span><span class="badge-body">${escapeHtml(noteText)}</span></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Lightweight Native Markdown Parsers (Tables & Notes for Murphy Grammar)
|
||||||
|
// Zero-dependency, safe escaping first, pure ES6
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
export function renderMarkdownInline(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
let str = escapeHtml(text);
|
||||||
|
// Bold: **text**
|
||||||
|
str = str.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
// Inline code: `text`
|
||||||
|
str = str.replace(/`([^`]+)`/g, '<code class="grammar-inline-code">$1</code>');
|
||||||
|
// Italic: *text* (excluding already formatted strong)
|
||||||
|
str = str.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMarkdownTable(mdText) {
|
||||||
|
if (!mdText || typeof mdText !== 'string' || !mdText.trim()) return '';
|
||||||
|
const rawLines = mdText.trim().split(/\r?\n/).map(l => l.trim()).filter(l => l.length > 0);
|
||||||
|
const tableLines = rawLines.filter(l => l.startsWith('|') && l.endsWith('|'));
|
||||||
|
if (tableLines.length < 2) {
|
||||||
|
return `<div class="grammar-table-raw"><pre>${escapeHtml(mdText)}</pre></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitRow = (rowStr) => {
|
||||||
|
const inner = rowStr.replace(/^\|/, '').replace(/\|$/, '');
|
||||||
|
return inner.split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = splitRow(tableLines[0]);
|
||||||
|
const alignRow = splitRow(tableLines[1]);
|
||||||
|
const alignments = alignRow.map(col => {
|
||||||
|
const tc = col.trim();
|
||||||
|
if (tc.startsWith(':') && tc.endsWith(':')) return 'center';
|
||||||
|
if (tc.endsWith(':')) return 'right';
|
||||||
|
return 'left';
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (let r = 2; r < tableLines.length; r++) {
|
||||||
|
rows.push(splitRow(tableLines[r]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="grammar-table-wrap"><table class="grammar-cheatsheet-table"><thead><tr>';
|
||||||
|
headers.forEach((h, idx) => {
|
||||||
|
const align = alignments[idx] || 'left';
|
||||||
|
html += `<th style="text-align: ${align}">${renderMarkdownInline(h)}</th>`;
|
||||||
|
});
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
html += '<tr>';
|
||||||
|
row.forEach((cell, idx) => {
|
||||||
|
const align = alignments[idx] || 'left';
|
||||||
|
html += `<td style="text-align: ${align}">${renderMarkdownInline(cell)}</td>`;
|
||||||
|
});
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMarkdownNotes(mdText) {
|
||||||
|
if (!mdText || typeof mdText !== 'string' || !mdText.trim()) return '';
|
||||||
|
const lines = mdText.split(/\r?\n/);
|
||||||
|
const out = [];
|
||||||
|
let inList = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const trimmed = lines[i].trim();
|
||||||
|
|
||||||
|
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
||||||
|
if (!inList) {
|
||||||
|
out.push('<ul class="grammar-notes-list">');
|
||||||
|
inList = true;
|
||||||
|
}
|
||||||
|
const itemText = trimmed.replace(/^[-*]\s+/, '');
|
||||||
|
out.push(`<li>${renderMarkdownInline(itemText)}</li>`);
|
||||||
|
continue;
|
||||||
|
} else if (inList) {
|
||||||
|
out.push('</ul>');
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('#### ')) {
|
||||||
|
out.push(`<h5 class="grammar-note-h5">${renderMarkdownInline(trimmed.replace(/^####\s+/, ''))}</h5>`);
|
||||||
|
} else if (trimmed.startsWith('### ')) {
|
||||||
|
out.push(`<h4 class="grammar-note-h4">${renderMarkdownInline(trimmed.replace(/^###\s+/, ''))}</h4>`);
|
||||||
|
} else if (trimmed.startsWith('## ')) {
|
||||||
|
out.push(`<h3 class="grammar-note-h3">${renderMarkdownInline(trimmed.replace(/^##\s+/, ''))}</h3>`);
|
||||||
|
} else if (trimmed.startsWith('> ')) {
|
||||||
|
out.push(`<blockquote class="grammar-note-quote">${renderMarkdownInline(trimmed.replace(/^>\s+/, ''))}</blockquote>`);
|
||||||
|
} else if (trimmed === '') {
|
||||||
|
// blank line separator
|
||||||
|
} else {
|
||||||
|
out.push(`<p class="grammar-note-p">${renderMarkdownInline(trimmed)}</p>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inList) {
|
||||||
|
out.push('</ul>');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="grammar-notes-box">${out.join('\n')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -154,6 +273,11 @@ export function closeModal(modalElem) {
|
|||||||
// View Switcher Helper
|
// View Switcher Helper
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
export function switchView(targetViewId) {
|
export function switchView(targetViewId) {
|
||||||
|
if (targetViewId !== 'reader-view') {
|
||||||
|
document.body.classList.remove('zen-mode-active');
|
||||||
|
const hud = document.getElementById('reader-zen-hud');
|
||||||
|
if (hud) hud.classList.add('hidden');
|
||||||
|
}
|
||||||
const allViews = [
|
const allViews = [
|
||||||
'dictionary-view',
|
'dictionary-view',
|
||||||
'srs-view',
|
'srs-view',
|
||||||
@@ -270,6 +394,11 @@ export const ui = {
|
|||||||
updateHeaderStats,
|
updateHeaderStats,
|
||||||
playAudio,
|
playAudio,
|
||||||
playTextSpeech,
|
playTextSpeech,
|
||||||
|
cleanPromptTranslation,
|
||||||
|
formatTranslationWithSpoiler,
|
||||||
|
renderMarkdownInline,
|
||||||
|
renderMarkdownTable,
|
||||||
|
renderMarkdownNotes,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ui;
|
export default ui;
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
// Service Worker for Notes English PWA (Stage 2)
|
// Service Worker for Notes English PWA (Stage 2)
|
||||||
// Strategy: Cache-first for static assets, Network-first for API calls.
|
// Strategy: Cache-first for static assets, Network-first for API calls.
|
||||||
|
|
||||||
const CACHE_NAME = 'notes-v6.7';
|
const CACHE_NAME = 'notes-v6.9';
|
||||||
const OFFLINE_URL = '/';
|
const OFFLINE_URL = '/';
|
||||||
|
|
||||||
const PRECACHE_URLS = [
|
const PRECACHE_URLS = [
|
||||||
@@ -22,6 +22,7 @@ const PRECACHE_URLS = [
|
|||||||
'/static/js/modules/reader.js',
|
'/static/js/modules/reader.js',
|
||||||
'/static/js/modules/cefr.js',
|
'/static/js/modules/cefr.js',
|
||||||
'/static/js/modules/grammar.js',
|
'/static/js/modules/grammar.js',
|
||||||
|
'/static/js/modules/feedback.js',
|
||||||
'/manifest.json',
|
'/manifest.json',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 📋 Пользовательские репорты & Запросы на доработку (study.qpid.pro)
|
||||||
|
|
||||||
|
> 🔄 Данный файл автоматически синхронизируется с виджетом обратной связи на сайте.
|
||||||
|
> AI-ассистент (Antigravity) считывает этот файл для анализа найденных багов и планирования задач.
|
||||||
|
|
||||||
|
**Всего репортов:** 8 | **Последняя синхронизация:** сейчас
|
||||||
|
|
||||||
|
## 🔴 Активные репорты (Требуют внимания / В работе)
|
||||||
|
|
||||||
|
🎉 *Активных багов или задач нет! Все известные репорты закрыты.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Решенные / Закрытые задачи
|
||||||
|
|
||||||
|
- [x] **#8 💡 (2026-10-08 09:28:40)**: сделать кнопки при озвучивании предложения или текста 0.7-1 скорость. Предложения иногда неудобно слушать, так как диктор может сказать быстро, хочется замедлить *(Страница: #dictionary)*
|
||||||
|
- [x] **#7 🐛 (2026-10-08 09:22:52)**: Проверить, верно ли применяется голос озвучки, после смены его в меню настройки. Такое чувство, что не везде применяется озвучка *(Страница: #lesson)*
|
||||||
|
- [x] **#6 💡 (2026-10-08 09:21:19)**: в SRS тестах чтобы не было упражнений по грамматике, там должны быть карточки исключительно касающиеся слов. *(Страница: #dictionary)*
|
||||||
|
- [x] **#5 💡 (2026-10-08 09:19:07)**: В тестах не показывать подсказку (русский перевод) но сделать кнопку "подсказка", тогда перевод показывается нужного слова, но за это снимается бал. Например за вопрос дается 1 бал, так я получу с подсказкой 0.8 или около того *(Страница: #cefr/A1)*
|
||||||
|
- [x] **#4 💡 (2026-10-08 09:14:12)**: Чтобы можно было нажать на любой текст, будь то в тесте или еще где, (кроме элементов управление) и добавить в словарь. А то иногда встречаются места, где читаешь предложение и не понимаешь, что за слово там ... *(Страница: #cefr/A1)*
|
||||||
|
- [x] **#3 💡 (2026-10-08 09:04:56)**: В меню боковом измени скролбар на нестандартный. В текущей цветовой палитре и оформлении, стандартный скролбар сильно выделяется *(Страница: #dictionary)*
|
||||||
|
- [x] **#2 🐛 (2026-10-08 09:01:46)**: Вкладка "мой словарь", карандишик (изменить слово), окно открывается слишком большое, без масштабирования вкладки его невозможно закрыть. Еще бы добавить функцию закрытия окна по пустому месту или по кнопке Esc *(Страница: #dictionary)*
|
||||||
|
- [x] **#1 🐛 (2026-10-08 09:00:18)**: Вкладка "мой словарь", звук слов не воспроизводится, но предложения озвучиваются без проблем. *(Страница: #dictionary)*
|
||||||
+829
-754
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -18,6 +18,7 @@ services:
|
|||||||
- DB_PATH=/app/data/notes.db
|
- DB_PATH=/app/data/notes.db
|
||||||
- OLLAMA_BASE_URL=http://ollama:11434
|
- OLLAMA_BASE_URL=http://ollama:11434
|
||||||
- OLLAMA_MODEL=qwen2.5:3b
|
- OLLAMA_MODEL=qwen2.5:3b
|
||||||
|
- OLLAMA_KEEP_ALIVE=1h
|
||||||
depends_on:
|
depends_on:
|
||||||
- ollama
|
- ollama
|
||||||
|
|
||||||
@@ -30,4 +31,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data/ollama:/root/.ollama
|
- ./data/ollama:/root/.ollama
|
||||||
environment:
|
environment:
|
||||||
- OLLAMA_KEEP_ALIVE=24h
|
- OLLAMA_KEEP_ALIVE=1h
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 🧠 Специализированная модель перевода и архитектура ИИ (Qwen2.5-0.5B + CTranslate2)
|
||||||
|
|
||||||
|
Архитектурное решение по переходу на узкоспециализированную модель перевода для интерактивной читалки книг и разделению ролей ИИ в платформе Study Qpid Pro (`study.qpid.pro`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Архитектурный выбор: Qwen2.5-0.5B-Instruct vs MarianMT (Helsinki-NLP)
|
||||||
|
|
||||||
|
Исследованы два класса узких моделей для локального размещения на сервере (AMD Ryzen 9 5950X, 6 vCPU, без GPU, ~7.6 GB RAM доступно):
|
||||||
|
|
||||||
|
| Критерий | Helsinki-NLP/opus-mt-en-ru (77M) | Qwen2.5-0.5B-Instruct (~490M) |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Архитектура** | Seq2Seq (Encoder-Decoder) | Causal LLM (Decoder-only) |
|
||||||
|
| **Выходной формат** | Только сплошной текст | Строгий JSON (`sentence_ru` + `word_meaning`) |
|
||||||
|
| **Выделение значения слова** | Требует внешнего aligner / словаря | Выделяет контекстное значение напрямую |
|
||||||
|
| **Поддержка контекста абзаца**| Слабая (обучена по предложениям) | Отличная (учитывает промпт и контекст) |
|
||||||
|
| **Инференс на CPU (CTranslate2)**| 10–20 мс | 100–150 мс |
|
||||||
|
| **Потребление RAM** | ~150 МБ | ~350–400 МБ |
|
||||||
|
|
||||||
|
**Выбор**: **`Qwen2.5-0.5B-Instruct`** с дообучением под задачу контекстного перевода предложений и извлечения целевых слов в структурированный JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Разделение ответственности компонентов (Clean Separation of Concerns)
|
||||||
|
|
||||||
|
1. **Читалка книг (На лету)**:
|
||||||
|
- **Модель**: Дообученная `Qwen2.5-0.5B-Instruct`.
|
||||||
|
- **Движок**: **CTranslate2** (`int8`), загруженный in-process прямо в Python-память FastAPI (`app/ai.py`).
|
||||||
|
- **Задержка**: ~100–150 мс (без оверхеда на HTTP/сокеты/Docker).
|
||||||
|
- **Задача**: `(sentence, target_word, context, dict_hints) -> JSON {"sentence_ru", "word_meaning"}`.
|
||||||
|
|
||||||
|
2. **Итоговый разбор урока (Debrief)**:
|
||||||
|
- **Модель**: `Qwen2.5-3B` через Ollama с параметром `keep_alive: "5m"` (подгружается по запросу раз в 20–30 минут, после чего выгружается из памяти).
|
||||||
|
- **Резерв**: Мгновенный детерминированный движок `generate_deterministic_debrief()` (0 мс, по формулам Мерфи).
|
||||||
|
|
||||||
|
3. **Синтез речи (Озвучка)**:
|
||||||
|
- **Движок**: Piper Neural TTS (ONNX VITS).
|
||||||
|
- **Статус**: 100% автономен, не зависит от LLM, задержка 20–40 мс.
|
||||||
|
|
||||||
|
4. **Генерация и проверка тестов**:
|
||||||
|
- **Принцип**: 100% алгоритмический детерминизм (каталог 142 юнитов Мерфи, алгоритм SM-2/FSRS в SRS, алгоритмический майнер предложений из книг). Нейросети к проверке тестов намеренно не привлекаются для исключения галлюцинаций.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ Преимущества CTranslate2 над HTTP-демоном Ollama
|
||||||
|
|
||||||
|
1. **In-Process инференс**: `import ctranslate2` прямо в бэкенде. Ноль оверхеда на сеть, TCP-сокеты и сериализацию между контейнерами.
|
||||||
|
2. **Низкоуровневая оптимизация**: Скомпилирован под AVX2 / FMA для AMD Ryzen.
|
||||||
|
3. **Квантование int8**: Минимальный расход RAM (~350 МБ) при сохранении 99% точности.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Пайплайн локального дообучения (Fine-Tuning) на VPS
|
||||||
|
|
||||||
|
Поскольку модель имеет всего ~490M параметров, её можно дообучить прямо на процессоре сервера без GPU:
|
||||||
|
|
||||||
|
### 1. Формат обучающего датасета (`train.jsonl`, 1500–3000 примеров)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "Ты — переводчик художественной литературы. Переведи указанное предложение и выдели значение целевого слова. Ответь строго JSON: {\"sentence_ru\": \"...\", \"word_meaning\": \"...\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Контекст абзаца: Mr. Dursley stopped dead. Fear flooded him.\nПредложение: He stared at the people in cloaks.\nЦелевое слово: cloaks\nСловарные подсказки: cloak — плащ, мантия"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "{\"sentence_ru\": \"Он уставился на людей в мантиях.\", \"word_meaning\": \"мантиях\"}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Дообучение через PyTorch CPU + PEFT (LoRA)
|
||||||
|
- Инструменты: `torch (cpu)`, `transformers`, `peft`, `trl`.
|
||||||
|
- Параметры: `r=16`, `lora_alpha=32`, `target_modules=["q_proj", "k_proj", "v_proj", "o_proj"]`.
|
||||||
|
- Время обучения на 6 ядрах Ryzen 9 5950X: **~25–45 минут**.
|
||||||
|
- Расход RAM в процессе: **~3.5–4.5 ГБ** (на сервере доступно ~7.6 ГБ).
|
||||||
|
|
||||||
|
### 3. Конвертация в CTranslate2
|
||||||
|
```bash
|
||||||
|
# 1. Слияние адаптера LoRA с базовой моделью:
|
||||||
|
python3 merge_lora.py --base Qwen/Qwen2.5-0.5B-Instruct --adapter ./final_adapter --output ./qwen-0.5b-merged
|
||||||
|
|
||||||
|
# 2. Конвертация в int8 CTranslate2 модель:
|
||||||
|
ct2-transformers-converter --model ./qwen-0.5b-merged \
|
||||||
|
--output_dir /home/ai-projects/services/study.qpid.pro/models/qwen05b-ct2 \
|
||||||
|
--quantization int8
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Подключение в `app/ai.py`
|
||||||
|
Заменяем сетевой HTTP-клиент к Ollama для читалки на прямой вызов `ctranslate2.Generator`, сохранив кэширование SQLite.
|
||||||
@@ -16,3 +16,4 @@ typing-inspection==0.4.4
|
|||||||
typing_extensions==4.16.0
|
typing_extensions==4.16.0
|
||||||
uvicorn==0.53.0
|
uvicorn==0.53.0
|
||||||
pypdf>=5.0.0
|
pypdf>=5.0.0
|
||||||
|
pytest>=8.0.0
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
|||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path("/home/ai-projects/services/study.qpid.pro/data/notes.db")
|
||||||
|
TARGET_MD = Path("/home/ai-projects/services/study.qpid.pro/data/USER_REPORTS.md")
|
||||||
|
MEMORY_MD = Path("/home/ai-projects/memory/USER_REPORTS.md")
|
||||||
|
|
||||||
|
def sync():
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
print("Database not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT f.id, f.category, f.message, f.context_url, f.context_data_json, f.status, f.created_at, f.updated_at, u.username
|
||||||
|
FROM feedback_reports f
|
||||||
|
JOIN users u ON f.user_id = u.id
|
||||||
|
ORDER BY f.id DESC
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# 📋 Пользовательские репорты & Запросы на доработку (study.qpid.pro)",
|
||||||
|
"",
|
||||||
|
"> 🔄 Данный файл автоматически синхронизируется с виджетом обратной связи на сайте.",
|
||||||
|
"> AI-ассистент (Antigravity) считывает этот файл для анализа найденных багов и планирования задач.",
|
||||||
|
"",
|
||||||
|
f"**Всего репортов:** {len(rows)} | **Последняя синхронизация:** сейчас",
|
||||||
|
"",
|
||||||
|
"## 🔴 Активные репорты (Требуют внимания / В работе)",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
active_rows = [r for r in rows if r["status"] in ("new", "in_progress")]
|
||||||
|
resolved_rows = [r for r in rows if r["status"] in ("resolved", "closed")]
|
||||||
|
|
||||||
|
if not active_rows:
|
||||||
|
lines.append("🎉 *Активных багов или задач нет! Все известные репорты закрыты.*")
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
for r in active_rows:
|
||||||
|
cat_emoji = "🐛 БАГ" if r["category"] == "bug" else ("💡 ФИЧА" if r["category"] == "feature" else "📝 ЗАМЕТКА")
|
||||||
|
status_icon = "🆕 Новый" if r["status"] == "new" else "⏳ В работе"
|
||||||
|
|
||||||
|
ctx_extra = ""
|
||||||
|
if r["context_data_json"]:
|
||||||
|
try:
|
||||||
|
c_dict = json.loads(r["context_data_json"])
|
||||||
|
parts = []
|
||||||
|
if "screen" in c_dict:
|
||||||
|
parts.append(f"Экран: {c_dict['screen']}")
|
||||||
|
if "device" in c_dict:
|
||||||
|
parts.append(f"Устройство: {c_dict['device']}")
|
||||||
|
if parts:
|
||||||
|
ctx_extra = f" ({', '.join(parts)})"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lines.append(f"### #{r['id']} [{cat_emoji}] — {status_icon} (`{r['created_at']}`)")
|
||||||
|
lines.append(f"- **Пользователь**: `{r['username']}`")
|
||||||
|
if r["context_url"]:
|
||||||
|
lines.append(f"- **Раздел/Страница**: `{r['context_url']}`{ctx_extra}")
|
||||||
|
lines.append(f"- **Текст**:\n> {r['message'].strip()}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"## ✅ Решенные / Закрытые задачи",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
if not resolved_rows:
|
||||||
|
lines.append("*Пока нет закрытых репортов.*")
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
for r in resolved_rows:
|
||||||
|
cat_emoji = "🐛" if r["category"] == "bug" else ("💡" if r["category"] == "feature" else "📝")
|
||||||
|
lines.append(f"- [x] **#{r['id']} {cat_emoji} ({r['created_at']})**: {r['message'].strip()} *(Страница: {r['context_url'] or 'общая'})*")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
content = "\n".join(lines)
|
||||||
|
TARGET_MD.write_text(content, encoding="utf-8")
|
||||||
|
MEMORY_MD.write_text(content, encoding="utf-8")
|
||||||
|
print(f"Synced {len(rows)} reports successfully!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sync()
|
||||||
@@ -1511,8 +1511,18 @@ def test_grammar_engine_and_handwritten_journaling():
|
|||||||
assert len(u_data["contrasts"]) >= 1
|
assert len(u_data["contrasts"]) >= 1
|
||||||
assert "journaling_prompt" in u_data
|
assert "journaling_prompt" in u_data
|
||||||
assert "тетрадь" in u_data["journaling_prompt"].lower()
|
assert "тетрадь" in u_data["journaling_prompt"].lower()
|
||||||
|
assert "cheatsheet_table" in u_data
|
||||||
|
assert "|" in u_data["cheatsheet_table"]
|
||||||
|
assert "detailed_notes" in u_data
|
||||||
|
assert len(u_data["detailed_notes"]) > 100
|
||||||
|
assert "###" in u_data["detailed_notes"]
|
||||||
assert len(u_data["exercises"]) == 5
|
assert len(u_data["exercises"]) == 5
|
||||||
|
|
||||||
|
# Check lookup by unit_num directly
|
||||||
|
num_res = client.get("/api/grammar/units/1")
|
||||||
|
assert num_res.status_code == 200
|
||||||
|
assert num_res.json()["id"] == unit_1_id
|
||||||
|
|
||||||
ex0 = u_data["exercises"][0]
|
ex0 = u_data["exercises"][0]
|
||||||
assert len(ex0["options"]) == 4
|
assert len(ex0["options"]) == 4
|
||||||
|
|
||||||
@@ -2497,6 +2507,143 @@ def test_lessons_debrief_and_history():
|
|||||||
assert last_session["errors_count"] == 2
|
assert last_session["errors_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_reverse_dictionary_lookup():
|
||||||
|
"""
|
||||||
|
BUG-06 regression test: Searching Russian words should perform reverse lookup,
|
||||||
|
returning the corresponding English term, phonetics, and Russian meaning.
|
||||||
|
"""
|
||||||
|
from app.auth import _login_failures
|
||||||
|
_login_failures.clear()
|
||||||
|
login_resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||||
|
assert login_resp.status_code == 200
|
||||||
|
token = login_resp.json()["token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
resp = client.get("/api/lookup?q=собака", headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["is_reverse"] is True
|
||||||
|
assert data["term"].lower() == "dog"
|
||||||
|
assert "собака" in data["translation"].lower()
|
||||||
|
assert data["phonetic"] != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_wikipedia_parsoid_html_cleaning():
|
||||||
|
"""
|
||||||
|
BUG-05 regression test: Wikipedia Parsoid HTML containing data-mw attributes with
|
||||||
|
embedded <ref> and literal '>' chars should be cleanly stripped without leaking into text.
|
||||||
|
"""
|
||||||
|
from app.reader import clean_wiki_html
|
||||||
|
|
||||||
|
sample_html = (
|
||||||
|
'<body>'
|
||||||
|
'<span class="mw-empty-elt" about="#mwt1" typeof="mw:Transclusion" '
|
||||||
|
'data-mw=\'{"parts":[{"template":{"target":{"wt":"Infobox"},"params":{"title":{"wt":"Test<ref>RefText</ref>"}}}]}\'>'
|
||||||
|
'</span>'
|
||||||
|
'<p>Linux is a Unix-like operating system.</p>'
|
||||||
|
'<table style="font-family:\'Times New Roman\', serif;"><tr><td>Clean Cell</td></tr></table>'
|
||||||
|
'</body>'
|
||||||
|
)
|
||||||
|
chapters, cover, words = clean_wiki_html(sample_html, "Test Article")
|
||||||
|
assert len(chapters) >= 1
|
||||||
|
content = chapters[0]["content"]
|
||||||
|
assert "data-mw" not in content
|
||||||
|
assert "{" not in content
|
||||||
|
assert "Infobox" not in content
|
||||||
|
assert "RefText" not in content
|
||||||
|
assert "Linux is a Unix-like operating system." in content
|
||||||
|
assert "Times New Roman" not in content # stripped cleanly from table
|
||||||
|
assert "Clean Cell" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_feedback_endpoints_workflow():
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 1. Unauthenticated access blocked
|
||||||
|
unauth_client = TestClient(app)
|
||||||
|
resp = unauth_client.get("/api/feedback")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
resp = unauth_client.post("/api/feedback", json={"message": "Test bug"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
# 2. Authenticated login
|
||||||
|
login_resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||||
|
assert login_resp.status_code == 200
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 3. Create a bug report
|
||||||
|
bug_payload = {
|
||||||
|
"category": "bug",
|
||||||
|
"message": "Кнопка в читалке не нажимается на мобилке",
|
||||||
|
"context_url": "#reader/book/1/ch/2",
|
||||||
|
"context_data": {"screen": "390x844", "device": "Mobile"}
|
||||||
|
}
|
||||||
|
create_resp = client.post("/api/feedback", json=bug_payload)
|
||||||
|
assert create_resp.status_code == 201
|
||||||
|
c_data = create_resp.json()
|
||||||
|
assert c_data["ok"] is True
|
||||||
|
report_id = c_data["id"]
|
||||||
|
assert c_data["category"] == "bug"
|
||||||
|
assert c_data["status"] == "new"
|
||||||
|
|
||||||
|
# 4. Create a feature request
|
||||||
|
feat_payload = {
|
||||||
|
"category": "feature",
|
||||||
|
"message": "Добавить темную тему OLED",
|
||||||
|
"context_url": "#reader",
|
||||||
|
"context_data": {"screen": "1920x1080", "device": "Desktop"}
|
||||||
|
}
|
||||||
|
create_resp2 = client.post("/api/feedback", json=feat_payload)
|
||||||
|
assert create_resp2.status_code == 201
|
||||||
|
report_id2 = create_resp2.json()["id"]
|
||||||
|
|
||||||
|
# 5. List reports
|
||||||
|
list_resp = client.get("/api/feedback")
|
||||||
|
assert list_resp.status_code == 200
|
||||||
|
reports = list_resp.json()
|
||||||
|
assert len(reports) >= 2
|
||||||
|
r_ids = [r["id"] for r in reports]
|
||||||
|
assert report_id in r_ids
|
||||||
|
assert report_id2 in r_ids
|
||||||
|
|
||||||
|
# 6. Filter by category
|
||||||
|
bugs_resp = client.get("/api/feedback?category=bug")
|
||||||
|
assert bugs_resp.status_code == 200
|
||||||
|
assert all(r["category"] == "bug" for r in bugs_resp.json())
|
||||||
|
|
||||||
|
# 7. Update status to resolved
|
||||||
|
patch_resp = client.patch(f"/api/feedback/{report_id}", json={"status": "resolved"})
|
||||||
|
assert patch_resp.status_code == 200
|
||||||
|
assert patch_resp.json()["status"] == "resolved"
|
||||||
|
|
||||||
|
# Verify updated
|
||||||
|
get_updated = client.get("/api/feedback?status=resolved")
|
||||||
|
assert get_updated.status_code == 200
|
||||||
|
assert any(r["id"] == report_id and r["status"] == "resolved" for r in get_updated.json())
|
||||||
|
|
||||||
|
# 8. Delete report
|
||||||
|
del_resp = client.delete(f"/api/feedback/{report_id2}")
|
||||||
|
assert del_resp.status_code == 200
|
||||||
|
assert del_resp.json()["ok"] is True
|
||||||
|
|
||||||
|
# 9. Verify markdown sync file exists and has content
|
||||||
|
reports_md = Path("/home/ai-projects/memory/USER_REPORTS.md")
|
||||||
|
assert reports_md.exists()
|
||||||
|
content = reports_md.read_text(encoding="utf-8")
|
||||||
|
assert "Пользовательские репорты" in content
|
||||||
|
assert "Кнопка в читалке не нажимается" in content
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
from scripts.sync_reports import sync
|
||||||
|
sync()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user