diff --git a/PLAN.md b/PLAN.md index 546891d..2f6498b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. Быстрый захват из браузера**: - Букмарклет для выделения текста на любой странице и отправки в словарь. diff --git a/app/auth.py b/app/auth.py index a254925..08e1648 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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) + diff --git a/app/config.py b/app/config.py index 8c053b6..1f4b8e8 100644 --- a/app/config.py +++ b/app/config.py @@ -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")) diff --git a/app/database.py b/app/database.py index 7cb550d..d3837a1 100644 --- a/app/database.py +++ b/app/database.py @@ -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) diff --git a/app/export.py b/app/export.py new file mode 100644 index 0000000..38a3d5a --- /dev/null +++ b/app/export.py @@ -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") diff --git a/app/main.py b/app/main.py index e2a18b5..0859d05 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,8 @@ import os import urllib.parse from contextlib import asynccontextmanager -from typing import Optional, List +from typing import Optional, List, Dict, Any +from datetime import datetime, timezone, date from fastapi import FastAPI, Depends, HTTPException, Request, Response, status, Query from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse from fastapi.staticfiles import StaticFiles @@ -18,9 +19,13 @@ from app.auth import ( delete_session, check_login_rate_limit, record_login_failure, - record_login_success + record_login_success, + change_user_password, ) from app.dictionary import lookup_term, USER_AGENT +from app.export import export_csv, export_json, export_anki_tsv, export_markdown +from app.srs import calculate_sm2, generate_quiz_options, build_activity_heatmap + @asynccontextmanager async def lifespan(app: FastAPI): @@ -28,16 +33,18 @@ async def lifespan(app: FastAPI): ensure_admin_user() yield + app = FastAPI( title="Notes App", description="Personal Vocabulary & Language Notebook (notes.qpid.pro)", - version="1.0.0", - lifespan=lifespan + version="2.1.0", + lifespan=lifespan, ) # Mount static files app.mount("/static", StaticFiles(directory=str(config.BASE_DIR / "app" / "static")), name="static") + # Security Headers Middleware @app.middleware("http") async def add_security_headers(request: Request, call_next): @@ -47,11 +54,14 @@ async def add_security_headers(request: Request, call_next): response.headers["Referrer-Policy"] = "same-origin" return response -# Dependency: get authenticated user or None + +# --------------------------------------------------------------------------- +# Auth dependencies +# --------------------------------------------------------------------------- + def get_optional_user(request: Request) -> Optional[dict]: token = request.cookies.get(config.SESSION_COOKIE_NAME) if not token: - # Check Authorization header bearer token fallback auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:].strip() @@ -59,21 +69,31 @@ def get_optional_user(request: Request) -> Optional[dict]: return get_user_from_session(token) return None -# Dependency: require authenticated user + def require_user(request: Request) -> dict: user = get_optional_user(request) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication required" + detail="Authentication required", ) return user -# Models + +# --------------------------------------------------------------------------- +# Request / Response models +# --------------------------------------------------------------------------- + class LoginRequest(BaseModel): username: str = Field(..., min_length=1) password: str = Field(..., min_length=1) + +class ChangePasswordRequest(BaseModel): + old_password: str = Field(..., min_length=1) + new_password: str = Field(..., min_length=6) + + class CardCreateRequest(BaseModel): term: str = Field(..., min_length=1) translation: str = Field(..., min_length=1) @@ -84,6 +104,8 @@ class CardCreateRequest(BaseModel): definition_en: Optional[str] = "" tags: Optional[str] = "general" notes: Optional[str] = "" + collection: Optional[str] = "General" + class CardUpdateRequest(BaseModel): term: Optional[str] = None @@ -95,152 +117,260 @@ class CardUpdateRequest(BaseModel): definition_en: Optional[str] = None tags: Optional[str] = None notes: Optional[str] = None + collection: Optional[str] = None -# Routes + +class BatchActionRequest(BaseModel): + card_ids: List[int] = Field(..., min_length=1) + action: str = Field(..., pattern="^(set_collection|add_tags|delete)$") + value: Optional[str] = "" + + +class SrsReviewRequest(BaseModel): + card_id: int + rating: int = Field(..., ge=1, le=4) + + +# --------------------------------------------------------------------------- +# PWA Static Endpoints +# --------------------------------------------------------------------------- + +@app.get("/manifest.json", include_in_schema=False) +async def manifest_endpoint(): + manifest_path = config.BASE_DIR / "app" / "static" / "manifest.json" + if manifest_path.exists(): + return Response( + content=manifest_path.read_text(encoding="utf-8"), + media_type="application/manifest+json; charset=utf-8", + headers={"Cache-Control": "public, max-age=86400"}, + ) + raise HTTPException(status_code=404, detail="Manifest not found") + + +@app.get("/sw.js", include_in_schema=False) +async def service_worker_endpoint(): + sw_path = config.BASE_DIR / "app" / "static" / "sw.js" + if sw_path.exists(): + return Response( + content=sw_path.read_text(encoding="utf-8"), + media_type="application/javascript; charset=utf-8", + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Service-Worker-Allowed": "/", + }, + ) + raise HTTPException(status_code=404, detail="Service worker not found") + + +# --------------------------------------------------------------------------- +# HTML entry point +# --------------------------------------------------------------------------- @app.api_route("/", methods=["GET", "HEAD"], response_class=HTMLResponse) -async def serve_index(request: Request): - index_path = config.BASE_DIR / "app" / "static" / "index.html" - if not index_path.is_file(): +async def index_page(request: Request): + index_file = config.BASE_DIR / "app" / "static" / "index.html" + if not index_file.exists(): raise HTTPException(status_code=404, detail="Index file not found") - with open(index_path, "r", encoding="utf-8") as f: - content = f.read() - return HTMLResponse(content) + with open(index_file, "r", encoding="utf-8") as f: + html_content = f.read() + return HTMLResponse(content=html_content, status_code=200) + + +# --------------------------------------------------------------------------- +# Auth API +# --------------------------------------------------------------------------- @app.post("/api/login") async def api_login(req: LoginRequest, request: Request, response: Response): - client_ip = request.client.host if request.client else "unknown" - - if not check_login_rate_limit(client_ip): + ip = request.client.host if request.client else "unknown" + if not check_login_rate_limit(ip): raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail="Too many failed login attempts. Please wait 1 minute." + detail="Too many login attempts. Please try again in a minute.", ) user = authenticate_user(req.username, req.password) if not user: - record_login_failure(client_ip) + record_login_failure(ip) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid username or password" + detail="Invalid username or password", ) - record_login_success(client_ip) - user_agent = request.headers.get("User-Agent", "")[:255] - session_token = create_session(user["id"], user_agent, client_ip) + record_login_success(ip) + user_agent = request.headers.get("User-Agent", "") + session_token = create_session(user["id"], user_agent=user_agent, ip=ip) - # Set secure HttpOnly cookie response.set_cookie( key=config.SESSION_COOKIE_NAME, value=session_token, - max_age=config.SESSION_EXPIRE_DAYS * 86400, + max_age=config.SESSION_MAX_AGE, httponly=True, samesite="lax", - secure=False # Nginx handles SSL termination + secure=False, ) + return {"status": "ok", "user": {"id": user["id"], "username": user["username"]}} - return {"status": "ok", "user": user} @app.post("/api/logout") async def api_logout(request: Request, response: Response): token = request.cookies.get(config.SESSION_COOKIE_NAME) if token: delete_session(token) - response.delete_cookie(config.SESSION_COOKIE_NAME) - return {"status": "ok", "message": "Logged out successfully"} + response.delete_cookie(key=config.SESSION_COOKIE_NAME) + return {"status": "ok"} + @app.get("/api/me") async def api_me(user: dict = Depends(require_user)): - return {"user": user} + return {"status": "ok", "user": {"id": user["id"], "username": user["username"]}} + + +@app.post("/api/change-password") +@app.post("/api/auth/change-password") +async def api_change_password(req: ChangePasswordRequest, user: dict = Depends(require_user)): + ok, msg = change_user_password(user["id"], req.old_password, req.new_password) + if not ok: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=msg or "Current password does not match", + ) + return {"status": "ok", "message": "Password changed successfully"} + + +# --------------------------------------------------------------------------- +# Dictionary Lookup & Audio Proxy +# --------------------------------------------------------------------------- @app.get("/api/lookup") -async def api_lookup(q: str = Query(..., min_length=1), user: dict = Depends(require_user)): - result = await lookup_term(q) - return result +async def api_lookup( + term: Optional[str] = None, + q: Optional[str] = None, + user: dict = Depends(require_user) +): + query_str = (term or q or "").strip() + if not query_str: + raise HTTPException(status_code=400, detail="Query term cannot be empty") + data = await lookup_term(query_str) + return data + @app.api_route("/api/audio", methods=["GET", "HEAD"]) -async def api_audio_proxy(file: str = Query(..., min_length=1)): - """Proxies Wikimedia audio file with required User-Agent.""" - cleaned_file = urllib.parse.unquote(file).strip() - # Basic safety check - if ".." in cleaned_file or "/" in cleaned_file or "\\" in cleaned_file: - raise HTTPException(status_code=400, detail="Invalid filename") +async def api_audio_proxy(url: str = Query(..., min_length=1), user: dict = Depends(require_user)): + try: + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in ("http", "https"): + raise HTTPException(status_code=400, detail="Invalid audio URL scheme") + if not parsed.netloc: + raise HTTPException(status_code=400, detail="Invalid audio host") + except Exception: + raise HTTPException(status_code=400, detail="Malformed audio URL") - target_url = f"https://commons.wikimedia.org/wiki/Special:FilePath/{urllib.parse.quote(cleaned_file)}" + try: + async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: + audio_resp = await client.get(url, headers={"User-Agent": USER_AGENT}) + if audio_resp.status_code != 200: + raise HTTPException(status_code=audio_resp.status_code, detail="Audio upstream error") + media_type = audio_resp.headers.get("Content-Type", "audio/mpeg") + return Response( + content=audio_resp.content, + media_type=media_type, + headers={"Cache-Control": "public, max-age=604800"}, + ) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Failed to fetch audio: {str(e)}") - async def stream_audio(): - headers = {"User-Agent": USER_AGENT} - async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client: - async with client.stream("GET", target_url, headers=headers) as resp: - if resp.status_code != 200: - yield b"" - return - async for chunk in resp.aiter_bytes(): - yield chunk - # Determine mime-type based on extension - ext = cleaned_file.lower().split(".")[-1] - mime_map = { - "ogg": "audio/ogg", - "oga": "audio/ogg", - "mp3": "audio/mpeg", - "wav": "audio/wav", - "flac": "audio/flac" - } - media_type = mime_map.get(ext, "application/octet-stream") +# --------------------------------------------------------------------------- +# Collections +# --------------------------------------------------------------------------- + +@app.get("/api/collections") +async def api_get_collections(user: dict = Depends(require_user)): + """Returns all collections for the current user with card count.""" + with get_db() as conn: + rows = conn.execute( + """ + SELECT collection, COUNT(*) as count + FROM vocabulary_cards + WHERE user_id = ? + GROUP BY collection + ORDER BY count DESC, collection ASC + """, + (user["id"],), + ).fetchall() + return [{"name": r["collection"], "collection": r["collection"], "count": r["count"]} for r in rows] + + +# --------------------------------------------------------------------------- +# Cards CRUD & Duplicate Check +# --------------------------------------------------------------------------- + +@app.get("/api/cards/check-duplicate") +async def api_check_duplicate(term: str = Query(..., min_length=1), user: dict = Depends(require_user)): + clean_term = term.strip().lower() + with get_db() as conn: + row = conn.execute( + "SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ?", + (user["id"], clean_term), + ).fetchone() + if row: + return {"exists": True, "card": dict(row)} + return {"exists": False, "card": None} - return StreamingResponse( - stream_audio(), - media_type=media_type, - headers={"Cache-Control": "public, max-age=86400"} - ) @app.get("/api/cards") async def api_get_cards( search: Optional[str] = None, tag: Optional[str] = None, + collection: Optional[str] = None, + srs_state: Optional[str] = None, limit: int = 100, offset: int = 0, - user: dict = Depends(require_user) + user: dict = Depends(require_user), ): with get_db() as conn: query = """ SELECT id, term, translation, context_sentence, phonetic, audio_url, - part_of_speech, definition_en, tags, notes, created_at, updated_at + part_of_speech, definition_en, tags, notes, collection, + srs_reps, srs_interval, srs_ease, srs_next_review, srs_last_review, srs_state, error_count, + created_at, updated_at FROM vocabulary_cards WHERE user_id = ? """ params = [user["id"]] if search: + s_clean = f"%{search.strip()}%" query += " AND (term LIKE ? OR translation LIKE ? OR context_sentence LIKE ? OR notes LIKE ?)" - s = f"%{search.strip()}%" - params.extend([s, s, s, s]) + params.extend([s_clean, s_clean, s_clean, s_clean]) if tag: - query += " AND (',' || tags || ',') LIKE ?" - params.append(f"%,{tag.strip()}%,") + t_clean = f"%{tag.strip()}%" + query += " AND tags LIKE ?" + params.append(t_clean) + + if collection: + query += " AND collection = ?" + params.append(collection.strip()) + + if srs_state: + query += " AND srs_state = ?" + params.append(srs_state.strip()) + + # Total count before limit + count_query = f"SELECT COUNT(*) FROM ({query})" + total = conn.execute(count_query, params).fetchone()[0] query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" - params.extend([min(limit, 500), max(offset, 0)]) + params.extend([limit, offset]) rows = conn.execute(query, params).fetchall() - - # Count total - count_query = "SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ?" - count_params = [user["id"]] - if search: - count_query += " AND (term LIKE ? OR translation LIKE ? OR context_sentence LIKE ? OR notes LIKE ?)" - s = f"%{search.strip()}%" - count_params.extend([s, s, s, s]) - if tag: - count_query += " AND (',' || tags || ',') LIKE ?" - count_params.append(f"%,{tag.strip()}%,") - total = conn.execute(count_query, count_params).fetchone()[0] - cards = [dict(r) for r in rows] + return {"total": total, "cards": cards} + @app.post("/api/cards") async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_user)): with get_db() as conn: @@ -248,74 +378,456 @@ async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_ """ INSERT INTO vocabulary_cards ( user_id, term, translation, context_sentence, phonetic, audio_url, - part_of_speech, definition_en, tags, notes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + part_of_speech, definition_en, tags, notes, collection, + srs_reps, srs_interval, srs_ease, srs_state, error_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 2.5, 'new', 0) """, ( user["id"], card.term.strip(), card.translation.strip(), - card.context_sentence.strip() if card.context_sentence else "", - card.phonetic.strip() if card.phonetic else "", - card.audio_url.strip() if card.audio_url else "", - card.part_of_speech.strip() if card.part_of_speech else "", - card.definition_en.strip() if card.definition_en else "", - card.tags.strip() if card.tags else "general", - card.notes.strip() if card.notes else "" - ) + (card.context_sentence or "").strip(), + (card.phonetic or "").strip(), + (card.audio_url or "").strip(), + (card.part_of_speech or "").strip(), + (card.definition_en or "").strip(), + (card.tags or "general").strip(), + (card.notes or "").strip(), + (card.collection or "General").strip(), + ), ) new_id = cursor.lastrowid row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (new_id,)).fetchone() return dict(row) + @app.put("/api/cards/{card_id}") async def api_update_card(card_id: int, update: CardUpdateRequest, user: dict = Depends(require_user)): with get_db() as conn: - row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?", (card_id, user["id"])).fetchone() + row = conn.execute( + "SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?", + (card_id, user["id"]), + ).fetchone() if not row: raise HTTPException(status_code=404, detail="Card not found") - current = dict(row) - term = update.term.strip() if update.term is not None else current["term"] - translation = update.translation.strip() if update.translation is not None else current["translation"] - context_sentence = update.context_sentence.strip() if update.context_sentence is not None else current["context_sentence"] - phonetic = update.phonetic.strip() if update.phonetic is not None else current["phonetic"] - audio_url = update.audio_url.strip() if update.audio_url is not None else current["audio_url"] - part_of_speech = update.part_of_speech.strip() if update.part_of_speech is not None else current["part_of_speech"] - definition_en = update.definition_en.strip() if update.definition_en is not None else current["definition_en"] - tags = update.tags.strip() if update.tags is not None else current["tags"] - notes = update.notes.strip() if update.notes is not None else current["notes"] + + def pick(new_val, current_val): + return new_val.strip() if new_val is not None else current_val conn.execute( """ UPDATE vocabulary_cards SET term = ?, translation = ?, context_sentence = ?, phonetic = ?, audio_url = ?, part_of_speech = ?, definition_en = ?, tags = ?, - notes = ?, updated_at = CURRENT_TIMESTAMP + notes = ?, collection = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? """, - (term, translation, context_sentence, phonetic, audio_url, part_of_speech, definition_en, tags, notes, card_id, user["id"]) + ( + pick(update.term, current["term"]), + pick(update.translation, current["translation"]), + pick(update.context_sentence, current.get("context_sentence", "")), + pick(update.phonetic, current.get("phonetic", "")), + pick(update.audio_url, current.get("audio_url", "")), + pick(update.part_of_speech, current.get("part_of_speech", "")), + pick(update.definition_en, current.get("definition_en", "")), + pick(update.tags, current.get("tags", "")), + pick(update.notes, current.get("notes", "")), + pick(update.collection, current.get("collection", "General")), + card_id, + user["id"], + ), ) updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (card_id,)).fetchone() return dict(updated_row) + @app.delete("/api/cards/{card_id}") async def api_delete_card(card_id: int, user: dict = Depends(require_user)): with get_db() as conn: cursor = conn.execute( "DELETE FROM vocabulary_cards WHERE id = ? AND user_id = ?", - (card_id, user["id"]) + (card_id, user["id"]), ) if cursor.rowcount == 0: raise HTTPException(status_code=404, detail="Card not found") return {"status": "ok", "deleted_id": card_id} + +# --------------------------------------------------------------------------- +# Batch operations +# --------------------------------------------------------------------------- + +@app.post("/api/cards/batch") +async def api_batch_action(req: BatchActionRequest, user: dict = Depends(require_user)): + if not req.card_ids: + raise HTTPException(status_code=400, detail="No card IDs provided") + + with get_db() as conn: + placeholders = ",".join("?" * len(req.card_ids)) + params_base = list(req.card_ids) + [user["id"]] + + if req.action == "delete": + cursor = conn.execute( + f"DELETE FROM vocabulary_cards WHERE id IN ({placeholders}) AND user_id = ?", + params_base, + ) + return {"status": "ok", "action": "delete", "affected": cursor.rowcount} + + elif req.action == "set_collection": + collection_val = (req.value or "General").strip() + cursor = conn.execute( + f"UPDATE vocabulary_cards SET collection = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders}) AND user_id = ?", + [collection_val] + params_base, + ) + return {"status": "ok", "action": "set_collection", "affected": cursor.rowcount} + + elif req.action == "add_tags": + new_tags_raw = req.value or "" + new_tags = [t.strip() for t in new_tags_raw.split(",") if t.strip()] + if not new_tags: + return {"status": "ok", "action": "add_tags", "affected": 0} + + rows = conn.execute( + f"SELECT id, tags FROM vocabulary_cards WHERE id IN ({placeholders}) AND user_id = ?", + params_base, + ).fetchall() + + affected = 0 + for r in rows: + existing = [t.strip() for t in (r["tags"] or "").split(",") if t.strip()] + combined = existing + [t for t in new_tags if t not in existing] + conn.execute( + "UPDATE vocabulary_cards SET tags = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (", ".join(combined), r["id"]), + ) + affected += 1 + + return {"status": "ok", "action": "add_tags", "affected": affected} + else: + raise HTTPException(status_code=422, detail=f"Unknown action: {req.action}") + + +# --------------------------------------------------------------------------- +# SRS Spaced Repetition Endpoints & Smart Quiz +# --------------------------------------------------------------------------- + +@app.get("/api/srs/queue") +@app.get("/api/srs/due") +async def api_srs_queue( + collection: Optional[str] = None, + limit: int = 25, + mix: bool = False, + user: dict = Depends(require_user), +): + """ + Returns queue of cards ready for study. + If mix=True (Smart Daily Mix): + - up to 10 new cards + - up to 10 review due cards + - up to 5 leeches / problematic cards (error_count > 0 or low ease) + Shuffled for interleaved recall. + """ + with get_db() as conn: + now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + col_filter = " AND collection = ?" if collection else "" + col_params = [collection] if collection else [] + + if mix: + # 1. New cards (up to 10) + new_rows = conn.execute( + f""" + SELECT * FROM vocabulary_cards + WHERE user_id = ? AND srs_state = 'new'{col_filter} + ORDER BY created_at DESC LIMIT 10 + """, + [user["id"]] + col_params, + ).fetchall() + + # 2. Due review cards (up to 10) + due_rows = conn.execute( + f""" + SELECT * FROM vocabulary_cards + WHERE user_id = ? AND srs_state IN ('learning', 'review', 'mastered') + AND (srs_next_review <= ? OR srs_next_review IS NULL){col_filter} + ORDER BY srs_next_review ASC LIMIT 10 + """, + [user["id"], now_str] + col_params, + ).fetchall() + + # 3. Problematic leeches (up to 5 with highest error count) + used_ids = {r["id"] for r in new_rows} | {r["id"] for r in due_rows} + leech_rows = conn.execute( + f""" + SELECT * FROM vocabulary_cards + WHERE user_id = ? AND (error_count > 0 OR srs_ease < 2.0){col_filter} + ORDER BY error_count DESC, srs_ease ASC LIMIT 10 + """, + [user["id"]] + col_params, + ).fetchall() + leech_filtered = [r for r in leech_rows if r["id"] not in used_ids][:5] + + combined = [dict(r) for r in new_rows] + [dict(r) for r in due_rows] + [dict(r) for r in leech_filtered] + import random + random.shuffle(combined) + cards = combined[:limit] + + else: + # Standard SRS due queue: Due cards first, then new cards + query = f""" + SELECT * FROM vocabulary_cards + WHERE user_id = ?{col_filter} + AND (srs_state = 'new' OR srs_next_review <= ? OR srs_next_review IS NULL) + ORDER BY + CASE WHEN srs_state != 'new' AND (srs_next_review <= ? OR srs_next_review IS NULL) THEN 0 ELSE 1 END, + srs_next_review ASC, + created_at DESC + LIMIT ? + """ + params = [user["id"]] + col_params + [now_str, now_str, limit] + rows = conn.execute(query, params).fetchall() + cards = [dict(r) for r in rows] + + return {"count": len(cards), "cards": cards} + + +@app.get("/api/srs/quiz") +async def api_srs_quiz( + collection: Optional[str] = None, + count: int = 10, + user: dict = Depends(require_user), +): + """ + Generates a Multiple-Choice Quiz (4 options per question). + """ + with get_db() as conn: + # Fetch target cards for user + col_filter = " AND collection = ?" if collection else "" + col_params = [collection] if collection else [] + rows = conn.execute( + f"SELECT * FROM vocabulary_cards WHERE user_id = ?{col_filter} ORDER BY RANDOM() LIMIT ?", + [user["id"]] + col_params + [count], + ).fetchall() + target_cards = [dict(r) for r in rows] + + # Fetch pool of all user cards for generating smart distractors + all_rows = conn.execute( + "SELECT id, term, translation, part_of_speech FROM vocabulary_cards WHERE user_id = ?", + (user["id"],), + ).fetchall() + all_cards = [dict(r) for r in all_rows] + + quiz_items = [] + for card in target_cards: + options = generate_quiz_options(card, all_cards, count=4) + quiz_items.append({ + "card": card, + "options": options, + }) + + return {"count": len(quiz_items), "quiz": quiz_items} + + +@app.post("/api/srs/review") +@app.post("/api/srs/review/{card_id_path}") +async def api_srs_review( + req: Optional[SrsReviewRequest] = None, + card_id_path: Optional[int] = None, + rating_param: Optional[int] = Query(None, alias="rating"), + request: Request = None, + user: dict = Depends(require_user), +): + """ + Records a review rating (1..4) and updates SM-2 state & activity logs. + """ + target_card_id = req.card_id if req else card_id_path + target_rating = req.rating if req else rating_param + + if target_card_id is None: + try: + body = await request.json() + target_card_id = body.get("card_id") + target_rating = body.get("rating") + except Exception: + pass + + if target_card_id is None or target_rating is None: + raise HTTPException(status_code=400, detail="Missing card_id or rating") + + if target_rating not in (1, 2, 3, 4): + raise HTTPException(status_code=422, detail="Rating must be between 1 and 4") + + with get_db() as conn: + row = conn.execute( + "SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?", + (target_card_id, user["id"]), + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Card not found") + + current = dict(row) + new_srs = calculate_sm2( + rating=target_rating, + reps=current.get("srs_reps", 0), + interval=current.get("srs_interval", 0), + ease=current.get("srs_ease", 2.5), + state=current.get("srs_state", "new"), + error_count=current.get("error_count", 0), + ) + + conn.execute( + """ + UPDATE vocabulary_cards + SET srs_reps = ?, srs_interval = ?, srs_ease = ?, srs_state = ?, + srs_last_review = ?, srs_next_review = ?, error_count = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND user_id = ? + """, + ( + new_srs["srs_reps"], + new_srs["srs_interval"], + new_srs["srs_ease"], + new_srs["srs_state"], + new_srs["srs_last_review"], + new_srs["srs_next_review"], + new_srs["error_count"], + target_card_id, + user["id"], + ), + ) + + # Record review log + conn.execute( + """ + INSERT INTO srs_logs (user_id, card_id, rating, interval, ease, reviewed_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, + (user["id"], target_card_id, target_rating, new_srs["srs_interval"], new_srs["srs_ease"]), + ) + + updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (target_card_id,)).fetchone() + return {"status": "ok", "card": dict(updated_row)} + + +@app.get("/api/srs/stats") +async def api_srs_stats(user: dict = Depends(require_user)): + """ + Returns full learning analytics, SRS states, retention rate, streaks. + """ + with get_db() as conn: + now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + # Total cards + total_cards = conn.execute( + "SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ?", + (user["id"],), + ).fetchone()[0] + + # By state + states = conn.execute( + """ + SELECT srs_state, COUNT(*) as count + FROM vocabulary_cards + WHERE user_id = ? + GROUP BY srs_state + """, + (user["id"],), + ).fetchall() + state_counts = {r["srs_state"]: r["count"] for r in states} + + new_cards = state_counts.get("new", 0) + learning = state_counts.get("learning", 0) + review = state_counts.get("review", 0) + mastered = state_counts.get("mastered", 0) + + # Due now + due_cards = conn.execute( + """ + SELECT COUNT(*) FROM vocabulary_cards + WHERE user_id = ? AND (srs_state = 'new' OR srs_next_review <= ? OR srs_next_review IS NULL) + """, + (user["id"], now_str), + ).fetchone()[0] + + # Leeches + leech_count = conn.execute( + """ + SELECT COUNT(*) FROM vocabulary_cards + WHERE user_id = ? AND (error_count > 0 OR srs_ease < 2.0) + """, + (user["id"],), + ).fetchone()[0] + + # Total reviews and retention + review_logs = conn.execute( + "SELECT rating, date(reviewed_at) as rev_date FROM srs_logs WHERE user_id = ?", + (user["id"],), + ).fetchall() + + total_reviews = len(review_logs) + good_reviews = sum(1 for r in review_logs if r["rating"] in (3, 4)) + retention_rate = round((good_reviews / total_reviews * 100), 1) if total_reviews > 0 else 100.0 + + # Daily review aggregation for streak + date_counts: Dict[str, int] = {} + for r in review_logs: + d = r["rev_date"] + if d: + date_counts[d] = date_counts.get(d, 0) + 1 + + heatmap_info = build_activity_heatmap(date_counts, days=180) + + # Today's activity + today_str = date.today().strftime("%Y-%m-%d") + today_added = conn.execute( + "SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ? AND date(created_at) = date('now')", + (user["id"],), + ).fetchone()[0] + today_reviewed = date_counts.get(today_str, 0) + + return { + "total_cards": total_cards, + "new_cards": new_cards, + "learning": learning, + "review": review, + "mastered": mastered, + "due_cards": due_cards, + "leech_count": leech_count, + "total_reviews": total_reviews, + "retention_rate": retention_rate, + "streak_days": heatmap_info["current_streak"], + "longest_streak": heatmap_info["longest_streak"], + "today_added": today_added, + "today_reviewed": today_reviewed, + } + + +@app.get("/api/srs/activity-heatmap") +async def api_srs_activity_heatmap(days: int = 180, user: dict = Depends(require_user)): + """ + Returns GitHub-style daily activity heatmap data. + """ + with get_db() as conn: + rows = conn.execute( + "SELECT date(reviewed_at) as rev_date, COUNT(*) as cnt FROM srs_logs WHERE user_id = ? GROUP BY date(reviewed_at)", + (user["id"],), + ).fetchall() + date_counts = {r["rev_date"]: r["cnt"] for r in rows if r["rev_date"]} + heatmap_data = build_activity_heatmap(date_counts, days=days) + return heatmap_data + + +# --------------------------------------------------------------------------- +# Tags & Stats +# --------------------------------------------------------------------------- + @app.get("/api/tags") async def api_get_tags(user: dict = Depends(require_user)): with get_db() as conn: rows = conn.execute( "SELECT tags FROM vocabulary_cards WHERE user_id = ?", - (user["id"],) + (user["id"],), ).fetchall() tag_counts: dict[str, int] = {} for r in rows: @@ -328,25 +840,95 @@ async def api_get_tags(user: dict = Depends(require_user)): sorted_tags = sorted( [{"tag": k, "count": v} for k, v in tag_counts.items()], key=lambda x: x["count"], - reverse=True + reverse=True, ) return sorted_tags + @app.get("/api/stats") async def api_stats(user: dict = Depends(require_user)): with get_db() as conn: total = conn.execute( "SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ?", - (user["id"],) + (user["id"],), ).fetchone()[0] today_added = conn.execute( - """ - SELECT COUNT(*) FROM vocabulary_cards - WHERE user_id = ? AND date(created_at) = date('now') - """, - (user["id"],) + "SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ? AND date(created_at) = date('now')", + (user["id"],), ).fetchone()[0] - return { - "total_cards": total, - "today_added": today_added - } + return {"total_cards": total, "today_added": today_added} + + +# --------------------------------------------------------------------------- +# Export endpoints +# --------------------------------------------------------------------------- + +def _fetch_all_cards(user_id: int, collection: Optional[str] = None) -> list: + with get_db() as conn: + if collection: + rows = conn.execute( + """ + SELECT id, term, translation, context_sentence, phonetic, audio_url, + part_of_speech, definition_en, tags, notes, collection, created_at, updated_at + FROM vocabulary_cards + WHERE user_id = ? AND collection = ? + ORDER BY collection ASC, created_at DESC + """, + (user_id, collection), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT id, term, translation, context_sentence, phonetic, audio_url, + part_of_speech, definition_en, tags, notes, collection, created_at, updated_at + FROM vocabulary_cards + WHERE user_id = ? + ORDER BY collection ASC, created_at DESC + """, + (user_id,), + ).fetchall() + return [dict(r) for r in rows] + + +@app.get("/api/export/csv") +async def api_export_csv(collection: Optional[str] = None, user: dict = Depends(require_user)): + cards = _fetch_all_cards(user["id"], collection) + data = export_csv(cards) + return Response( + content=data, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": "attachment; filename=\"vocabulary.csv\""}, + ) + + +@app.get("/api/export/json") +async def api_export_json(collection: Optional[str] = None, user: dict = Depends(require_user)): + cards = _fetch_all_cards(user["id"], collection) + data = export_json(cards) + return Response( + content=data, + media_type="application/json; charset=utf-8", + headers={"Content-Disposition": "attachment; filename=\"vocabulary.json\""}, + ) + + +@app.get("/api/export/anki") +async def api_export_anki(collection: Optional[str] = None, user: dict = Depends(require_user)): + cards = _fetch_all_cards(user["id"], collection) + data = export_anki_tsv(cards) + return Response( + content=data, + media_type="text/plain; charset=utf-8", + headers={"Content-Disposition": "attachment; filename=\"vocabulary_anki.txt\""}, + ) + + +@app.get("/api/export/markdown") +async def api_export_markdown(collection: Optional[str] = None, user: dict = Depends(require_user)): + cards = _fetch_all_cards(user["id"], collection) + data = export_markdown(cards) + return Response( + content=data, + media_type="text/markdown; charset=utf-8", + headers={"Content-Disposition": "attachment; filename=\"vocabulary.md\""}, + ) diff --git a/app/srs.py b/app/srs.py new file mode 100644 index 0000000..9855e35 --- /dev/null +++ b/app/srs.py @@ -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), + } diff --git a/app/static/css/style.css b/app/static/css/style.css index 3393532..dbda065 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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 { } } + diff --git a/app/static/icons/icon-192.png b/app/static/icons/icon-192.png new file mode 100755 index 0000000..9e53ee1 Binary files /dev/null and b/app/static/icons/icon-192.png differ diff --git a/app/static/icons/icon-512.png b/app/static/icons/icon-512.png new file mode 100755 index 0000000..e4efd08 Binary files /dev/null and b/app/static/icons/icon-512.png differ diff --git a/app/static/icons/icon.svg b/app/static/icons/icon.svg new file mode 100755 index 0000000..1415464 --- /dev/null +++ b/app/static/icons/icon.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NOTES + diff --git a/app/static/index.html b/app/static/index.html index 953a637..81cdd5c 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -1,65 +1,93 @@ - + - - Notes — Персональный тренажёр английского языка + + Notes — Персональный тренажер и словарь английского языка + - + + + + + + + + - - -