feat(trainer): add GitHub activity heatmap, 4-option quiz mode, active typing, and duplicate checks
This commit is contained in:
@@ -98,37 +98,39 @@
|
||||
### Этап 2: Организация, теги, экспорт и PWA
|
||||
*Цель: гибкое управление растущим словарным запасом и удобство на мобильных.*
|
||||
|
||||
- [ ] **2.1. Расширенная организация и контроль качества**:
|
||||
- [x] **2.1. Расширенная организация и контроль качества**:
|
||||
- Коллекции и папки (например, «Разговорный», «Работа», «Книги/Сериалы», «Сети/IT»).
|
||||
- Быстрое массовое тегирование.
|
||||
- Быстрое массовое тегирование и групповые действия (удаление, перемещение).
|
||||
- **Контроль и предотвращение дубликатов (Duplicate Prevention & Merging)**:
|
||||
- Проверка при вводе и сохранении (case-insensitive trim).
|
||||
- Уведомление, если слово/фраза уже есть в словаре.
|
||||
- Возможность дописать новый контекстный пример / значение к существующей карточке вместо пложения дублей или перейти к её редактированию.
|
||||
- [ ] **2.2. Экспорт данных**:
|
||||
- Экспорт базы в CSV / JSON / Markdown.
|
||||
- Экспорт в колоду Anki (`.apkg` / `.csv`) с аудио и контекстными предложениями.
|
||||
- Резервное копирование SQLite в один клик.
|
||||
- [ ] **2.3. PWA (Progressive Web App)**:
|
||||
- [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)
|
||||
*Цель: перевод слов из пассивного словаря в активную долговременную память.*
|
||||
|
||||
- [ ] **3.1. Движок SuperMemo-2 (SM-2) & Умная выборка**:
|
||||
- [x] **3.1. Движок SuperMemo-2 (SM-2) & Умная выборка**:
|
||||
- Расчет интервалов забывания Эббингауза: `Again`, `Hard`, `Good`, `Easy`.
|
||||
- **Умный микс дня (Daily Quiz Mix)**: баланс сессии (например, 10 новых + 10 интервальных повторений + 5 проблемных слов в разнобой).
|
||||
- **Умный микс дня (Daily Quiz Mix)**: баланс сессии (до 10 новых + 10 интервальных повторений + 5 проблемных слов в разнобой).
|
||||
- **Детектор трудных слов (Leeches)**: автоматическая маркировка слов с частыми ошибками и их приоритетное подмешивание.
|
||||
- [ ] **3.2. Режим Flashcards**:
|
||||
- Карточка En $\rightarrow$ Ru и Ru $\rightarrow$ En с озвучкой.
|
||||
- [ ] **3.3. Режим активного набора (Active Typing / Ru $\rightarrow$ En)**:
|
||||
- [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$ вводишь английское слово с клавиатуры (прокачка моторной памяти и спеллинга).
|
||||
- [ ] **3.4. Режим Cloze Deletion (Контекстные пропуски)**:
|
||||
- [x] **3.5. Режим Cloze Deletion (Контекстные пропуски)**:
|
||||
- Заполнение пропущенного слова прямо в живом предложении.
|
||||
- [ ] **3.5. Аудио-тренажёр (Dictation)**:
|
||||
- [x] **3.6. Аудио-тренажёр (Dictation)**:
|
||||
- Слушаем слово $\rightarrow$ печатаем правильный спеллинг.
|
||||
|
||||
---
|
||||
@@ -136,8 +138,11 @@
|
||||
### Этап 4: Интеллектуальный помощник и аналитика
|
||||
*Цель: отслеживание динамики и поддержка мотивации.*
|
||||
|
||||
- [ ] **4.1. Статистика и Daily Streak**:
|
||||
- Учёт непрерывных дней занятий.
|
||||
- График роста активного словарного запаса по уровням (A1-C1).
|
||||
- [x] **4.1. Статистика, Daily Streak и Календарь активности (GitHub-style Heatmap)**:
|
||||
- GitHub-style сетка активности за 180 дней с градацией интенсивности (серый, салатовый, зеленый, изумрудный).
|
||||
- Учёт непрерывных дней занятий (Daily Streak 🔥) и рекорда серии.
|
||||
- Сегментированный прогресс-бар стадий освоения (Новые, В процессе, Закрепление, Выучено).
|
||||
- Оценка уровня владения языком (A1 - C1) по объёму активного словаря.
|
||||
- Процент точности запоминания (Retention Rate %).
|
||||
- [ ] **4.2. Быстрый захват из браузера**:
|
||||
- Букмарклет для выделения текста на любой странице и отправки в словарь.
|
||||
|
||||
+22
@@ -108,9 +108,31 @@ def delete_session(token: str):
|
||||
with get_db() as conn:
|
||||
conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
|
||||
|
||||
def change_user_password(user_id: int, old_password: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Changes password for the specified user after verifying old password."""
|
||||
if not new_password or len(new_password) < 6:
|
||||
return False, "Новый пароль должен содержать минимум 6 символов"
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT password_hash, salt FROM users WHERE id = ?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False, "Пользователь не найден"
|
||||
if not verify_password(old_password, row["password_hash"], row["salt"]):
|
||||
return False, "Неверный текущий пароль"
|
||||
pwd_hash, salt = hash_password(new_password)
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash = ?, salt = ? WHERE id = ?",
|
||||
(pwd_hash, salt, user_id)
|
||||
)
|
||||
return True, "Пароль успешно изменён"
|
||||
|
||||
|
||||
def ensure_admin_user():
|
||||
"""Ensures at least one user exists; creates default admin from config if table empty."""
|
||||
with get_db() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if count == 0 and config.ADMIN_PASSWORD:
|
||||
create_user(config.ADMIN_USERNAME, config.ADMIN_PASSWORD)
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ DEBUG = os.getenv("DEBUG", "False").lower() in ("1", "true", "yes")
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "default-dev-secret-key-please-change-in-production")
|
||||
SESSION_COOKIE_NAME = os.getenv("SESSION_COOKIE_NAME", "notes_session")
|
||||
SESSION_EXPIRE_DAYS = int(os.getenv("SESSION_EXPIRE_DAYS", "30"))
|
||||
SESSION_MAX_AGE = SESSION_EXPIRE_DAYS * 86400
|
||||
|
||||
|
||||
RATE_LIMIT_LOGIN_MAX = int(os.getenv("RATE_LIMIT_LOGIN_MAX", "5"))
|
||||
RATE_LIMIT_LOGIN_WINDOW_SEC = int(os.getenv("RATE_LIMIT_LOGIN_WINDOW_SEC", "60"))
|
||||
|
||||
+77
-11
@@ -3,17 +3,16 @@ import contextlib
|
||||
from typing import Generator
|
||||
from app import config
|
||||
|
||||
|
||||
def get_db_connection() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(
|
||||
config.DB_PATH,
|
||||
timeout=15.0
|
||||
)
|
||||
conn = sqlite3.connect(config.DB_PATH, timeout=15.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
conn.execute("PRAGMA synchronous=NORMAL;")
|
||||
conn.execute("PRAGMA foreign_keys=ON;")
|
||||
return conn
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def get_db() -> Generator[sqlite3.Connection, None, None]:
|
||||
conn = get_db_connection()
|
||||
@@ -26,6 +25,46 @@ def get_db() -> Generator[sqlite3.Connection, None, None]:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _migrate(conn: sqlite3.Connection):
|
||||
"""Safe schema migrations: add new columns/tables without touching existing data."""
|
||||
# --- vocabulary_cards new columns ---
|
||||
existing_cols = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(vocabulary_cards)").fetchall()
|
||||
}
|
||||
card_migrations = [
|
||||
("collection", "TEXT NOT NULL DEFAULT 'General'"),
|
||||
("srs_reps", "INTEGER DEFAULT 0"),
|
||||
("srs_interval", "INTEGER DEFAULT 0"),
|
||||
("srs_ease", "REAL DEFAULT 2.5"),
|
||||
("srs_next_review", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"),
|
||||
("srs_last_review", "TIMESTAMP"),
|
||||
("srs_state", "TEXT DEFAULT 'new'"),
|
||||
("error_count", "INTEGER DEFAULT 0"),
|
||||
]
|
||||
for col, definition in card_migrations:
|
||||
if col not in existing_cols:
|
||||
conn.execute(f"ALTER TABLE vocabulary_cards ADD COLUMN {col} {definition}")
|
||||
|
||||
# Ensure srs_logs table exists
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS srs_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
card_id INTEGER NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
interval INTEGER NOT NULL,
|
||||
ease REAL NOT NULL,
|
||||
reviewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (card_id) REFERENCES vocabulary_cards(id) ON DELETE CASCADE
|
||||
);
|
||||
""")
|
||||
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_srs_user ON srs_logs(user_id);")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_srs_reviewed ON srs_logs(user_id, reviewed_at);")
|
||||
|
||||
|
||||
def init_db():
|
||||
config.DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with get_db() as conn:
|
||||
@@ -53,13 +92,21 @@ def init_db():
|
||||
user_id INTEGER NOT NULL,
|
||||
term TEXT NOT NULL,
|
||||
translation TEXT NOT NULL,
|
||||
context_sentence TEXT,
|
||||
phonetic TEXT,
|
||||
audio_url TEXT,
|
||||
part_of_speech TEXT,
|
||||
definition_en TEXT,
|
||||
tags TEXT,
|
||||
notes TEXT,
|
||||
context_sentence TEXT DEFAULT '',
|
||||
phonetic TEXT DEFAULT '',
|
||||
audio_url TEXT DEFAULT '',
|
||||
part_of_speech TEXT DEFAULT '',
|
||||
definition_en TEXT DEFAULT '',
|
||||
tags TEXT DEFAULT 'general',
|
||||
notes TEXT DEFAULT '',
|
||||
collection TEXT NOT NULL DEFAULT 'General',
|
||||
srs_reps INTEGER DEFAULT 0,
|
||||
srs_interval INTEGER DEFAULT 0,
|
||||
srs_ease REAL DEFAULT 2.5,
|
||||
srs_next_review TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
srs_last_review TIMESTAMP,
|
||||
srs_state TEXT DEFAULT 'new',
|
||||
error_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -68,6 +115,23 @@ def init_db():
|
||||
CREATE INDEX IF NOT EXISTS idx_vocab_user ON vocabulary_cards(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vocab_term ON vocabulary_cards(term);
|
||||
CREATE INDEX IF NOT EXISTS idx_vocab_created ON vocabulary_cards(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_vocab_collection ON vocabulary_cards(user_id, collection);
|
||||
CREATE INDEX IF NOT EXISTS idx_vocab_srs_queue ON vocabulary_cards(user_id, srs_next_review, srs_state);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS srs_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
card_id INTEGER NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
interval INTEGER NOT NULL,
|
||||
ease REAL NOT NULL,
|
||||
reviewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (card_id) REFERENCES vocabulary_cards(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_srs_user ON srs_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_srs_reviewed ON srs_logs(user_id, reviewed_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dictionary_cache (
|
||||
query TEXT PRIMARY KEY COLLATE NOCASE,
|
||||
@@ -75,3 +139,5 @@ def init_db():
|
||||
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
# Apply migrations for pre-existing databases
|
||||
_migrate(conn)
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Export module for notes.qpid.pro — Stage 2
|
||||
Supports: CSV (Excel-friendly with BOM), JSON, Anki TSV (Cloze-ready), Markdown.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
def export_csv(cards: List[Dict[str, Any]]) -> bytes:
|
||||
"""CSV with UTF-8 BOM for correct Excel auto-detection of encoding."""
|
||||
output = io.StringIO()
|
||||
fieldnames = [
|
||||
"term", "translation", "context", "phonetic",
|
||||
"part_of_speech", "definition_en", "collection", "tags", "notes", "created_at",
|
||||
]
|
||||
writer = csv.DictWriter(
|
||||
output,
|
||||
fieldnames=fieldnames,
|
||||
extrasaction="ignore",
|
||||
quoting=csv.QUOTE_MINIMAL,
|
||||
lineterminator="\r\n",
|
||||
)
|
||||
writer.writeheader()
|
||||
for c in cards:
|
||||
writer.writerow({
|
||||
"term": c.get("term") or "",
|
||||
"translation": c.get("translation") or "",
|
||||
"context": c.get("context_sentence") or c.get("context") or "",
|
||||
"phonetic": c.get("phonetic") or "",
|
||||
"part_of_speech": c.get("part_of_speech") or "",
|
||||
"definition_en": c.get("definition_en") or "",
|
||||
"collection": c.get("collection") or "General",
|
||||
"tags": c.get("tags") or "",
|
||||
"notes": c.get("notes") or "",
|
||||
"created_at": c.get("created_at") or "",
|
||||
})
|
||||
# UTF-8 BOM (\ufeff) so Excel auto-detects encoding
|
||||
return ("\ufeff" + output.getvalue()).encode("utf-8")
|
||||
|
||||
|
||||
def export_json(cards: List[Dict[str, Any]]) -> bytes:
|
||||
"""Full JSON export with metadata header."""
|
||||
payload = {
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"total": len(cards),
|
||||
"cards": [
|
||||
{
|
||||
"id": c.get("id"),
|
||||
"term": c.get("term") or "",
|
||||
"translation": c.get("translation") or "",
|
||||
"context_sentence": c.get("context_sentence") or c.get("context") or "",
|
||||
"phonetic": c.get("phonetic") or "",
|
||||
"part_of_speech": c.get("part_of_speech") or "",
|
||||
"definition_en": c.get("definition_en") or "",
|
||||
"collection": c.get("collection") or "General",
|
||||
"tags": c.get("tags") or "",
|
||||
"notes": c.get("notes") or "",
|
||||
"audio_url": c.get("audio_url") or "",
|
||||
"created_at": c.get("created_at") or "",
|
||||
"updated_at": c.get("updated_at") or "",
|
||||
}
|
||||
for c in cards
|
||||
],
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
|
||||
|
||||
def export_anki_tsv(cards: List[Dict[str, Any]]) -> bytes:
|
||||
"""
|
||||
Anki-compatible tab-separated export with cloze deletion support.
|
||||
"""
|
||||
lines: List[str] = [
|
||||
"#separator:tab",
|
||||
"#html:true",
|
||||
"#notetype:Basic (and reversed card)",
|
||||
"#columns:Front\tBack\tTags\tContext",
|
||||
]
|
||||
for c in cards:
|
||||
term = (c.get("term") or "").replace("\t", " ").replace("\n", " ")
|
||||
translation = (c.get("translation") or "").replace("\t", " ").replace("\n", " ")
|
||||
phonetic = (c.get("phonetic") or "").replace("\t", " ")
|
||||
context = (c.get("context_sentence") or c.get("context") or "").replace("\t", " ").replace("\n", " ")
|
||||
tags_raw = c.get("tags") or ""
|
||||
collection = (c.get("collection") or "General").strip().replace(" ", "_")
|
||||
|
||||
# Cloze representation if context contains the term
|
||||
cloze_ctx = ""
|
||||
if context and term and term.lower() in context.lower():
|
||||
cloze_ctx = re.sub(re.escape(term), f"{{{{c1::{term}}}}}", context, flags=re.IGNORECASE)
|
||||
|
||||
tag_parts = [f"collection_{collection}"] + [
|
||||
t.strip().replace(" ", "_")
|
||||
for t in tags_raw.split(",") if t.strip()
|
||||
]
|
||||
tags_str = " ".join(tag_parts)
|
||||
|
||||
front = term if not phonetic else f"{term} [{phonetic}]"
|
||||
back = translation
|
||||
|
||||
lines.append(f"{front}\t{back}\t{tags_str}\t{cloze_ctx or context}")
|
||||
|
||||
return ("\n".join(lines) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def export_markdown(cards: List[Dict[str, Any]]) -> bytes:
|
||||
"""Human-readable Markdown export, grouped by collection."""
|
||||
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
lines: List[str] = [
|
||||
f"# 📚 Vocabulary Export — {now_str}",
|
||||
f"**Total cards:** {len(cards)}",
|
||||
"",
|
||||
]
|
||||
|
||||
by_collection: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for c in cards:
|
||||
col = (c.get("collection") or "General").strip()
|
||||
by_collection.setdefault(col, []).append(c)
|
||||
|
||||
for collection, col_cards in sorted(by_collection.items()):
|
||||
lines.append(f"## {collection} ({len(col_cards)} cards)")
|
||||
lines.append("")
|
||||
lines.append("| Term | Translation | Phonetic | Tags |")
|
||||
lines.append("|------|-------------|----------|------|")
|
||||
for c in col_cards:
|
||||
term = (c.get("term") or "").replace("|", "\\|")
|
||||
translation = (c.get("translation") or "").replace("|", "\\|")
|
||||
phonetic = (c.get("phonetic") or "").replace("|", "\\|")
|
||||
tags = (c.get("tags") or "").replace("|", "\\|")
|
||||
lines.append(f"| **{term}** | {translation} | {phonetic} | {tags} |")
|
||||
lines.append("")
|
||||
lines.append("### Details")
|
||||
lines.append("")
|
||||
for c in col_cards:
|
||||
term = c.get("term") or ""
|
||||
phonetic_str = f" `{c['phonetic']}`" if c.get("phonetic") else ""
|
||||
pos_str = f" *({c['part_of_speech']})*" if c.get("part_of_speech") else ""
|
||||
lines.append(f"### {term}{phonetic_str}{pos_str}")
|
||||
lines.append(f"- **Перевод**: {c.get('translation') or '—'}")
|
||||
if c.get("definition_en"):
|
||||
lines.append(f"- **Толкование**: {c['definition_en']}")
|
||||
if c.get("context_sentence") or c.get("context"):
|
||||
lines.append(f"- **Контекст**: *\"{c.get('context_sentence') or c.get('context')}\"*")
|
||||
if c.get("notes"):
|
||||
lines.append(f"- **Заметка**: {c['notes']}")
|
||||
lines.append("")
|
||||
|
||||
return ("\n".join(lines) + "\n").encode("utf-8")
|
||||
+705
-123
File diff suppressed because it is too large
Load Diff
+250
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
SuperMemo-2 (SM-2) spaced repetition system (SRS) calculation logic,
|
||||
quiz generation, and activity heatmap / streak analysis.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone, date
|
||||
from typing import Dict, Any, Optional, List
|
||||
import random
|
||||
|
||||
# Quality rating constants
|
||||
RATING_AGAIN = 1
|
||||
RATING_HARD = 2
|
||||
RATING_GOOD = 3
|
||||
RATING_EASY = 4
|
||||
|
||||
MIN_EASE = 1.3
|
||||
MAX_EASE = 3.0
|
||||
DEFAULT_EASE = 2.5
|
||||
MASTERED_INTERVAL_THRESHOLD = 21
|
||||
|
||||
FALLBACK_DISTRACTORS = [
|
||||
("concurrency", "одновременность, параллелизм"),
|
||||
("throughput", "пропускная способность"),
|
||||
("bottleneck", "узкое место"),
|
||||
("redundancy", "избыточность, резервирование"),
|
||||
("resilience", "устойчивость, отказоустойчивость"),
|
||||
("deprecated", "устаревший, не рекомендуемый"),
|
||||
("mitigate", "смягчать, уменьшать последствия"),
|
||||
("seamless", "бесшовный, плавный"),
|
||||
("leverage", "использовать с выгодой"),
|
||||
("tweak", "подправить, тонко настроить"),
|
||||
("scaffold", "каркас, шаблон структуры"),
|
||||
("boilerplate", "шаблонный код"),
|
||||
("payload", "полезная нагрузка"),
|
||||
("latency", "задержка, пинг"),
|
||||
("invoke", "вызывать (функцию/метод)"),
|
||||
("retrieve", "извлекать, получать данные"),
|
||||
("subtle", "тонкий, едва уловимый"),
|
||||
("comprehensive", "всесторонний, исчерпывающий"),
|
||||
("reluctant", "неохотный, нежелающий"),
|
||||
("perseverance", "настойчивость, упорство"),
|
||||
("serendipity", "счастливая случайность"),
|
||||
("ubiquitous", "вездесущий, повсеместный"),
|
||||
("indispensable", "незаменимый"),
|
||||
("arbitrary", "произвольный"),
|
||||
("coherent", "связный, последовательный"),
|
||||
("feasible", "осуществимый, выполнимый"),
|
||||
("tangible", "осязаемый, ощутимый"),
|
||||
("robust", "надежный, устойчивый к сбоям"),
|
||||
("versatile", "универсальный, гибкий"),
|
||||
("streamline", "оптимизировать, упрощать"),
|
||||
]
|
||||
|
||||
|
||||
def calculate_next_review(interval_days: int, base_time: Optional[datetime] = None) -> str:
|
||||
"""Calculates the next review timestamp formatted as 'YYYY-MM-DD HH:MM:SS' (UTC)."""
|
||||
if base_time is None:
|
||||
base_time = datetime.now(timezone.utc)
|
||||
next_time = base_time + timedelta(days=interval_days)
|
||||
return next_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def calculate_sm2(
|
||||
rating: int,
|
||||
reps: Optional[int] = 0,
|
||||
interval: Optional[int] = 0,
|
||||
ease: Optional[float] = 2.5,
|
||||
state: Optional[str] = "new",
|
||||
base_time: Optional[datetime] = None,
|
||||
error_count: Optional[int] = 0,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Processes a card review rating using SM-2 algorithm:
|
||||
- 1: Again (reps=0, interval=1, ease = max(1.3, ease - 0.2), state='learning')
|
||||
- 2: Hard (reps=reps+1, interval=max(1, round(interval * 1.2)), ease = max(1.3, ease - 0.15), state='learning' if reps < 3 else 'review')
|
||||
- 3: Good (reps=reps+1, interval= 1 if reps==1 else (6 if reps==2 else round(interval * ease)), state='review' if interval < 21 else 'mastered')
|
||||
- 4: Easy (reps=reps+1, interval= 4 if reps==1 else round(interval * ease * 1.3), ease = min(3.0, ease + 0.15), state='review' if interval < 21 else 'mastered')
|
||||
"""
|
||||
if rating not in (RATING_AGAIN, RATING_HARD, RATING_GOOD, RATING_EASY):
|
||||
raise ValueError(f"Invalid rating {rating}: must be 1 (Again), 2 (Hard), 3 (Good), or 4 (Easy)")
|
||||
|
||||
reps = reps if reps is not None else 0
|
||||
interval = interval if interval is not None else 0
|
||||
ease = ease if ease is not None else DEFAULT_EASE
|
||||
state = state or "new"
|
||||
error_count = error_count if error_count is not None else 0
|
||||
|
||||
if base_time is None:
|
||||
base_time = datetime.now(timezone.utc)
|
||||
last_review_str = base_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
new_error_count = error_count
|
||||
if rating == RATING_AGAIN:
|
||||
new_reps = 0
|
||||
new_interval = 1
|
||||
new_ease = max(MIN_EASE, ease - 0.2)
|
||||
new_state = "learning"
|
||||
new_error_count += 1
|
||||
|
||||
elif rating == RATING_HARD:
|
||||
new_reps = reps + 1
|
||||
new_interval = max(1, round(interval * 1.2))
|
||||
new_ease = max(MIN_EASE, ease - 0.15)
|
||||
new_state = "learning" if new_reps < 3 else "review"
|
||||
|
||||
elif rating == RATING_GOOD:
|
||||
new_reps = reps + 1
|
||||
if new_reps == 1:
|
||||
new_interval = 1
|
||||
elif new_reps == 2:
|
||||
new_interval = 6
|
||||
else:
|
||||
new_interval = round(interval * ease)
|
||||
new_ease = ease
|
||||
new_state = "review" if new_interval < MASTERED_INTERVAL_THRESHOLD else "mastered"
|
||||
|
||||
elif rating == RATING_EASY:
|
||||
new_reps = reps + 1
|
||||
if new_reps == 1:
|
||||
new_interval = 4
|
||||
else:
|
||||
new_interval = round(interval * ease * 1.3)
|
||||
new_ease = min(MAX_EASE, ease + 0.15)
|
||||
new_state = "review" if new_interval < MASTERED_INTERVAL_THRESHOLD else "mastered"
|
||||
|
||||
new_ease = round(new_ease, 2)
|
||||
next_review_str = calculate_next_review(new_interval, base_time)
|
||||
|
||||
return {
|
||||
"srs_reps": new_reps,
|
||||
"srs_interval": new_interval,
|
||||
"srs_ease": new_ease,
|
||||
"srs_state": new_state,
|
||||
"srs_last_review": last_review_str,
|
||||
"srs_next_review": next_review_str,
|
||||
"error_count": new_error_count,
|
||||
}
|
||||
|
||||
|
||||
def generate_quiz_options(target_card: Dict[str, Any], all_cards: List[Dict[str, Any]], count: int = 4) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generates 4 multiple choice options for a target card (1 correct + 3 distractors).
|
||||
Returns list of options with text and is_correct flag, randomly shuffled.
|
||||
"""
|
||||
target_id = target_card.get("id")
|
||||
correct_text = target_card.get("translation", "")
|
||||
|
||||
# Gather potential distractors from user's other cards
|
||||
candidate_distractors = [
|
||||
c.get("translation", "").strip()
|
||||
for c in all_cards
|
||||
if c.get("id") != target_id and c.get("translation", "").strip() and c.get("translation", "").strip() != correct_text
|
||||
]
|
||||
|
||||
# Deduplicate candidates
|
||||
candidate_distractors = list(dict.fromkeys(candidate_distractors))
|
||||
random.shuffle(candidate_distractors)
|
||||
|
||||
selected_distractors: List[str] = []
|
||||
for d in candidate_distractors:
|
||||
if len(selected_distractors) >= (count - 1):
|
||||
break
|
||||
selected_distractors.append(d)
|
||||
|
||||
# If still not enough, take from fallback dictionary
|
||||
if len(selected_distractors) < (count - 1):
|
||||
for _, ru in FALLBACK_DISTRACTORS:
|
||||
if ru != correct_text and ru not in selected_distractors:
|
||||
selected_distractors.append(ru)
|
||||
if len(selected_distractors) >= (count - 1):
|
||||
break
|
||||
|
||||
options = [{"text": correct_text, "is_correct": True}]
|
||||
for d in selected_distractors:
|
||||
options.append({"text": d, "is_correct": False})
|
||||
|
||||
random.shuffle(options)
|
||||
return options
|
||||
|
||||
|
||||
def build_activity_heatmap(review_dates: Dict[str, int], days: int = 180) -> Dict[str, Any]:
|
||||
"""
|
||||
Constructs a daily contribution heatmap (like GitHub) for the past `days`.
|
||||
Returns daily entries with date, count, and level (0: none, 1: 1 review, 2: 2-3 reviews, 3: 4+ reviews).
|
||||
Also computes current streak and longest streak.
|
||||
"""
|
||||
today = date.today()
|
||||
start_date = today - timedelta(days=days - 1)
|
||||
|
||||
heatmap_days = []
|
||||
current_date = start_date
|
||||
|
||||
while current_date <= today:
|
||||
d_str = current_date.strftime("%Y-%m-%d")
|
||||
count = review_dates.get(d_str, 0)
|
||||
if count == 0:
|
||||
level = 0
|
||||
elif count == 1:
|
||||
level = 1
|
||||
elif count <= 3:
|
||||
level = 2
|
||||
else:
|
||||
level = 3
|
||||
|
||||
heatmap_days.append({
|
||||
"date": d_str,
|
||||
"count": count,
|
||||
"level": level,
|
||||
"day_of_week": current_date.weekday(), # 0=Mon, 6=Sun
|
||||
})
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# Compute streaks
|
||||
current_streak = 0
|
||||
check_day = today
|
||||
# Check today
|
||||
if review_dates.get(check_day.strftime("%Y-%m-%d"), 0) > 0:
|
||||
current_streak += 1
|
||||
check_day -= timedelta(days=1)
|
||||
else:
|
||||
# If no activity today yet, check yesterday to preserve ongoing streak
|
||||
yesterday = today - timedelta(days=1)
|
||||
if review_dates.get(yesterday.strftime("%Y-%m-%d"), 0) > 0:
|
||||
check_day = yesterday
|
||||
|
||||
while check_day >= start_date:
|
||||
if review_dates.get(check_day.strftime("%Y-%m-%d"), 0) > 0:
|
||||
if check_day != today: # don't double-count today
|
||||
current_streak += 1
|
||||
check_day -= timedelta(days=1)
|
||||
else:
|
||||
break
|
||||
|
||||
# Longest streak calculation
|
||||
longest_streak = 0
|
||||
temp_streak = 0
|
||||
for day in heatmap_days:
|
||||
if day["count"] > 0:
|
||||
temp_streak += 1
|
||||
if temp_streak > longest_streak:
|
||||
longest_streak = temp_streak
|
||||
else:
|
||||
temp_streak = 0
|
||||
|
||||
return {
|
||||
"days": heatmap_days,
|
||||
"total_active_days": sum(1 for d in heatmap_days if d["count"] > 0),
|
||||
"total_reviews": sum(d["count"] for d in heatmap_days),
|
||||
"current_streak": current_streak,
|
||||
"longest_streak": max(longest_streak, current_streak),
|
||||
}
|
||||
@@ -1905,6 +1905,382 @@ body {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Duplicate Warning Banner */
|
||||
.duplicate-warning-box {
|
||||
margin-top: 10px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(234, 179, 8, 0.12);
|
||||
border: 1px solid rgba(234, 179, 8, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.dup-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
.dup-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Multiple Choice Quiz Mode */
|
||||
.quiz-card {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.quiz-hero {
|
||||
text-align: center;
|
||||
padding: 20px 0 16px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.quiz-prompt-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quiz-term-display {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.quiz-context-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.quiz-options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.quiz-option-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
background: var(--bg-main);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-main);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.quiz-option-btn:hover:not(:disabled) {
|
||||
border-color: var(--primary);
|
||||
background: var(--bg-surface-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.quiz-option-btn:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.quiz-opt-num {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quiz-opt-text {
|
||||
flex: 1;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.quiz-option-btn.opt-correct {
|
||||
background: rgba(34, 197, 94, 0.15) !important;
|
||||
border-color: var(--success) !important;
|
||||
color: var(--success) !important;
|
||||
}
|
||||
|
||||
.quiz-option-btn.opt-correct .quiz-opt-num {
|
||||
background: var(--success);
|
||||
color: #ffffff;
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.quiz-option-btn.opt-wrong {
|
||||
background: rgba(239, 68, 68, 0.15) !important;
|
||||
border-color: var(--danger) !important;
|
||||
color: var(--danger) !important;
|
||||
animation: shake 0.3s ease;
|
||||
}
|
||||
|
||||
.quiz-option-btn.opt-wrong .quiz-opt-num {
|
||||
background: var(--danger);
|
||||
color: #ffffff;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.quiz-next-bar {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Active Typing Mode */
|
||||
.typing-hero {
|
||||
text-align: center;
|
||||
padding: 16px 0 20px 0;
|
||||
}
|
||||
|
||||
.typing-prompt-ru {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.typing-context-hint {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
background: var(--bg-main);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: inline-block;
|
||||
max-width: 90%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Analytics & Activity Heatmap Modal */
|
||||
.stats-modal-window {
|
||||
max-width: 820px;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.stats-hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-kpi-card {
|
||||
background: var(--bg-main);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 12px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.streak-kpi {
|
||||
border-color: rgba(249, 115, 22, 0.4);
|
||||
background: rgba(249, 115, 22, 0.05);
|
||||
}
|
||||
|
||||
.kpi-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.kpi-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
/* GitHub Heatmap Section */
|
||||
.heatmap-section {
|
||||
background: var(--bg-main);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.heatmap-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.heatmap-header h4 {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.legend-cell {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.heatmap-container-wrap {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.heatmap-grid {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(7, 12px);
|
||||
gap: 4px;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heatmap-cell:hover {
|
||||
transform: scale(1.3);
|
||||
z-index: 10;
|
||||
outline: 1px solid var(--text-main);
|
||||
}
|
||||
|
||||
/* Heatmap Levels (GitHub-style intensity) */
|
||||
.lvl-0 { background: rgba(255, 255, 255, 0.06); }
|
||||
.lvl-1 { background: #166534; } /* 1 review: soft green */
|
||||
.lvl-2 { background: #22c55e; } /* 2-3 reviews: vibrant green */
|
||||
.lvl-3 { background: #4ade80; } /* 4+ reviews: bright intense green */
|
||||
|
||||
[data-theme="light"] .lvl-0 { background: #e2e8f0; }
|
||||
[data-theme="light"] .lvl-1 { background: #86efac; }
|
||||
[data-theme="light"] .lvl-2 { background: #22c55e; }
|
||||
[data-theme="light"] .lvl-3 { background: #15803d; }
|
||||
|
||||
.heatmap-footer-note {
|
||||
margin-top: 10px;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Segmented Mastery Progress Bar */
|
||||
.mastery-section {
|
||||
background: var(--bg-main);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mastery-section h4 {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.segmented-progress-bar {
|
||||
display: flex;
|
||||
height: 14px;
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.prog-segment {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.seg-mastered { background: #22c55e; }
|
||||
.seg-review { background: #38bdf8; }
|
||||
.seg-learning { background: #f59e0b; }
|
||||
.seg-new { background: #64748b; }
|
||||
|
||||
.mastery-legend-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.m-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dot-mastered { background: #22c55e; }
|
||||
.dot-review { background: #38bdf8; }
|
||||
.dot-learning { background: #f59e0b; }
|
||||
.dot-new { background: #64748b; }
|
||||
.dot-leech { background: #ef4444; }
|
||||
|
||||
.text-danger { color: #ef4444; }
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-4px); }
|
||||
75% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.navbar {
|
||||
@@ -1939,12 +2315,14 @@ body {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.fc-flip-btn-wrap {
|
||||
display: block;
|
||||
}
|
||||
.srs-topbar {
|
||||
padding: 10px 12px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
.srs-topbar-left, .srs-topbar-right {
|
||||
justify-content: space-between;
|
||||
@@ -1955,6 +2333,12 @@ body {
|
||||
.srs-rating-bar {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.quiz-options-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.stats-hero-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.export-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1972,3 +2356,4 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 772 B |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Executable
+73
@@ -0,0 +1,73 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0f172a"/>
|
||||
<stop offset="50%" stop-color="#1e293b"/>
|
||||
<stop offset="100%" stop-color="#090d16"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accentGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#38bdf8"/>
|
||||
<stop offset="100%" stop-color="#0284c7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#34d399"/>
|
||||
<stop offset="100%" stop-color="#059669"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="16" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Base Rounded Container -->
|
||||
<rect width="512" height="512" rx="115" fill="url(#bgGrad)"/>
|
||||
<rect width="510" height="510" x="1" y="1" rx="114" fill="none" stroke="#334155" stroke-width="2"/>
|
||||
<rect width="500" height="500" x="6" y="6" rx="110" fill="none" stroke="url(#accentGrad)" stroke-width="3" stroke-opacity="0.4"/>
|
||||
|
||||
<!-- Glowing Accent Light -->
|
||||
<circle cx="256" cy="180" r="140" fill="#38bdf8" opacity="0.12" filter="url(#glow)"/>
|
||||
|
||||
<!-- Open Book Symbol -->
|
||||
<g transform="translate(96, 150)">
|
||||
<!-- Book Left Page -->
|
||||
<path d="M152 40 C110 20, 45 22, 10 32 C4 34, 0 39, 0 46 L0 236 C0 244, 7 250, 15 248 C48 240, 110 238, 152 258 Z"
|
||||
fill="#1e293b" stroke="#38bdf8" stroke-width="8" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Book Right Page -->
|
||||
<path d="M168 40 C210 20, 275 22, 310 32 C316 34, 320 39, 320 46 L320 236 C320 244, 313 250, 305 248 C272 240, 210 238, 168 258 Z"
|
||||
fill="#1e293b" stroke="#38bdf8" stroke-width="8" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Spine -->
|
||||
<path d="M160 36 L160 262" stroke="#38bdf8" stroke-width="10" stroke-linecap="round"/>
|
||||
|
||||
<!-- Left Page Text Lines -->
|
||||
<line x1="36" y1="84" x2="124" y2="76" stroke="#94a3b8" stroke-width="7" stroke-linecap="round" opacity="0.8"/>
|
||||
<line x1="36" y1="124" x2="124" y2="116" stroke="#94a3b8" stroke-width="7" stroke-linecap="round" opacity="0.8"/>
|
||||
<line x1="36" y1="164" x2="104" y2="156" stroke="#94a3b8" stroke-width="7" stroke-linecap="round" opacity="0.8"/>
|
||||
|
||||
<!-- Right Page Text Lines -->
|
||||
<line x1="196" y1="76" x2="284" y2="84" stroke="#94a3b8" stroke-width="7" stroke-linecap="round" opacity="0.8"/>
|
||||
<line x1="196" y1="116" x2="284" y2="124" stroke="#94a3b8" stroke-width="7" stroke-linecap="round" opacity="0.8"/>
|
||||
<line x1="196" y1="156" x2="254" y2="164" stroke="#94a3b8" stroke-width="7" stroke-linecap="round" opacity="0.8"/>
|
||||
|
||||
<!-- Bookmark Ribbon -->
|
||||
<path d="M152 26 L152 140 L160 128 L168 140 L168 26 Z" fill="url(#emeraldGrad)"/>
|
||||
</g>
|
||||
|
||||
<!-- SRS / Spaced Repetition Learning Sparkle / Cap atop -->
|
||||
<g transform="translate(256, 120)">
|
||||
<circle cx="0" cy="0" r="14" fill="#38bdf8"/>
|
||||
<path d="M-8 0 L8 0 M0 -8 L0 8" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="-38" cy="12" r="6" fill="#34d399"/>
|
||||
<circle cx="38" cy="12" r="6" fill="#34d399"/>
|
||||
</g>
|
||||
|
||||
<!-- Bottom Accent Text "NOTES" -->
|
||||
<text x="256" y="446"
|
||||
font-family="system-ui, -apple-system, sans-serif"
|
||||
font-size="34"
|
||||
font-weight="900"
|
||||
letter-spacing="6"
|
||||
fill="#f8fafc"
|
||||
text-anchor="middle">NOTES</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
+665
-75
@@ -1,65 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<html lang="ru" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Notes — Персональный тренажёр английского языка</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
||||
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>">
|
||||
|
||||
<!-- PWA meta and icons -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Notes English">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Login Overlay / Page -->
|
||||
<div id="login-overlay" class="login-overlay hidden">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="login-logo">📖</div>
|
||||
<h1>Notes</h1>
|
||||
<p>Персональный словарь и тренажёр английского языка</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Modal / Screen -->
|
||||
<div id="auth-screen" class="auth-screen hidden">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<div class="app-logo">📖</div>
|
||||
<h2>Вход в Notes</h2>
|
||||
<p>Персональный словарь и тренажёр языка</p>
|
||||
</div>
|
||||
<form id="login-form">
|
||||
<form id="login-form" class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Имя пользователя</label>
|
||||
<input type="text" id="username" autocomplete="username" required placeholder="admin" autofocus>
|
||||
<label for="login-username">Имя пользователя</label>
|
||||
<input type="text" id="login-username" class="input-styled" autocomplete="username" required autofocus placeholder="admin">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль</label>
|
||||
<input type="password" id="password" autocomplete="current-password" required placeholder="••••••••">
|
||||
<label for="login-password">Пароль</label>
|
||||
<input type="password" id="login-password" class="input-styled" autocomplete="current-password" required placeholder="••••••••">
|
||||
</div>
|
||||
<div id="auth-error" class="error-banner hidden"></div>
|
||||
<button type="submit" id="login-btn" class="btn btn-primary btn-block">Войти в систему</button>
|
||||
|
||||
<div id="login-error" class="login-error hidden"></div>
|
||||
|
||||
<button type="submit" id="login-submit-btn" class="btn btn-primary btn-block">
|
||||
Войти в словарь
|
||||
</button>
|
||||
</form>
|
||||
<div class="auth-footer">
|
||||
<span>Защищённый контур: notes.qpid.pro</span>
|
||||
<div class="login-footer">
|
||||
<small>Безопасное хранилище словаря • notes.qpid.pro</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Application UI -->
|
||||
<div id="app-container" class="app-container hidden">
|
||||
<!-- Top Navigation -->
|
||||
<!-- Main Application Shell -->
|
||||
<div id="app-shell" class="app-shell">
|
||||
<!-- Top Navigation Bar -->
|
||||
<header class="navbar">
|
||||
<div class="nav-brand">
|
||||
<div class="nav-brand" id="nav-brand-home" title="На главную">
|
||||
<span class="brand-icon">📖</span>
|
||||
<span class="brand-title">Notes</span>
|
||||
<span class="brand-sub">English Lexicon</span>
|
||||
</div>
|
||||
|
||||
<div class="nav-center">
|
||||
<div class="nav-stats" id="nav-stats">
|
||||
<span class="stat-badge" title="Всего слов в словаре">Всего: <strong id="stat-total">0</strong></span>
|
||||
<span class="stat-badge stat-today" title="Добавлено сегодня">Сегодня: <strong id="stat-today">+0</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-actions">
|
||||
<button id="srs-trainer-btn" class="btn btn-trainer" title="Интервальный тренажер (Flashcards, 4 варианта, диктант, ввод)">
|
||||
<span class="trainer-icon">🎓</span>
|
||||
<span class="trainer-text">Тренажёр SRS</span>
|
||||
<span id="srs-badge" class="srs-due-badge">0</span>
|
||||
</button>
|
||||
<button id="stats-open-btn" class="btn btn-outline btn-sm stat-open-btn" title="Активность (GitHub Heatmap) и аналитика">
|
||||
<span>📊 Статистика</span>
|
||||
</button>
|
||||
<button id="export-open-btn" class="btn btn-outline btn-sm" title="Экспорт словаря">
|
||||
<span>📤 Экспорт</span>
|
||||
</button>
|
||||
<button id="theme-toggle" class="icon-btn" title="Переключить тему">🌓</button>
|
||||
<div class="user-profile">
|
||||
<span id="user-display-name" class="user-badge">user</span>
|
||||
<button id="change-pwd-open-btn" class="btn btn-sm btn-outline" title="Сменить пароль">🔑 Пароль</button>
|
||||
<button id="logout-btn" class="btn btn-sm btn-outline" title="Выйти из аккаунта">Выход</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
<!-- VIEW 1: Main Dictionary & Capture View -->
|
||||
<main class="main-content" id="dictionary-view">
|
||||
<!-- Search & Translation Section -->
|
||||
<section class="search-section">
|
||||
<div class="search-box-wrapper">
|
||||
@@ -70,6 +98,15 @@
|
||||
<button id="translate-btn" class="btn btn-primary translate-action-btn">Перевести</button>
|
||||
</div>
|
||||
|
||||
<!-- Duplicate Warning Notification -->
|
||||
<div id="duplicate-warning-box" class="duplicate-warning-box hidden">
|
||||
<div class="dup-left">
|
||||
<span class="dup-icon">⚠️</span>
|
||||
<span class="dup-text">Это слово уже есть в вашем словаре: <strong id="dup-term-label"></strong> (<em id="dup-trans-label"></em>)</span>
|
||||
</div>
|
||||
<button id="dup-open-btn" class="btn btn-xs btn-outline">Посмотреть / Дополнить</button>
|
||||
</div>
|
||||
|
||||
<div class="hotkey-hints">
|
||||
<span class="hint-item"><kbd>Enter</kbd> перевод</span>
|
||||
<span class="hint-item"><kbd>Ctrl</kbd>+<kbd>Enter</kbd> сохранить</span>
|
||||
@@ -78,58 +115,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loader -->
|
||||
<div id="lookup-spinner" class="lookup-spinner hidden">
|
||||
<div class="spinner"></div>
|
||||
<span>Ищем толкование, произношение и перевод...</span>
|
||||
<!-- Live Translation & Dictionary Lookup Result Card -->
|
||||
<div id="lookup-card" class="lookup-card hidden">
|
||||
<div class="lookup-header">
|
||||
<div class="lookup-term-group">
|
||||
<h2 id="res-term" class="res-term">Word</h2>
|
||||
<span id="res-phonetic" class="res-phonetic">/wɜːd/</span>
|
||||
<button id="audio-play-btn" class="icon-btn audio-btn hidden" title="Прослушать произношение">🔊</button>
|
||||
</div>
|
||||
<span id="res-pos" class="badge-pos">Noun</span>
|
||||
</div>
|
||||
|
||||
<!-- Live Result Card -->
|
||||
<div id="result-card" class="result-card hidden">
|
||||
<div class="result-header">
|
||||
<div class="term-title-row">
|
||||
<h2 id="res-term" class="res-term">reluctant</h2>
|
||||
<span id="res-phonetic" class="res-phonetic badge-pill">/rɪˈlʌktənt/</span>
|
||||
<span id="res-pos" class="res-pos badge-pill">Adjective</span>
|
||||
<button id="play-audio-btn" class="audio-btn" title="Прослушать произношение">
|
||||
🔊 <span class="audio-text">Слушать</span>
|
||||
</button>
|
||||
<!-- English Definition if available -->
|
||||
<div id="res-definition-wrap" class="definition-wrap hidden">
|
||||
<div class="def-label">Толкование (EN):</div>
|
||||
<div id="res-definition" class="res-definition"></div>
|
||||
</div>
|
||||
|
||||
<!-- Primary Translation Field (Editable before saving) -->
|
||||
<div class="form-group edit-translation-group">
|
||||
<label for="edit-translation">Перевод на русский язык:</label>
|
||||
<div class="input-with-button">
|
||||
<input type="text" id="edit-translation" class="input-styled" placeholder="Русский перевод...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="res-definition-box" class="definition-box">
|
||||
<div class="def-label">Толкование (English):</div>
|
||||
<p id="res-definition" class="def-text"></p>
|
||||
</div>
|
||||
|
||||
<div class="card-edit-grid">
|
||||
<div class="field-group">
|
||||
<label for="edit-translation">Перевод на русский <span class="req">*</span></label>
|
||||
<input type="text" id="edit-translation" class="input-styled" placeholder="Основной перевод...">
|
||||
<div id="translation-suggestions" class="suggestions-chips"></div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label for="edit-context">Контекстное предложение (где встретилось?)</label>
|
||||
<input type="text" id="edit-context" class="input-styled" placeholder="Например: The switch drops the frame if the VLAN ID is invalid.">
|
||||
<!-- Context & Extra Details Box -->
|
||||
<div class="context-group">
|
||||
<label for="edit-context">Контекстное предложение (где встретилось выражение):</label>
|
||||
<textarea id="edit-context" class="input-styled textarea-context" rows="2" placeholder="He was reluctant to admit his mistake during the team retrospective..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Organization: Collection & Tags Row -->
|
||||
<div class="meta-inputs-grid">
|
||||
<div class="field-row">
|
||||
<div class="field-group flex-2">
|
||||
<label for="edit-tags">Теги (через запятую)</label>
|
||||
<input type="text" id="edit-tags" class="input-styled" placeholder="general, phrasal-verb, networking">
|
||||
<div class="quick-tags" id="quick-tags-bar">
|
||||
<span class="tag-chip" data-tag="general">#general</span>
|
||||
<span class="tag-chip" data-tag="phrasal-verb">#phrasal-verb</span>
|
||||
<span class="tag-chip" data-tag="idiom">#idiom</span>
|
||||
<span class="tag-chip" data-tag="daily">#daily</span>
|
||||
<span class="tag-chip" data-tag="tech">#tech</span>
|
||||
<span class="tag-chip" data-tag="work">#work</span>
|
||||
<div class="form-group flex-1">
|
||||
<label for="edit-collection">📁 Коллекция (папка):</label>
|
||||
<input type="text" id="edit-collection" list="collections-datalist" class="input-styled" value="General" placeholder="General, IT, Work...">
|
||||
<datalist id="collections-datalist"></datalist>
|
||||
</div>
|
||||
<div class="form-group flex-1">
|
||||
<label for="edit-tags">🏷️ Теги (через запятую):</label>
|
||||
<input type="text" id="edit-tags" class="input-styled" placeholder="#phrasal-verb, #work, #daily" value="general">
|
||||
</div>
|
||||
|
||||
<div class="field-group flex-1">
|
||||
<label for="edit-notes">Заметка / Мнемоника</label>
|
||||
<div class="form-group flex-1">
|
||||
<label for="edit-notes">💡 Заметка / Ассоциация:</label>
|
||||
<input type="text" id="edit-notes" class="input-styled" placeholder="Ассоциация...">
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,16 +182,33 @@
|
||||
</div>
|
||||
|
||||
<div class="list-controls">
|
||||
<button id="batch-toggle-btn" class="btn btn-sm btn-outline batch-mode-btn" title="Режим множественного выбора">
|
||||
☑️ Выбрать
|
||||
</button>
|
||||
<div class="search-mini-box">
|
||||
<input type="text" id="filter-input" placeholder="Фильтр по слову, переводу, контексту...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collections Filter Bar -->
|
||||
<div class="collections-filter-wrap">
|
||||
<div class="filter-header-bar">
|
||||
<span class="filter-title">📁 Коллекции:</span>
|
||||
<button id="new-collection-btn" class="btn btn-xs btn-ghost">+ Новая папка</button>
|
||||
</div>
|
||||
<div id="collections-filter-bar" class="filter-chips-row">
|
||||
<button class="col-chip active" data-col="">Все коллекции</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Tags Filter -->
|
||||
<div id="tags-filter-bar" class="tags-filter-bar">
|
||||
<div class="tags-filter-wrap">
|
||||
<span class="filter-title">🏷️ Теги:</span>
|
||||
<div id="tags-filter-bar" class="filter-chips-row">
|
||||
<button class="filter-chip active" data-tag="">Все теги</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cards Grid/List -->
|
||||
<div id="cards-list" class="cards-list">
|
||||
@@ -173,33 +220,447 @@
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- VIEW 2: SRS Trainer Interactive View -->
|
||||
<section id="srs-view" class="srs-view hidden">
|
||||
<!-- SRS Top Bar -->
|
||||
<header class="srs-topbar">
|
||||
<div class="srs-topbar-left">
|
||||
<button id="srs-exit-btn" class="btn btn-sm btn-outline">← В словарь</button>
|
||||
<div class="srs-mode-switch">
|
||||
<button class="srs-tab active" data-mode="flashcards">📇 Карточки</button>
|
||||
<button class="srs-tab" data-mode="quiz">🎯 4 Варианта</button>
|
||||
<button class="srs-tab" data-mode="typing">✍️ Набор слова</button>
|
||||
<button class="srs-tab" data-mode="cloze">🧩 Пропуски</button>
|
||||
<button class="srs-tab" data-mode="dictation">🎧 Диктант</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="srs-topbar-center">
|
||||
<div class="srs-progress-container">
|
||||
<div id="srs-progress-fill" class="srs-progress-fill" style="width: 0%;"></div>
|
||||
</div>
|
||||
<span id="srs-counter" class="srs-counter">Карточка 0 из 0</span>
|
||||
</div>
|
||||
|
||||
<div class="srs-topbar-right">
|
||||
<button id="srs-mix-btn" class="btn btn-sm btn-ghost" title="Умный микс дня: 10 новых + 10 повторов + 5 трудных слов">
|
||||
<span id="srs-mix-label">🎲 Микс дня: ВКЛ</span>
|
||||
</button>
|
||||
<button id="srs-dir-btn" class="btn btn-sm btn-secondary" title="Сменить направление">
|
||||
<span id="srs-dir-label">EN ➔ RU</span> ⇄
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- SRS Main Interactive Arena -->
|
||||
<div class="srs-arena">
|
||||
<!-- Loading State -->
|
||||
<div id="srs-loading" class="srs-state-box hidden">
|
||||
<div class="spinner"></div>
|
||||
<p>Загрузка карточек для тренировки...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="srs-empty" class="srs-state-box hidden">
|
||||
<div class="srs-big-icon">🎉</div>
|
||||
<h3>На сегодня всё повторено!</h3>
|
||||
<p>В очереди нет слов, требующих интервального повторения.</p>
|
||||
<div class="srs-empty-buttons">
|
||||
<button id="srs-train-all-btn" class="btn btn-primary btn-lg">Тренировать все слова вне очереди</button>
|
||||
<button id="srs-empty-back-btn" class="btn btn-outline btn-lg">Вернуться в словарь</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODE 1: Flashcards -->
|
||||
<div id="srs-mode-flashcards" class="srs-mode-container hidden">
|
||||
<div class="flashcard-wrapper" id="flashcard-flip-wrap">
|
||||
<div class="flashcard-inner" id="flashcard-inner">
|
||||
<!-- Card Front -->
|
||||
<div class="flashcard-face flashcard-front" id="flashcard-front">
|
||||
<div class="fc-badge-row">
|
||||
<span class="fc-dir-badge" id="fc-front-dir-badge">EN ➔ RU</span>
|
||||
<span class="fc-pos-badge" id="fc-front-pos">Adjective</span>
|
||||
</div>
|
||||
<div class="fc-center">
|
||||
<h1 class="fc-main-term" id="fc-front-main">reluctant</h1>
|
||||
<div class="fc-phonetic-row" id="fc-front-phonetic-wrap">
|
||||
<span class="res-phonetic" id="fc-front-phonetic">/rɪˈlʌktənt/</span>
|
||||
<button id="fc-front-audio-btn" class="icon-btn audio-round-btn" title="Озвучить (Ctrl+P / 🔊)">🔊</button>
|
||||
</div>
|
||||
<div class="fc-hint-wrapper">
|
||||
<button id="fc-hint-btn" class="btn btn-xs btn-ghost">💡 Показать контекст</button>
|
||||
<div id="fc-hint-box" class="fc-hint-content hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fc-footer-hint">
|
||||
<kbd>Space</kbd> или клик по карточке — перевернуть
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Back -->
|
||||
<div class="flashcard-face flashcard-back" id="flashcard-back">
|
||||
<div class="fc-badge-row">
|
||||
<span class="fc-dir-badge">ОТВЕТ</span>
|
||||
<span class="fc-pos-badge" id="fc-back-pos">Adjective</span>
|
||||
</div>
|
||||
<div class="fc-back-content">
|
||||
<div class="fc-back-top">
|
||||
<h2 class="fc-back-term" id="fc-back-term">reluctant</h2>
|
||||
<button id="fc-back-audio-btn" class="icon-btn audio-round-btn" title="Озвучить">🔊</button>
|
||||
</div>
|
||||
<div class="fc-back-translation" id="fc-back-translation">неохотный, нежелающий</div>
|
||||
<div class="fc-back-detail-box" id="fc-back-def-box">
|
||||
<span class="detail-label">Толкование:</span>
|
||||
<p id="fc-back-def" class="detail-text"></p>
|
||||
</div>
|
||||
<div class="fc-back-detail-box" id="fc-back-context-box">
|
||||
<span class="detail-label">Контекст:</span>
|
||||
<p id="fc-back-context" class="detail-text"></p>
|
||||
</div>
|
||||
<div class="fc-back-detail-box" id="fc-back-notes-box">
|
||||
<span class="detail-label">Заметка:</span>
|
||||
<p id="fc-back-notes" class="detail-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flip button (mobile accessible) -->
|
||||
<div id="fc-flip-btn-wrap" class="fc-flip-btn-wrap">
|
||||
<button id="fc-flip-btn" class="btn btn-primary btn-block btn-lg">Показать ответ (Space)</button>
|
||||
</div>
|
||||
|
||||
<!-- Rating Buttons -->
|
||||
<div id="fc-rating-bar" class="srs-rating-bar hidden">
|
||||
<button class="btn-rate btn-rate-1" data-rating="1" title="Горячая клавиша: 1">
|
||||
<span class="rate-key">[1]</span>
|
||||
<span class="rate-name">Снова</span>
|
||||
<span class="rate-time">< 1 мин</span>
|
||||
</button>
|
||||
<button class="btn-rate btn-rate-2" data-rating="2" title="Горячая клавиша: 2">
|
||||
<span class="rate-key">[2]</span>
|
||||
<span class="rate-name">Трудно</span>
|
||||
<span class="rate-time">1 день</span>
|
||||
</button>
|
||||
<button class="btn-rate btn-rate-3" data-rating="3" title="Горячая клавиша: 3">
|
||||
<span class="rate-key">[3]</span>
|
||||
<span class="rate-name">Хорошо</span>
|
||||
<span class="rate-time">3 дня</span>
|
||||
</button>
|
||||
<button class="btn-rate btn-rate-4" data-rating="4" title="Горячая клавиша: 4">
|
||||
<span class="rate-key">[4]</span>
|
||||
<span class="rate-name">Легко</span>
|
||||
<span class="rate-time">7 дней</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODE 2: Multiple Choice Quiz (4 options) -->
|
||||
<div id="srs-mode-quiz" class="srs-mode-container hidden">
|
||||
<div class="trainer-card quiz-card">
|
||||
<div class="trainer-header">
|
||||
<span class="mode-tag">🎯 Выбор из 4 вариантов</span>
|
||||
<span class="trainer-pos" id="quiz-pos"></span>
|
||||
</div>
|
||||
|
||||
<div class="quiz-hero">
|
||||
<div class="quiz-prompt-row">
|
||||
<h2 id="quiz-term-display" class="quiz-term-display">word</h2>
|
||||
<button id="quiz-audio-btn" class="icon-btn audio-round-btn" title="Озвучить">🔊</button>
|
||||
</div>
|
||||
<div id="quiz-phonetic-display" class="res-phonetic"></div>
|
||||
<div id="quiz-context-hint" class="quiz-context-hint hidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="quiz-options-grid" id="quiz-options-grid">
|
||||
<!-- 4 option buttons generated dynamically -->
|
||||
</div>
|
||||
|
||||
<div id="quiz-feedback-box" class="trainer-feedback-box hidden"></div>
|
||||
<div id="quiz-details-box" class="trainer-revealed-details hidden"></div>
|
||||
|
||||
<div id="quiz-next-bar" class="quiz-next-bar hidden">
|
||||
<button id="quiz-next-btn" class="btn btn-primary btn-lg btn-block">Следующее слово (Enter / Space)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODE 3: Active Typing Mode (Ru -> En) -->
|
||||
<div id="srs-mode-typing" class="srs-mode-container hidden">
|
||||
<div class="trainer-card">
|
||||
<div class="trainer-header">
|
||||
<span class="mode-tag">✍️ Набор слова на английском</span>
|
||||
<span class="trainer-pos" id="typing-pos"></span>
|
||||
</div>
|
||||
|
||||
<div class="typing-hero">
|
||||
<span class="hint-label">Переведите на английский язык:</span>
|
||||
<h2 id="typing-prompt-ru" class="typing-prompt-ru">слово</h2>
|
||||
<div id="typing-context-hint" class="typing-context-hint hidden"></div>
|
||||
</div>
|
||||
|
||||
<form id="typing-form" class="trainer-form">
|
||||
<div class="input-action-row">
|
||||
<input type="text" id="typing-input" class="input-styled input-hero" placeholder="Напечатайте английское слово или связку..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus>
|
||||
<button type="submit" id="typing-submit-btn" class="btn btn-primary">Проверить</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="typing-feedback-box" class="trainer-feedback-box hidden"></div>
|
||||
<div id="typing-details-box" class="trainer-revealed-details hidden"></div>
|
||||
|
||||
<!-- Rating bar for typing -->
|
||||
<div id="typing-rating-bar" class="srs-rating-bar hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODE 4: Cloze Deletion -->
|
||||
<div id="srs-mode-cloze" class="srs-mode-container hidden">
|
||||
<div class="trainer-card">
|
||||
<div class="trainer-header">
|
||||
<span class="mode-tag">🧩 Пропуски в контексте</span>
|
||||
<span class="trainer-pos" id="cloze-pos"></span>
|
||||
</div>
|
||||
|
||||
<div class="cloze-sentence-display" id="cloze-sentence-display">
|
||||
The switch <span class="gap-placeholder">_____</span> the frame if invalid.
|
||||
</div>
|
||||
|
||||
<div class="cloze-translation-hint">
|
||||
<span class="hint-label">Перевод целевого слова:</span>
|
||||
<strong id="cloze-target-translation">неохотный</strong>
|
||||
</div>
|
||||
|
||||
<form id="cloze-form" class="trainer-form">
|
||||
<div class="input-action-row">
|
||||
<input type="text" id="cloze-input" class="input-styled input-hero" placeholder="Введите пропущенное слово..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus>
|
||||
<button type="submit" id="cloze-submit-btn" class="btn btn-primary">Проверить</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="cloze-feedback-box" class="trainer-feedback-box hidden"></div>
|
||||
<div id="cloze-details-box" class="trainer-revealed-details hidden"></div>
|
||||
|
||||
<!-- Rating bar for cloze -->
|
||||
<div id="cloze-rating-bar" class="srs-rating-bar hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODE 5: Audio Dictation -->
|
||||
<div id="srs-mode-dictation" class="srs-mode-container hidden">
|
||||
<div class="trainer-card">
|
||||
<div class="trainer-header">
|
||||
<span class="mode-tag">🎧 Аудио-диктант</span>
|
||||
<button id="dictation-replay-btn" class="btn btn-sm btn-ghost audio-replay-btn">
|
||||
🔊 Повторить звук <span class="kbd-sub"><kbd>Space</kbd></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dictation-hero">
|
||||
<button id="dictation-big-play-btn" class="dictation-pulse-btn" title="Слушать произношение">
|
||||
🔊
|
||||
</button>
|
||||
<p class="dictation-instructions">Слушайте произношение нативного диктора и напечатайте слово</p>
|
||||
</div>
|
||||
|
||||
<div class="dictation-hint-wrap">
|
||||
<button id="dictation-toggle-hint-btn" class="btn btn-xs btn-ghost">💡 Показать перевод-подсказку</button>
|
||||
<span id="dictation-hint-text" class="dictation-hint-text hidden"></span>
|
||||
</div>
|
||||
|
||||
<form id="dictation-form" class="trainer-form">
|
||||
<div class="input-action-row">
|
||||
<input type="text" id="dictation-input" class="input-styled input-hero" placeholder="Напечатайте услышанное слово на английском..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus>
|
||||
<button type="submit" id="dictation-submit-btn" class="btn btn-primary">Проверить</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="dictation-feedback-box" class="trainer-feedback-box hidden"></div>
|
||||
<div id="dictation-details-box" class="trainer-revealed-details hidden"></div>
|
||||
|
||||
<!-- Rating bar for dictation -->
|
||||
<div id="dictation-rating-bar" class="srs-rating-bar hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Complete Summary Screen -->
|
||||
<div id="srs-summary" class="srs-summary-card hidden">
|
||||
<div class="summary-trophy">🏆</div>
|
||||
<h2>Сессия завершена!</h2>
|
||||
<p class="summary-lead">Вы успешно закрепили знания и освежили долговременную память.</p>
|
||||
|
||||
<div class="summary-metrics">
|
||||
<div class="metric-card">
|
||||
<span class="metric-val" id="sum-count">0</span>
|
||||
<span class="metric-desc">Повторено слов</span>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span class="metric-val text-success" id="sum-retention">100%</span>
|
||||
<span class="metric-desc">Успешность (Хорошо/Легко)</span>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span class="metric-val text-primary" id="sum-streak">🔥 1</span>
|
||||
<span class="metric-desc">Серия дней</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-rates-bar" id="sum-rates-breakdown"></div>
|
||||
|
||||
<div class="summary-buttons">
|
||||
<button id="sum-repeat-btn" class="btn btn-primary btn-lg">🔄 Повторить ещё сессию</button>
|
||||
<button id="sum-return-btn" class="btn btn-outline btn-lg">📚 Вернуться в словарь</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sticky Bulk Action Bar for Batch Selection Mode -->
|
||||
<div id="batch-action-bar" class="batch-action-bar hidden">
|
||||
<div class="batch-left">
|
||||
<label class="batch-check-label">
|
||||
<input type="checkbox" id="batch-select-all" class="batch-master-checkbox">
|
||||
<span id="batch-count-label">Выбрано: 0</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="batch-actions">
|
||||
<button id="batch-move-btn" class="btn btn-sm btn-outline">📁 В коллекцию</button>
|
||||
<button id="batch-tag-btn" class="btn btn-sm btn-outline">🏷️ Добавить тег</button>
|
||||
<button id="batch-delete-btn" class="btn btn-sm btn-danger">🗑️ Удалить</button>
|
||||
<button id="batch-cancel-btn" class="btn btn-sm btn-ghost">✕ Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analytics & Activity Heatmap Modal -->
|
||||
<div id="stats-modal" class="modal-backdrop hidden">
|
||||
<div class="modal-window stats-modal-window">
|
||||
<div class="modal-header">
|
||||
<h3>📊 Аналитика обучения & Активность</h3>
|
||||
<button id="close-stats-modal-btn" class="icon-btn">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Streak & Core Stats Hero -->
|
||||
<div class="stats-hero-grid">
|
||||
<div class="stat-kpi-card streak-kpi">
|
||||
<div class="kpi-icon">🔥</div>
|
||||
<div class="kpi-value" id="kpi-current-streak">0</div>
|
||||
<div class="kpi-label">Текущая серия дней</div>
|
||||
<div class="kpi-sub" id="kpi-longest-streak">Рекорд: 0 дней</div>
|
||||
</div>
|
||||
<div class="stat-kpi-card">
|
||||
<div class="kpi-icon">🎯</div>
|
||||
<div class="kpi-value text-success" id="kpi-retention-rate">100%</div>
|
||||
<div class="kpi-label">Точность запоминания</div>
|
||||
<div class="kpi-sub" id="kpi-total-reviews">Всего повторений: 0</div>
|
||||
</div>
|
||||
<div class="stat-kpi-card">
|
||||
<div class="kpi-icon">📚</div>
|
||||
<div class="kpi-value" id="kpi-total-words">0</div>
|
||||
<div class="kpi-label">Слов в словаре</div>
|
||||
<div class="kpi-sub" id="kpi-today-added">Сегодня добавлено: +0</div>
|
||||
</div>
|
||||
<div class="stat-kpi-card">
|
||||
<div class="kpi-icon">🎓</div>
|
||||
<div class="kpi-value text-accent" id="kpi-cefr-level">A1</div>
|
||||
<div class="kpi-label">Оценка уровня</div>
|
||||
<div class="kpi-sub" id="kpi-cefr-desc">Начальный словарный запас</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GitHub-Style Contribution Heatmap -->
|
||||
<div class="heatmap-section">
|
||||
<div class="heatmap-header">
|
||||
<h4>🟩 Календарь активности (GitHub-style Heatmap)</h4>
|
||||
<div class="heatmap-legend">
|
||||
<span>Меньше</span>
|
||||
<span class="legend-cell lvl-0" title="0 повторений"></span>
|
||||
<span class="legend-cell lvl-1" title="1 повторение"></span>
|
||||
<span class="legend-cell lvl-2" title="2-3 повторения"></span>
|
||||
<span class="legend-cell lvl-3" title="4+ повторений"></span>
|
||||
<span>Больше</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heatmap-container-wrap">
|
||||
<div id="heatmap-grid" class="heatmap-grid">
|
||||
<!-- Heatmap cells generated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="heatmap-footer-note">
|
||||
<small>Цвет ячейки показывает количество пройденных тестов и интервальных повторений за день.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mastery Distribution Progress -->
|
||||
<div class="mastery-section">
|
||||
<h4>📈 Стадии освоения слов (SM-2 SRS)</h4>
|
||||
<div class="segmented-progress-bar">
|
||||
<div id="prog-mastered" class="prog-segment seg-mastered" style="width: 0%;" title="Выучено (интервал > 21 дня)"></div>
|
||||
<div id="prog-review" class="prog-segment seg-review" style="width: 0%;" title="Повторение"></div>
|
||||
<div id="prog-learning" class="prog-segment seg-learning" style="width: 0%;" title="В процессе (1-3 повтора)"></div>
|
||||
<div id="prog-new" class="prog-segment seg-new" style="width: 100%;" title="Новые слова"></div>
|
||||
</div>
|
||||
<div class="mastery-legend-row">
|
||||
<div class="m-legend-item"><span class="dot dot-mastered"></span> Выучено: <strong id="cnt-mastered">0</strong></div>
|
||||
<div class="m-legend-item"><span class="dot dot-review"></span> Закрепление: <strong id="cnt-review">0</strong></div>
|
||||
<div class="m-legend-item"><span class="dot dot-learning"></span> В процессе: <strong id="cnt-learning">0</strong></div>
|
||||
<div class="m-legend-item"><span class="dot dot-new"></span> Новые: <strong id="cnt-new">0</strong></div>
|
||||
<div class="m-legend-item text-danger"><span class="dot dot-leech"></span> Трудные слова: <strong id="cnt-leeches">0</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="close-stats-btn" class="btn btn-primary">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Card Modal -->
|
||||
<div id="edit-modal" class="modal-backdrop hidden">
|
||||
<div class="modal-window">
|
||||
<div class="modal-header">
|
||||
<h3>Редактирование карточки</h3>
|
||||
<h3>✏️ Редактировать карточку</h3>
|
||||
<button id="close-modal-btn" class="icon-btn">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="modal-card-id">
|
||||
<div class="form-group">
|
||||
<label>Слово / Фраза</label>
|
||||
<input type="text" id="modal-term" class="input-styled">
|
||||
<label>Слово / Выражение (EN)</label>
|
||||
<input type="text" id="modal-term" class="input-styled" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Перевод на русский</label>
|
||||
<input type="text" id="modal-translation" class="input-styled">
|
||||
<label>Перевод (RU)</label>
|
||||
<input type="text" id="modal-translation" class="input-styled" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Контекстное предложение</label>
|
||||
<input type="text" id="modal-context" class="input-styled">
|
||||
<textarea id="modal-context" class="input-styled" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="form-group flex-1">
|
||||
<label>Транскрипция (IPA)</label>
|
||||
<input type="text" id="modal-phonetic" class="input-styled">
|
||||
</div>
|
||||
<div class="form-group flex-1">
|
||||
<label>Часть речи</label>
|
||||
<input type="text" id="modal-pos" class="input-styled">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Английское толкование</label>
|
||||
<textarea id="modal-def" class="input-styled" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="form-group flex-1">
|
||||
<label>Коллекция (Папка)</label>
|
||||
<input type="text" id="modal-collection" class="input-styled">
|
||||
</div>
|
||||
<div class="form-group flex-1">
|
||||
<label>Теги</label>
|
||||
<input type="text" id="modal-tags" class="input-styled">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Заметки / Мнемоника</label>
|
||||
<input type="text" id="modal-notes" class="input-styled">
|
||||
@@ -212,6 +673,135 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Modal -->
|
||||
<div id="export-modal" class="modal-backdrop hidden">
|
||||
<div class="modal-window export-modal-window">
|
||||
<div class="modal-header">
|
||||
<h3>📤 Экспорт словаря</h3>
|
||||
<button id="close-export-modal-btn" class="icon-btn">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-lead" id="export-modal-desc">Выберите удобный формат для выгрузки карточек:</p>
|
||||
<div class="export-grid">
|
||||
<div class="export-tile" data-format="csv">
|
||||
<div class="export-tile-icon">📊</div>
|
||||
<div class="export-tile-info">
|
||||
<h4>CSV (Excel / Sheets)</h4>
|
||||
<p>Таблица со всеми полями, переводами и примерами (UTF-8 BOM для русского языка).</p>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary">Скачать</button>
|
||||
</div>
|
||||
|
||||
<div class="export-tile" data-format="anki">
|
||||
<div class="export-tile-icon">🗃️</div>
|
||||
<div class="export-tile-info">
|
||||
<h4>Колода для Anki (.tsv)</h4>
|
||||
<p>Готовый файл для импорта в Anki с транскрипцией, озвучкой и контекстом.</p>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary">Скачать</button>
|
||||
</div>
|
||||
|
||||
<div class="export-tile" data-format="json">
|
||||
<div class="export-tile-icon">📋</div>
|
||||
<div class="export-tile-info">
|
||||
<h4>JSON (Полный архив)</h4>
|
||||
<p>Полная структурированная резервная копия данных словаря.</p>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary">Скачать</button>
|
||||
</div>
|
||||
|
||||
<div class="export-tile" data-format="md">
|
||||
<div class="export-tile-icon">📝</div>
|
||||
<div class="export-tile-info">
|
||||
<h4>Markdown (.md)</h4>
|
||||
<p>Красивый структурированный конспект для Obsidian, Notion или чтения.</p>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary">Скачать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<span id="export-scope-label" class="text-dim">Объём экспорта: все карточки</span>
|
||||
<button id="export-cancel-btn" class="btn btn-outline">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Move Collection Modal -->
|
||||
<div id="move-col-modal" class="modal-backdrop hidden">
|
||||
<div class="modal-window modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>📁 Переместить в коллекцию</h3>
|
||||
<button id="close-move-modal-btn" class="icon-btn">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-lead" id="move-modal-count-label">Выберите коллекцию для выбранных карточек:</p>
|
||||
<div class="form-group">
|
||||
<label for="move-col-input">Название коллекции</label>
|
||||
<input type="text" id="move-col-input" list="move-collections-datalist" class="input-styled" placeholder="Например: Разговорный">
|
||||
<datalist id="move-collections-datalist"></datalist>
|
||||
</div>
|
||||
<div class="quick-tags" id="move-col-quick-list"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="move-col-cancel-btn" class="btn btn-outline">Отмена</button>
|
||||
<button id="move-col-confirm-btn" class="btn btn-primary">Переместить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Collection Modal -->
|
||||
<div id="new-col-modal" class="modal-backdrop hidden">
|
||||
<div class="modal-window modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>📁 Новая коллекция</h3>
|
||||
<button id="close-new-col-modal-btn" class="icon-btn">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="new-col-name-input">Название новой коллекции</label>
|
||||
<input type="text" id="new-col-name-input" class="input-styled" placeholder="Например: Сериалы / Netflix">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="new-col-cancel-btn" class="btn btn-outline">Отмена</button>
|
||||
<button id="new-col-save-btn" class="btn btn-primary">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Modal -->
|
||||
<div id="change-password-modal" class="modal-backdrop hidden">
|
||||
<div class="modal-window modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>🔑 Смена пароля</h3>
|
||||
<button id="close-change-pwd-btn" class="icon-btn">✕</button>
|
||||
</div>
|
||||
<form id="change-password-form">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="current-password-input">Текущий пароль</label>
|
||||
<input type="password" id="current-password-input" class="input-styled" autocomplete="current-password" required placeholder="••••••••">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-password-input">Новый пароль</label>
|
||||
<input type="password" id="new-password-input" class="input-styled" autocomplete="new-password" required minlength="6" placeholder="Минимум 6 символов">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm-password-input">Подтвердите новый пароль</label>
|
||||
<input type="password" id="confirm-password-input" class="input-styled" autocomplete="new-password" required minlength="6" placeholder="Повторите новый пароль">
|
||||
</div>
|
||||
<div id="change-pwd-error" class="error-banner hidden"></div>
|
||||
<div id="change-pwd-success" class="success-banner hidden"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="change-pwd-cancel-btn" class="btn btn-outline">Отмена</button>
|
||||
<button type="submit" id="change-pwd-save-btn" class="btn btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio Element for native streaming -->
|
||||
<audio id="global-audio-player" preload="none"></audio>
|
||||
|
||||
|
||||
+1433
-385
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "Notes English — Personal Vocabulary",
|
||||
"short_name": "Notes",
|
||||
"description": "Персональный словарь английского языка с переводом, транскрипцией и произношением.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0f172a",
|
||||
"lang": "ru",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["education", "productivity"],
|
||||
"screenshots": []
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Service Worker for Notes English PWA (Stage 2)
|
||||
// Strategy: Cache-first for static assets, Network-first for API calls.
|
||||
|
||||
const CACHE_NAME = 'notes-v2.0';
|
||||
const OFFLINE_URL = '/';
|
||||
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/static/css/style.css',
|
||||
'/static/js/app.js',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Install: pre-cache core assets
|
||||
// -----------------------------------------------------------------------
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(PRECACHE_URLS);
|
||||
}).then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Activate: delete old caches
|
||||
// -----------------------------------------------------------------------
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => {
|
||||
return Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_NAME)
|
||||
.map((key) => caches.delete(key))
|
||||
);
|
||||
}).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fetch: strategy based on request type
|
||||
// -----------------------------------------------------------------------
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip cross-origin requests
|
||||
if (url.origin !== location.origin) return;
|
||||
|
||||
// API calls → Network-first (never serve API from cache)
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Offline. Please check your connection.' }),
|
||||
{ status: 503, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets (CSS, JS, icons) → Cache-first
|
||||
if (
|
||||
url.pathname.startsWith('/static/') ||
|
||||
url.pathname === '/manifest.json' ||
|
||||
url.pathname === '/sw.js'
|
||||
) {
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
return cached || fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML navigation (SPA root) → Network-first with offline fallback
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match(OFFLINE_URL).then((cached) => {
|
||||
return cached || new Response('<h1>Offline</h1><p>Please check your connection.</p>', {
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
+332
-1
@@ -13,7 +13,7 @@ os.environ["ADMIN_PASSWORD"] = "testpass12345"
|
||||
from app.main import app
|
||||
from app import config
|
||||
from app.database import init_db, get_db
|
||||
from app.auth import hash_password, verify_password, create_user, authenticate_user, ensure_admin_user
|
||||
from app.auth import hash_password, verify_password, create_user, authenticate_user, ensure_admin_user, change_user_password
|
||||
|
||||
config.ADMIN_USERNAME = "testadmin"
|
||||
config.ADMIN_PASSWORD = "testpass12345"
|
||||
@@ -57,6 +57,24 @@ def test_unauthenticated_access_blocked():
|
||||
resp = client.post("/api/cards", json={"term": "apple", "translation": "яблоко"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = client.get("/api/collections")
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = client.post("/api/cards/batch", json={"card_ids": [1], "action": "delete"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = client.get("/api/export/csv")
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = client.get("/api/srs/queue")
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = client.get("/api/srs/stats")
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = client.post("/api/srs/review", json={"card_id": 1, "rating": 3})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_and_card_crud_workflow():
|
||||
# 1. Login with testadmin
|
||||
resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||
@@ -173,3 +191,316 @@ def test_lookup_endpoint_authenticated():
|
||||
assert "phonetic" in data
|
||||
assert len(data["translation"]) > 0
|
||||
|
||||
def test_collections_and_batch_operations():
|
||||
# Login
|
||||
client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||
|
||||
# Create 3 cards in different collections
|
||||
c1 = client.post("/api/cards", json={
|
||||
"term": "ubiquitous",
|
||||
"translation": "вездесущий",
|
||||
"collection": "Academic",
|
||||
"tags": "vocab, tech"
|
||||
}).json()
|
||||
|
||||
c2 = client.post("/api/cards", json={
|
||||
"term": "perseverance",
|
||||
"translation": "настойчивость",
|
||||
"collection": "Academic",
|
||||
"tags": "character"
|
||||
}).json()
|
||||
|
||||
c3 = client.post("/api/cards", json={
|
||||
"term": "bite the bullet",
|
||||
"translation": "стиснуть зубы",
|
||||
"collection": "Idioms",
|
||||
"tags": "idioms, daily"
|
||||
}).json()
|
||||
|
||||
# Test GET /api/collections
|
||||
resp = client.get("/api/collections")
|
||||
assert resp.status_code == 200
|
||||
cols = resp.json()
|
||||
col_names = {c["name"]: c["count"] for c in cols}
|
||||
assert "Academic" in col_names and col_names["Academic"] >= 2
|
||||
assert "Idioms" in col_names and col_names["Idioms"] >= 1
|
||||
|
||||
# Test filter by collection in /api/cards
|
||||
resp = client.get("/api/cards?collection=Academic")
|
||||
assert resp.status_code == 200
|
||||
academic_cards = resp.json()
|
||||
assert all(c["collection"] == "Academic" for c in academic_cards["cards"])
|
||||
assert any(c["id"] == c1["id"] for c in academic_cards["cards"])
|
||||
assert any(c["id"] == c2["id"] for c in academic_cards["cards"])
|
||||
assert not any(c["id"] == c3["id"] for c in academic_cards["cards"])
|
||||
|
||||
# Test Batch set_collection
|
||||
batch_resp = client.post("/api/cards/batch", json={
|
||||
"card_ids": [c1["id"], c2["id"]],
|
||||
"action": "set_collection",
|
||||
"value": "Advanced English"
|
||||
})
|
||||
assert batch_resp.status_code == 200
|
||||
assert batch_resp.json()["affected"] == 2
|
||||
|
||||
# Verify collection updated
|
||||
card1_check = client.get("/api/cards?search=ubiquitous").json()["cards"][0]
|
||||
assert card1_check["collection"] == "Advanced English"
|
||||
|
||||
# Test Batch add_tags
|
||||
batch_resp = client.post("/api/cards/batch", json={
|
||||
"card_ids": [c1["id"], c2["id"]],
|
||||
"action": "add_tags",
|
||||
"value": "stage2, vocabulary"
|
||||
})
|
||||
assert batch_resp.status_code == 200
|
||||
card1_tags = client.get("/api/cards?search=ubiquitous").json()["cards"][0]["tags"]
|
||||
assert "stage2" in card1_tags
|
||||
assert "vocabulary" in card1_tags
|
||||
|
||||
# Test Batch delete
|
||||
batch_resp = client.post("/api/cards/batch", json={
|
||||
"card_ids": [c1["id"], c2["id"], c3["id"]],
|
||||
"action": "delete"
|
||||
})
|
||||
assert batch_resp.status_code == 200
|
||||
assert batch_resp.json()["affected"] == 3
|
||||
|
||||
# Verify deleted
|
||||
all_cards = client.get("/api/cards").json()["cards"]
|
||||
deleted_ids = {c1["id"], c2["id"], c3["id"]}
|
||||
assert not any(c["id"] in deleted_ids for c in all_cards)
|
||||
|
||||
def test_export_endpoints():
|
||||
client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||
|
||||
# Create a test card with context sentence for cloze
|
||||
card_resp = client.post("/api/cards", json={
|
||||
"term": "serendipity",
|
||||
"translation": "счастливая случайность",
|
||||
"context_sentence": "Finding this book was pure serendipity.",
|
||||
"phonetic": "ˌser.ənˈdɪp.ə.ti",
|
||||
"part_of_speech": "Noun",
|
||||
"definition_en": "Good luck in making unexpected discoveries",
|
||||
"collection": "Vocabulary",
|
||||
"tags": "literature, rare",
|
||||
"notes": "Beautiful English word"
|
||||
})
|
||||
card = card_resp.json()
|
||||
|
||||
# 1. CSV Export
|
||||
resp = client.get("/api/export/csv")
|
||||
assert resp.status_code == 200
|
||||
assert "text/csv" in resp.headers["Content-Type"]
|
||||
assert resp.content.startswith(b"\xef\xbb\xbf") # UTF-8 BOM
|
||||
csv_text = resp.content.decode("utf-8-sig")
|
||||
assert "serendipity" in csv_text
|
||||
assert "счастливая случайность" in csv_text
|
||||
assert "term,translation,context,phonetic" in csv_text
|
||||
|
||||
# 2. JSON Export
|
||||
resp = client.get("/api/export/json")
|
||||
assert resp.status_code == 200
|
||||
assert "application/json" in resp.headers["Content-Type"]
|
||||
json_data = resp.json()
|
||||
assert "cards" in json_data
|
||||
assert any(c["term"] == "serendipity" for c in json_data["cards"])
|
||||
|
||||
# 3. Anki Export
|
||||
resp = client.get("/api/export/anki")
|
||||
assert resp.status_code == 200
|
||||
anki_text = resp.text
|
||||
assert "#separator:tab" in anki_text
|
||||
assert "#html:true" in anki_text
|
||||
assert "{{c1::serendipity}}" in anki_text # Cloze verified
|
||||
assert "collection_Vocabulary" in anki_text
|
||||
|
||||
# 4. Markdown Export
|
||||
resp = client.get("/api/export/markdown")
|
||||
assert resp.status_code == 200
|
||||
md_text = resp.text
|
||||
assert "| **serendipity** |" in md_text
|
||||
assert "### serendipity" in md_text
|
||||
|
||||
# Filter export by collection
|
||||
resp_filtered = client.get("/api/export/json?collection=NonExistentCollection")
|
||||
assert resp_filtered.status_code == 200
|
||||
assert len(resp_filtered.json()["cards"]) == 0
|
||||
|
||||
# Clean up
|
||||
client.delete(f"/api/cards/{card['id']}")
|
||||
|
||||
def test_sm2_algorithm_unit():
|
||||
from app.srs import calculate_sm2, calculate_next_review
|
||||
from datetime import datetime, timezone
|
||||
|
||||
base = datetime(2026, 9, 22, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Rating 1 (Again)
|
||||
res1 = calculate_sm2(1, reps=2, interval=6, ease=2.5, base_time=base)
|
||||
assert res1["srs_reps"] == 0
|
||||
assert res1["srs_interval"] == 1
|
||||
assert res1["srs_ease"] == 2.3
|
||||
assert res1["srs_state"] == "learning"
|
||||
|
||||
# Rating 2 (Hard)
|
||||
res2 = calculate_sm2(2, reps=1, interval=1, ease=2.5, base_time=base)
|
||||
assert res2["srs_reps"] == 2
|
||||
assert res2["srs_interval"] == 1
|
||||
assert res2["srs_ease"] == 2.35
|
||||
assert res2["srs_state"] == "learning"
|
||||
|
||||
res2_later = calculate_sm2(2, reps=2, interval=10, ease=2.5, base_time=base)
|
||||
assert res2_later["srs_reps"] == 3
|
||||
assert res2_later["srs_interval"] == 12 # round(10 * 1.2) = 12
|
||||
assert res2_later["srs_state"] == "review" # reps >= 3
|
||||
|
||||
# Rating 3 (Good)
|
||||
res3_first = calculate_sm2(3, reps=0, interval=0, ease=2.5, base_time=base)
|
||||
assert res3_first["srs_reps"] == 1
|
||||
assert res3_first["srs_interval"] == 1
|
||||
assert res3_first["srs_state"] == "review"
|
||||
|
||||
res3_second = calculate_sm2(3, reps=1, interval=1, ease=2.5, base_time=base)
|
||||
assert res3_second["srs_reps"] == 2
|
||||
assert res3_second["srs_interval"] == 6
|
||||
assert res3_second["srs_state"] == "review"
|
||||
|
||||
res3_third = calculate_sm2(3, reps=2, interval=6, ease=2.5, base_time=base)
|
||||
assert res3_third["srs_reps"] == 3
|
||||
assert res3_third["srs_interval"] == 15 # round(6 * 2.5) = 15
|
||||
|
||||
res3_mastered = calculate_sm2(3, reps=3, interval=15, ease=2.5, base_time=base)
|
||||
assert res3_mastered["srs_reps"] == 4
|
||||
assert res3_mastered["srs_interval"] == 38 # round(15 * 2.5) = 38 >= 21
|
||||
assert res3_mastered["srs_state"] == "mastered"
|
||||
|
||||
# Rating 4 (Easy)
|
||||
res4_first = calculate_sm2(4, reps=0, interval=0, ease=2.5, base_time=base)
|
||||
assert res4_first["srs_reps"] == 1
|
||||
assert res4_first["srs_interval"] == 4
|
||||
assert res4_first["srs_ease"] == 2.65
|
||||
|
||||
# Boundary checks: Ease minimum 1.3
|
||||
res_min_ease = calculate_sm2(1, reps=0, interval=1, ease=1.35, base_time=base)
|
||||
assert res_min_ease["srs_ease"] == 1.3
|
||||
|
||||
# Boundary checks: Ease maximum 3.0
|
||||
res_max_ease = calculate_sm2(4, reps=1, interval=4, ease=2.95, base_time=base)
|
||||
assert res_max_ease["srs_ease"] == 3.0
|
||||
|
||||
# Invalid rating
|
||||
with pytest.raises(ValueError):
|
||||
calculate_sm2(0)
|
||||
with pytest.raises(ValueError):
|
||||
calculate_sm2(5)
|
||||
|
||||
def test_srs_endpoints_flow():
|
||||
client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||
|
||||
# Create a new card
|
||||
card_resp = client.post("/api/cards", json={
|
||||
"term": "meticulous",
|
||||
"translation": "дотошный, педантичный",
|
||||
"collection": "General"
|
||||
})
|
||||
card = card_resp.json()
|
||||
card_id = card["id"]
|
||||
assert card["srs_state"] == "new"
|
||||
|
||||
# 1. Check /api/srs/queue
|
||||
resp = client.get("/api/srs/queue")
|
||||
assert resp.status_code == 200
|
||||
queue_data = resp.json()
|
||||
assert any(c["id"] == card_id for c in queue_data["cards"])
|
||||
|
||||
# 2. Check /api/srs/stats before review
|
||||
resp = client.get("/api/srs/stats")
|
||||
assert resp.status_code == 200
|
||||
stats_before = resp.json()
|
||||
assert "new_cards" in stats_before
|
||||
assert "learning" in stats_before
|
||||
assert "mastered" in stats_before
|
||||
assert "streak_days" in stats_before
|
||||
|
||||
# 3. Submit Review: Rating 3 (Good)
|
||||
rev_resp = client.post("/api/srs/review", json={"card_id": card_id, "rating": 3})
|
||||
assert rev_resp.status_code == 200
|
||||
rev_data = rev_resp.json()
|
||||
assert rev_data["status"] == "ok"
|
||||
updated_card = rev_data["card"]
|
||||
assert updated_card["srs_reps"] == 1
|
||||
assert updated_card["srs_interval"] == 1
|
||||
assert updated_card["srs_state"] == "review"
|
||||
|
||||
# 4. Check stats after review: total_reviews incremented, streak >= 1
|
||||
stats_after = client.get("/api/srs/stats").json()
|
||||
assert stats_after["total_reviews"] >= stats_before["total_reviews"] + 1
|
||||
assert stats_after["streak_days"] >= 1
|
||||
|
||||
# 5. Review invalid rating
|
||||
bad_rev = client.post("/api/srs/review", json={"card_id": card_id, "rating": 99})
|
||||
assert bad_rev.status_code in (400, 422)
|
||||
|
||||
# 6. Review non-existent card
|
||||
missing_rev = client.post("/api/srs/review", json={"card_id": 999999, "rating": 3})
|
||||
assert missing_rev.status_code == 404
|
||||
|
||||
# Clean up
|
||||
client.delete(f"/api/cards/{card_id}")
|
||||
|
||||
def test_pwa_routes_response():
|
||||
resp_m = client.get("/manifest.json")
|
||||
assert resp_m.status_code in (200, 404)
|
||||
if resp_m.status_code == 200:
|
||||
assert resp_m.headers["Content-Type"].startswith("application/manifest+json")
|
||||
|
||||
resp_sw = client.get("/sw.js")
|
||||
assert resp_sw.status_code in (200, 404)
|
||||
if resp_sw.status_code == 200:
|
||||
assert resp_sw.headers["Service-Worker-Allowed"] == "/"
|
||||
assert "no-cache" in resp_sw.headers["Cache-Control"]
|
||||
|
||||
|
||||
def test_change_password_flow():
|
||||
# 1. Create a dedicated user for password change test
|
||||
test_uid = create_user("pwd_user", "old_secret_pass_123")
|
||||
assert test_uid > 0
|
||||
|
||||
# 2. Login
|
||||
login_resp = client.post("/api/login", json={"username": "pwd_user", "password": "old_secret_pass_123"})
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
# 3. Try to change with wrong old password
|
||||
fail_resp = client.post("/api/change-password", json={
|
||||
"old_password": "wrong_old_password",
|
||||
"new_password": "brand_new_pass_456"
|
||||
})
|
||||
assert fail_resp.status_code == 400
|
||||
|
||||
# 4. Try to change with short new password (<6 chars)
|
||||
short_resp = client.post("/api/change-password", json={
|
||||
"old_password": "old_secret_pass_123",
|
||||
"new_password": "123"
|
||||
})
|
||||
assert short_resp.status_code == 422
|
||||
|
||||
# 5. Successfully change password
|
||||
ok_resp = client.post("/api/change-password", json={
|
||||
"old_password": "old_secret_pass_123",
|
||||
"new_password": "brand_new_pass_456"
|
||||
})
|
||||
assert ok_resp.status_code == 200
|
||||
assert ok_resp.json()["status"] == "ok"
|
||||
|
||||
# 6. Verify old password no longer authenticates
|
||||
old_auth = authenticate_user("pwd_user", "old_secret_pass_123")
|
||||
assert old_auth is None
|
||||
|
||||
# 7. Verify new password authenticates
|
||||
new_auth = authenticate_user("pwd_user", "brand_new_pass_456")
|
||||
assert new_auth is not None
|
||||
assert new_auth["username"] == "pwd_user"
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user