diff --git a/TODO.md b/TODO.md index a844257..d214087 100644 --- a/TODO.md +++ b/TODO.md @@ -48,17 +48,17 @@ --- ### 3. 🛡️ Дедупликация слов и защита от повторного добавления (Reader + API) -- [ ] **Проверка дубликатов на бэкенде (`POST /api/cards`)**: - - Проверять наличие слова для пользователя без учёта регистра: `lower(trim(term))`. - - При попытке повторного добавления: не создавать дубликат-клон, а дополнять контекстное предложение (если в новой цитате другой контекст) и объединять теги книги (`tags`), возвращая существующую карточку со статусом `already_exists`. -- [ ] **Индикация в читалке (Reader Popup)**: - - При клике на слово проверять, есть ли уже `cleanTerm` в словаре пользователя. - - Если слово уже сохранено: показывать зелёный бейдж `✓ В словаре` вместо активной кнопки `+ В словарь` (предотвращая случайные повторные клики). - - Предоставить кнопку «Добавить контекст» для привязки новой цитаты к существующей карточке. -- [ ] **Индекс целостности в SQLite**: - - Добавить `CREATE INDEX IF NOT EXISTS idx_vocab_user_term_nocase ON vocabulary_cards(user_id, term COLLATE NOCASE);` для мгновенного поиска за $O(\log N)$. -- [ ] **Слияние существующих дубликатов в базе**: - - Написать безопасную миграцию/скрипт, объединяющий накопившиеся дубликаты (например, `admired`, `suddenly`/`Suddenly` у пользователя `kopri`), объединяя контексты предложений и сохраняя историю повторений SRS. +- [x] **Проверка дубликатов на бэкенде (`POST /api/cards`)**: + - Проверка слова без учёта регистра `lower(trim(term))`. + - При повторном добавлении не создаётся дубликат, а дополняется контекстное предложение и объединяются теги, возвращается `already_exists: True`. +- [x] **Индикация в читалке и карточке поиска**: + - В читалке при клике на слово и в карточке поиска выводится бейдж `⭐ В библиотеке`. + - Кнопка «+ В словарь» заменяется на бейдж `⭐ Слово уже в библиотеке` с опциональной кнопкой `+ Добавить эту цитату к карточке`. +- [x] **Индекс целостности в SQLite**: + - Создан `UNIQUE INDEX idx_vocab_user_term_nocase ON vocabulary_cards(user_id, term COLLATE NOCASE);` гарантирующий отсутствие дубликатов на уровне движка БД за $O(\log N)$. +- [x] **Слияние существующих дубликатов в базе**: + - Объединены дубликаты `admired` (ID 5 и ID 7) и `suddenly`/`Suddenly` (ID 2 и ID 9) с объединением контекстных предложений, слиянием тегов и сохранением прогресса SRS. + --- diff --git a/app/database.py b/app/database.py index 92cde65..5728dd0 100644 --- a/app/database.py +++ b/app/database.py @@ -83,6 +83,75 @@ def _migrate(conn: sqlite3.Connection): """) conn.execute("CREATE INDEX IF NOT EXISTS idx_books_user ON books(user_id);") + # --- Deduplicate vocabulary_cards (case-insensitive) and enforce UNIQUE index --- + dup_rows = conn.execute(""" + SELECT user_id, lower(trim(term)) as clean_term, COUNT(*) as cnt + FROM vocabulary_cards + GROUP BY user_id, lower(trim(term)) + HAVING cnt > 1 + """).fetchall() + + for d in dup_rows: + u_id = d["user_id"] + c_term = d["clean_term"] + cards = conn.execute( + "SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ? ORDER BY id ASC", + (u_id, c_term), + ).fetchall() + if not cards: + continue + primary = cards[0] + primary_id = primary["id"] + merged_contexts = [primary["context_sentence"]] if primary["context_sentence"] else [] + merged_tags = set([t.strip() for t in (primary["tags"] or "").split(",") if t.strip()]) + max_reps = primary["srs_reps"] or 0 + max_interval = primary["srs_interval"] or 0 + max_ease = primary["srs_ease"] or 2.5 + latest_review = primary["srs_last_review"] + srs_state = primary["srs_state"] + + for dup in cards[1:]: + dup_id = dup["id"] + if dup["context_sentence"] and dup["context_sentence"] not in merged_contexts: + merged_contexts.append(dup["context_sentence"]) + for t in (dup["tags"] or "").split(","): + if t.strip(): + merged_tags.add(t.strip()) + if (dup["srs_reps"] or 0) > max_reps: + max_reps = dup["srs_reps"] + max_interval = dup["srs_interval"] + max_ease = dup["srs_ease"] + latest_review = dup["srs_last_review"] + srs_state = dup["srs_state"] + # Reassign SRS logs to primary card + conn.execute("UPDATE srs_logs SET card_id = ? WHERE card_id = ?", (primary_id, dup_id)) + # Delete duplicate row + conn.execute("DELETE FROM vocabulary_cards WHERE id = ?", (dup_id,)) + + # Update primary card with merged data + conn.execute( + """ + UPDATE vocabulary_cards + SET context_sentence = ?, tags = ?, srs_reps = ?, srs_interval = ?, srs_ease = ?, + srs_last_review = ?, srs_state = ? + WHERE id = ? + """, + ( + "\n\n".join(merged_contexts), + ", ".join(sorted(merged_tags)), + max_reps, + max_interval, + max_ease, + latest_review, + srs_state, + primary_id, + ), + ) + + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_vocab_user_term_nocase ON vocabulary_cards(user_id, term COLLATE NOCASE);" + ) + def init_db(): config.DB_PATH.parent.mkdir(parents=True, exist_ok=True) diff --git a/app/main.py b/app/main.py index 12f8a5e..d1ab8ce 100644 --- a/app/main.py +++ b/app/main.py @@ -285,9 +285,24 @@ async def api_lookup( if not query_str: raise HTTPException(status_code=400, detail="Query term cannot be empty") data = await lookup_term(query_str) + + # Check if this term already exists in user's dictionary + with get_db() as conn: + existing = conn.execute( + "SELECT id, term, translation, context_sentence, tags, collection, srs_state, srs_reps FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))", + (user["id"], query_str), + ).fetchone() + if existing: + data["in_library"] = True + data["library_card"] = dict(existing) + else: + data["in_library"] = False + data["library_card"] = None + return data + @app.api_route("/api/audio", methods=["GET", "HEAD"]) async def api_audio_proxy(): # Audio feature disabled @@ -386,7 +401,42 @@ async def api_get_cards( @app.post("/api/cards") async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_user)): + clean_term = card.term.strip() with get_db() as conn: + existing = conn.execute( + "SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))", + (user["id"], clean_term), + ).fetchone() + + if existing: + # Word already exists in library! + existing_row = dict(existing) + new_ctx = (card.context_sentence or "").strip() + old_ctx = (existing_row.get("context_sentence") or "").strip() + updated_ctx = old_ctx + if new_ctx and new_ctx not in old_ctx: + updated_ctx = f"{old_ctx}\n\n{new_ctx}" if old_ctx else new_ctx + + # Merge tags + old_tags = set([t.strip() for t in (existing_row.get("tags") or "").split(",") if t.strip()]) + new_tags = [t.strip() for t in (card.tags or "").split(",") if t.strip()] + for t in new_tags: + old_tags.add(t) + updated_tags = ", ".join(sorted(old_tags)) if old_tags else "general" + + conn.execute( + """ + UPDATE vocabulary_cards + SET context_sentence = ?, tags = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (updated_ctx, updated_tags, existing_row["id"]), + ) + updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (existing_row["id"],)).fetchone() + res = dict(updated_row) + res["already_exists"] = True + return res + cursor = conn.execute( """ INSERT INTO vocabulary_cards ( @@ -397,7 +447,7 @@ async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_ """, ( user["id"], - card.term.strip(), + clean_term, card.translation.strip(), (card.context_sentence or "").strip(), (card.phonetic or "").strip(), @@ -414,6 +464,7 @@ async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_ 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: diff --git a/app/static/css/style.css b/app/static/css/style.css index 05d6fde..b360ab6 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -3040,8 +3040,42 @@ body { text-align: center; font-size: 13px; font-weight: 600; - color: var(--success); - padding: 6px; + color: #f59e0b; + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.3); + border-radius: var(--radius-sm); + padding: 6px 10px; +} + +.popup-in-library-badge { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 11px; + font-weight: 600; + color: #f59e0b; + background: rgba(245, 158, 11, 0.15); + border: 1px solid rgba(245, 158, 11, 0.4); + padding: 2px 7px; + border-radius: 9999px; + margin-left: 6px; + letter-spacing: 0.02em; + vertical-align: middle; +} + +.badge-library-star { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 600; + color: #f59e0b; + background: rgba(245, 158, 11, 0.15); + border: 1px solid rgba(245, 158, 11, 0.4); + padding: 2px 8px; + border-radius: 9999px; + margin-left: 8px; + vertical-align: middle; } diff --git a/app/static/index.html b/app/static/index.html index e4acfde..131df46 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -5,7 +5,7 @@