From a73bdc29fdfa50e69cf5824f49502c952a787e36 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Mon, 5 Oct 2026 15:15:06 +0300 Subject: [PATCH] feat: prevent duplicate cards, merge contexts, and display star badge when word is in library --- TODO.md | 22 +++---- app/database.py | 69 +++++++++++++++++++++ app/main.py | 53 +++++++++++++++- app/static/css/style.css | 38 +++++++++++- app/static/index.html | 9 ++- app/static/js/app.js | 131 ++++++++++++++++++++++++++++++++++++--- tests/test_api.py | 57 +++++++++++++++++ 7 files changed, 355 insertions(+), 24 deletions(-) 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 @@ Notes — Персональный тренажер и словарь английского языка - + @@ -125,6 +125,7 @@

Word

/wɜːd/ +
Noun @@ -905,6 +906,7 @@ @@ -918,13 +920,14 @@ - + diff --git a/app/static/js/app.js b/app/static/js/app.js index 94bd7e5..721f086 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -88,6 +88,7 @@ document.addEventListener('DOMContentLoaded', () => { const readerWordPopup = document.getElementById('reader-word-popup'); const popupTerm = document.getElementById('popup-term'); const popupPhonetic = document.getElementById('popup-phonetic'); + const popupInLibrary = document.getElementById('popup-in-library'); const popupAudioBtn = document.getElementById('popup-audio-btn'); const popupCloseBtn = document.getElementById('popup-close-btn'); const popupLoading = document.getElementById('popup-loading'); @@ -96,6 +97,7 @@ document.addEventListener('DOMContentLoaded', () => { const popupContext = document.getElementById('popup-context'); const popupSaveBtn = document.getElementById('popup-save-btn'); const popupSavedBadge = document.getElementById('popup-saved-badge'); + const popupAddContextBtn = document.getElementById('popup-add-context-btn'); // Reader State let currentBook = null; @@ -116,6 +118,7 @@ document.addEventListener('DOMContentLoaded', () => { const lookupCard = document.getElementById('lookup-card'); const resTerm = document.getElementById('res-term'); const resPhonetic = document.getElementById('res-phonetic'); + const resInLibrary = document.getElementById('res-in-library'); const audioPlayBtn = document.getElementById('audio-play-btn'); const resPos = document.getElementById('res-pos'); const resDefinitionWrap = document.getElementById('res-definition-wrap'); @@ -580,15 +583,36 @@ document.addEventListener('DOMContentLoaded', () => { resDefinitionWrap.classList.add('hidden'); } - editTranslation.value = data.translation || ''; - editContext.value = ''; - editNotes.value = ''; - editCollection.value = selectedCollection || 'General'; + const inLib = Boolean(data.in_library || foundDuplicateCard); + if (resInLibrary) { + resInLibrary.classList.toggle('hidden', !inLib); + } + + if (inLib) { + saveCardBtn.innerHTML = '⭐ Обновить в словаре (Ctrl + Enter)'; + const lib = data.library_card || foundDuplicateCard; + if (lib) { + if (lib.translation) editTranslation.value = lib.translation; + if (lib.context_sentence) editContext.value = lib.context_sentence; + if (lib.collection) editCollection.value = lib.collection; + if (lib.tags) editTags.value = lib.tags; + if (lib.notes) editNotes.value = lib.notes; + } else { + editTranslation.value = data.translation || ''; + } + } else { + saveCardBtn.innerHTML = '💾 Сохранить в словарь (Ctrl + Enter)'; + editTranslation.value = data.translation || ''; + editContext.value = ''; + editNotes.value = ''; + editCollection.value = selectedCollection || 'General'; + } lookupCard.classList.remove('hidden'); editTranslation.focus(); } + // Save Card Action saveCardBtn.addEventListener('click', saveNewCard); document.addEventListener('keydown', (e) => { @@ -2375,10 +2399,14 @@ document.addEventListener('DOMContentLoaded', () => { const cleanTerm = (term || '').replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim(); if (!cleanTerm) return; + const lowerClean = cleanTerm.toLowerCase(); + const existingCard = allCards && allCards.find(c => c.term && c.term.trim().toLowerCase() === lowerClean); + popupCurrentData = { term: cleanTerm, contextSentence: contextSentence, targetElement: targetElement, + existingCard: existingCard, }; popupTerm.textContent = cleanTerm; @@ -2387,9 +2415,27 @@ document.addEventListener('DOMContentLoaded', () => { popupContext.textContent = contextSentence ? `“${contextSentence}”` : ''; popupLoading.classList.remove('hidden'); popupContent.classList.add('hidden'); - popupSavedBadge.classList.add('hidden'); - popupSaveBtn.classList.remove('hidden'); - popupSaveBtn.disabled = false; + + // Immediate check against already-loaded user cards + if (popupInLibrary) { + popupInLibrary.classList.toggle('hidden', !existingCard); + } + + if (existingCard) { + popupSaveBtn.classList.add('hidden'); + popupSavedBadge.classList.remove('hidden'); + popupSavedBadge.textContent = '⭐ Слово уже в библиотеке'; + if (popupAddContextBtn && contextSentence && (!existingCard.context_sentence || !existingCard.context_sentence.includes(contextSentence))) { + popupAddContextBtn.classList.remove('hidden'); + } else if (popupAddContextBtn) { + popupAddContextBtn.classList.add('hidden'); + } + } else { + popupSavedBadge.classList.add('hidden'); + popupSaveBtn.classList.remove('hidden'); + popupSaveBtn.disabled = false; + if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden'); + } // Position popup near target element readerWordPopup.classList.remove('hidden'); @@ -2407,6 +2453,27 @@ document.addEventListener('DOMContentLoaded', () => { popupLoading.classList.add('hidden'); popupContent.classList.remove('hidden'); popupAudioBtn.classList.add('hidden'); + + const inLib = Boolean(data.in_library || existingCard); + const libCard = data.library_card || existingCard; + if (inLib) { + popupCurrentData.existingCard = libCard; + if (popupInLibrary) popupInLibrary.classList.remove('hidden'); + popupSaveBtn.classList.add('hidden'); + popupSavedBadge.classList.remove('hidden'); + popupSavedBadge.textContent = '⭐ Слово уже в библиотеке'; + if (popupAddContextBtn && contextSentence && libCard && (!libCard.context_sentence || !libCard.context_sentence.includes(contextSentence))) { + popupAddContextBtn.classList.remove('hidden'); + } else if (popupAddContextBtn) { + popupAddContextBtn.classList.add('hidden'); + } + } else { + if (popupInLibrary) popupInLibrary.classList.add('hidden'); + popupSaveBtn.classList.remove('hidden'); + popupSavedBadge.classList.add('hidden'); + if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden'); + } + positionWordPopup(targetElement); } else { popupTranslation.textContent = 'Ошибка перевода'; @@ -2491,8 +2558,12 @@ document.addEventListener('DOMContentLoaded', () => { }); if (resp.ok) { + const resData = await resp.json(); popupSaveBtn.classList.add('hidden'); popupSavedBadge.classList.remove('hidden'); + popupSavedBadge.textContent = resData.already_exists ? '⭐ Уже есть в библиотеке' : '✅ Сохранено в словарь!'; + if (popupInLibrary) popupInLibrary.classList.remove('hidden'); + if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden'); if (d.targetElement) { d.targetElement.classList.add('status-learning'); @@ -2510,6 +2581,52 @@ document.addEventListener('DOMContentLoaded', () => { }); } + if (popupAddContextBtn) { + popupAddContextBtn.addEventListener('click', async () => { + if (!popupCurrentData) return; + const d = popupCurrentData; + const lookup = d.lookup || {}; + const bookTag = currentBook ? slugify(currentBook.title) : 'reader'; + const payload = { + term: d.term, + translation: lookup.translation || lookup.translation_ru || popupTranslation.textContent, + context_sentence: d.contextSentence || '', + phonetic: lookup.phonetic || '', + audio_url: lookup.audio_url || '', + part_of_speech: lookup.part_of_speech || '', + definition_en: lookup.definition_en || '', + collection: 'Книги & Сериалы', + tags: `${bookTag}, reader`, + notes: currentBook ? `Из книги: ${currentBook.title}` : '', + }; + + popupAddContextBtn.disabled = true; + popupAddContextBtn.textContent = 'Сохраняем...'; + + try { + const resp = await fetch('/api/cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (resp.ok) { + popupAddContextBtn.classList.add('hidden'); + popupSavedBadge.textContent = '✅ Цитата прикреплена к карточке!'; + popupSavedBadge.classList.remove('hidden'); + await fetchCards(); + } else { + alert('Не удалось обновить контекст'); + } + } catch (e) { + console.error('Add context error:', e); + } finally { + popupAddContextBtn.disabled = false; + popupAddContextBtn.textContent = '+ Добавить эту цитату к карточке'; + } + }); + } + + // Phrase/Chunk Selection in Reader if (readerContentBody) { readerContentBody.addEventListener('mouseup', () => { diff --git a/tests/test_api.py b/tests/test_api.py index 49f8642..505c8ec 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -811,3 +811,60 @@ def test_gzip_compression(): resp_raw = client.get("/static/js/app.js", headers={"Accept-Encoding": "identity"}) assert resp_raw.status_code == 200 assert resp_raw.headers.get("content-encoding") is None + + +def test_duplicate_prevention_and_in_library_flag(): + # Login as testadmin + client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"}) + + # 1. Create initial card for 'admired' + resp1 = client.post("/api/cards", json={ + "term": "admired", + "translation": "восхищались", + "context_sentence": "People admired his courage.", + "tags": "jfk, book:kennedy", + "collection": "Literature", + }) + assert resp1.status_code == 200 + card1 = resp1.json() + card_id = card1["id"] + + # 2. Try adding same term with different case ('Admired') and new context + resp2 = client.post("/api/cards", json={ + "term": "Admired", + "translation": "восхищались", + "context_sentence": "He admired his older brother.", + "tags": "history, reader", + "collection": "General", + }) + assert resp2.status_code == 200 + card2 = resp2.json() + + # Must be same card ID (no duplicate created!) + assert card2["id"] == card_id + assert card2.get("already_exists") is True + + # Both contexts should be present and tags merged + assert "People admired his courage." in card2["context_sentence"] + assert "He admired his older brother." in card2["context_sentence"] + assert "book:kennedy" in card2["tags"] + assert "history" in card2["tags"] + + # Verify database count has only 1 card for 'admired' + cards_list = client.get("/api/cards?search=admired").json()["cards"] + assert len(cards_list) == 1 + assert cards_list[0]["id"] == card_id + + # 3. Test /api/lookup returns in_library: True for existing word + resp_lookup_existing = client.get("/api/lookup?term=admired") + assert resp_lookup_existing.status_code == 200 + data_existing = resp_lookup_existing.json() + assert data_existing["in_library"] is True + assert data_existing["library_card"]["id"] == card_id + + # Test /api/lookup returns in_library: False for word not in user library + resp_lookup_new = client.get("/api/lookup?term=xylophone_unseen_xyz") + assert resp_lookup_new.status_code == 200 + data_new = resp_lookup_new.json() + assert data_new["in_library"] is False + assert data_new["library_card"] is None