feat(trainer): add GitHub activity heatmap, 4-option quiz mode, active typing, and duplicate checks

This commit is contained in:
Antigravity committed 2026-09-23 10:02:29 +00:00
1 parent d31c77fdd0
commit 2294121777
16 files changed
+4318 -685

No files matched your search

+23 -18
View File
@@ -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
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+704 -122
View File
File diff suppressed because it is too large. Load diff
+250
View File
@@ -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),
}
+385
View File
@@ -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 {
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+73
View File
@@ -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

+673 -83
View File
@@ -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 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>
<!-- 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>
<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-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 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>
</div>
<!-- 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>
<!-- 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>
<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.">
</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>
<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="field-group flex-1">
<label for="edit-notes">Заметка / Мнемоника</label>
<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="form-group flex-1">
<label for="edit-notes">💡 Заметка / Ассоциация:</label>
<input type="text" id="edit-notes" class="input-styled" placeholder="Ассоциация...">
</div>
</div>
@@ -152,15 +182,32 @@
</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">
<button class="filter-chip active" data-tag="">Все теги</button>
<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 -->
@@ -173,32 +220,446 @@
</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">&lt; 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>
<input type="text" id="modal-tags" class="input-styled">
<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>
@@ -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>
+1498 -450
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -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": []
}
+101
View File
@@ -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
View File
@@ -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"