feat: prevent duplicate cards, merge contexts, and display star badge when word is in library
This commit is contained in:
1 parent
f52820fff6
commit
a73bdc29fd
7 files changed
+355
-24
No files matched your search
@@ -48,17 +48,17 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 3. 🛡️ Дедупликация слов и защита от повторного добавления (Reader + API)
|
### 3. 🛡️ Дедупликация слов и защита от повторного добавления (Reader + API)
|
||||||
- [ ] **Проверка дубликатов на бэкенде (`POST /api/cards`)**:
|
- [x] **Проверка дубликатов на бэкенде (`POST /api/cards`)**:
|
||||||
- Проверять наличие слова для пользователя без учёта регистра: `lower(trim(term))`.
|
- Проверка слова без учёта регистра `lower(trim(term))`.
|
||||||
- При попытке повторного добавления: не создавать дубликат-клон, а дополнять контекстное предложение (если в новой цитате другой контекст) и объединять теги книги (`tags`), возвращая существующую карточку со статусом `already_exists`.
|
- При повторном добавлении не создаётся дубликат, а дополняется контекстное предложение и объединяются теги, возвращается `already_exists: True`.
|
||||||
- [ ] **Индикация в читалке (Reader Popup)**:
|
- [x] **Индикация в читалке и карточке поиска**:
|
||||||
- При клике на слово проверять, есть ли уже `cleanTerm` в словаре пользователя.
|
- В читалке при клике на слово и в карточке поиска выводится бейдж `⭐ В библиотеке`.
|
||||||
- Если слово уже сохранено: показывать зелёный бейдж `✓ В словаре` вместо активной кнопки `+ В словарь` (предотвращая случайные повторные клики).
|
- Кнопка «+ В словарь» заменяется на бейдж `⭐ Слово уже в библиотеке` с опциональной кнопкой `+ Добавить эту цитату к карточке`.
|
||||||
- Предоставить кнопку «Добавить контекст» для привязки новой цитаты к существующей карточке.
|
- [x] **Индекс целостности в SQLite**:
|
||||||
- [ ] **Индекс целостности в SQLite**:
|
- Создан `UNIQUE INDEX idx_vocab_user_term_nocase ON vocabulary_cards(user_id, term COLLATE NOCASE);` гарантирующий отсутствие дубликатов на уровне движка БД за $O(\log N)$.
|
||||||
- Добавить `CREATE INDEX IF NOT EXISTS 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.
|
||||||
- Написать безопасную миграцию/скрипт, объединяющий накопившиеся дубликаты (например, `admired`, `suddenly`/`Suddenly` у пользователя `kopri`), объединяя контексты предложений и сохраняя историю повторений SRS.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,75 @@ def _migrate(conn: sqlite3.Connection):
|
|||||||
""")
|
""")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_books_user ON books(user_id);")
|
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():
|
def init_db():
|
||||||
config.DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
config.DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
+52
-1
@@ -285,9 +285,24 @@ async def api_lookup(
|
|||||||
if not query_str:
|
if not query_str:
|
||||||
raise HTTPException(status_code=400, detail="Query term cannot be empty")
|
raise HTTPException(status_code=400, detail="Query term cannot be empty")
|
||||||
data = await lookup_term(query_str)
|
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
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.api_route("/api/audio", methods=["GET", "HEAD"])
|
@app.api_route("/api/audio", methods=["GET", "HEAD"])
|
||||||
async def api_audio_proxy():
|
async def api_audio_proxy():
|
||||||
# Audio feature disabled
|
# Audio feature disabled
|
||||||
@@ -386,7 +401,42 @@ async def api_get_cards(
|
|||||||
|
|
||||||
@app.post("/api/cards")
|
@app.post("/api/cards")
|
||||||
async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_user)):
|
async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_user)):
|
||||||
|
clean_term = card.term.strip()
|
||||||
with get_db() as conn:
|
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(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO vocabulary_cards (
|
INSERT INTO vocabulary_cards (
|
||||||
@@ -397,7 +447,7 @@ async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
user["id"],
|
user["id"],
|
||||||
card.term.strip(),
|
clean_term,
|
||||||
card.translation.strip(),
|
card.translation.strip(),
|
||||||
(card.context_sentence or "").strip(),
|
(card.context_sentence or "").strip(),
|
||||||
(card.phonetic or "").strip(),
|
(card.phonetic or "").strip(),
|
||||||
@@ -414,6 +464,7 @@ async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_
|
|||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/cards/{card_id}")
|
@app.put("/api/cards/{card_id}")
|
||||||
async def api_update_card(card_id: int, update: CardUpdateRequest, user: dict = Depends(require_user)):
|
async def api_update_card(card_id: int, update: CardUpdateRequest, user: dict = Depends(require_user)):
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
|||||||
@@ -3040,8 +3040,42 @@ body {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--success);
|
color: #f59e0b;
|
||||||
padding: 6px;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
||||||
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=2.7">
|
<link rel="stylesheet" href="/static/css/style.css?v=2.8">
|
||||||
|
|
||||||
<!-- PWA meta and icons -->
|
<!-- PWA meta and icons -->
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
@@ -125,6 +125,7 @@
|
|||||||
<div class="lookup-term-group">
|
<div class="lookup-term-group">
|
||||||
<h2 id="res-term" class="res-term">Word</h2>
|
<h2 id="res-term" class="res-term">Word</h2>
|
||||||
<span id="res-phonetic" class="res-phonetic">/wɜːd/</span>
|
<span id="res-phonetic" class="res-phonetic">/wɜːd/</span>
|
||||||
|
<span id="res-in-library" class="badge-library-star hidden" title="Это слово уже есть в вашей библиотеке">⭐ В библиотеке</span>
|
||||||
<button id="audio-play-btn" class="icon-btn audio-btn hidden" style="display: none;" title="Прослушать произношение">🔊</button>
|
<button id="audio-play-btn" class="icon-btn audio-btn hidden" style="display: none;" title="Прослушать произношение">🔊</button>
|
||||||
</div>
|
</div>
|
||||||
<span id="res-pos" class="badge-pos">Noun</span>
|
<span id="res-pos" class="badge-pos">Noun</span>
|
||||||
@@ -905,6 +906,7 @@
|
|||||||
<div class="popup-term-wrap">
|
<div class="popup-term-wrap">
|
||||||
<strong id="popup-term" class="popup-term">word</strong>
|
<strong id="popup-term" class="popup-term">word</strong>
|
||||||
<span id="popup-phonetic" class="popup-phonetic">/wɜːd/</span>
|
<span id="popup-phonetic" class="popup-phonetic">/wɜːd/</span>
|
||||||
|
<span id="popup-in-library" class="popup-in-library-badge hidden" title="Это слово уже есть в вашей библиотеке">⭐ В библиотеке</span>
|
||||||
<button id="popup-audio-btn" class="icon-btn audio-btn" style="display: none;" title="Слушать произношение">🔊</button>
|
<button id="popup-audio-btn" class="icon-btn audio-btn" style="display: none;" title="Слушать произношение">🔊</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="popup-close-btn" class="popup-close-btn">✕</button>
|
<button id="popup-close-btn" class="popup-close-btn">✕</button>
|
||||||
@@ -918,13 +920,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="popup-footer">
|
<div class="popup-footer">
|
||||||
<button id="popup-save-btn" class="btn btn-primary btn-sm btn-block">💾 Добавить в словарь</button>
|
<button id="popup-save-btn" class="btn btn-primary btn-sm btn-block">💾 Добавить в словарь</button>
|
||||||
<div id="popup-saved-badge" class="popup-saved-badge hidden">✅ Сохранено в словарь!</div>
|
<div id="popup-saved-badge" class="popup-saved-badge hidden">⭐ Слово уже в библиотеке</div>
|
||||||
|
<button id="popup-add-context-btn" class="btn btn-xs btn-outline btn-block hidden" style="margin-top: 6px;">+ Добавить эту цитату к карточке</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Audio Element for native streaming -->
|
<!-- Audio Element for native streaming -->
|
||||||
<audio id="global-audio-player" preload="none"></audio>
|
<audio id="global-audio-player" preload="none"></audio>
|
||||||
|
|
||||||
<script src="/static/js/app.js?v=2.7"></script>
|
<script src="/static/js/app.js?v=2.8"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+124
-7
@@ -88,6 +88,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const readerWordPopup = document.getElementById('reader-word-popup');
|
const readerWordPopup = document.getElementById('reader-word-popup');
|
||||||
const popupTerm = document.getElementById('popup-term');
|
const popupTerm = document.getElementById('popup-term');
|
||||||
const popupPhonetic = document.getElementById('popup-phonetic');
|
const popupPhonetic = document.getElementById('popup-phonetic');
|
||||||
|
const popupInLibrary = document.getElementById('popup-in-library');
|
||||||
const popupAudioBtn = document.getElementById('popup-audio-btn');
|
const popupAudioBtn = document.getElementById('popup-audio-btn');
|
||||||
const popupCloseBtn = document.getElementById('popup-close-btn');
|
const popupCloseBtn = document.getElementById('popup-close-btn');
|
||||||
const popupLoading = document.getElementById('popup-loading');
|
const popupLoading = document.getElementById('popup-loading');
|
||||||
@@ -96,6 +97,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const popupContext = document.getElementById('popup-context');
|
const popupContext = document.getElementById('popup-context');
|
||||||
const popupSaveBtn = document.getElementById('popup-save-btn');
|
const popupSaveBtn = document.getElementById('popup-save-btn');
|
||||||
const popupSavedBadge = document.getElementById('popup-saved-badge');
|
const popupSavedBadge = document.getElementById('popup-saved-badge');
|
||||||
|
const popupAddContextBtn = document.getElementById('popup-add-context-btn');
|
||||||
|
|
||||||
// Reader State
|
// Reader State
|
||||||
let currentBook = null;
|
let currentBook = null;
|
||||||
@@ -116,6 +118,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const lookupCard = document.getElementById('lookup-card');
|
const lookupCard = document.getElementById('lookup-card');
|
||||||
const resTerm = document.getElementById('res-term');
|
const resTerm = document.getElementById('res-term');
|
||||||
const resPhonetic = document.getElementById('res-phonetic');
|
const resPhonetic = document.getElementById('res-phonetic');
|
||||||
|
const resInLibrary = document.getElementById('res-in-library');
|
||||||
const audioPlayBtn = document.getElementById('audio-play-btn');
|
const audioPlayBtn = document.getElementById('audio-play-btn');
|
||||||
const resPos = document.getElementById('res-pos');
|
const resPos = document.getElementById('res-pos');
|
||||||
const resDefinitionWrap = document.getElementById('res-definition-wrap');
|
const resDefinitionWrap = document.getElementById('res-definition-wrap');
|
||||||
@@ -580,15 +583,36 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
resDefinitionWrap.classList.add('hidden');
|
resDefinitionWrap.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
editTranslation.value = data.translation || '';
|
const inLib = Boolean(data.in_library || foundDuplicateCard);
|
||||||
editContext.value = '';
|
if (resInLibrary) {
|
||||||
editNotes.value = '';
|
resInLibrary.classList.toggle('hidden', !inLib);
|
||||||
editCollection.value = selectedCollection || 'General';
|
}
|
||||||
|
|
||||||
|
if (inLib) {
|
||||||
|
saveCardBtn.innerHTML = '⭐ Обновить в словаре <span class="btn-subtext">(Ctrl + Enter)</span>';
|
||||||
|
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 = '💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>';
|
||||||
|
editTranslation.value = data.translation || '';
|
||||||
|
editContext.value = '';
|
||||||
|
editNotes.value = '';
|
||||||
|
editCollection.value = selectedCollection || 'General';
|
||||||
|
}
|
||||||
|
|
||||||
lookupCard.classList.remove('hidden');
|
lookupCard.classList.remove('hidden');
|
||||||
editTranslation.focus();
|
editTranslation.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Save Card Action
|
// Save Card Action
|
||||||
saveCardBtn.addEventListener('click', saveNewCard);
|
saveCardBtn.addEventListener('click', saveNewCard);
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
@@ -2375,10 +2399,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const cleanTerm = (term || '').replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim();
|
const cleanTerm = (term || '').replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim();
|
||||||
if (!cleanTerm) return;
|
if (!cleanTerm) return;
|
||||||
|
|
||||||
|
const lowerClean = cleanTerm.toLowerCase();
|
||||||
|
const existingCard = allCards && allCards.find(c => c.term && c.term.trim().toLowerCase() === lowerClean);
|
||||||
|
|
||||||
popupCurrentData = {
|
popupCurrentData = {
|
||||||
term: cleanTerm,
|
term: cleanTerm,
|
||||||
contextSentence: contextSentence,
|
contextSentence: contextSentence,
|
||||||
targetElement: targetElement,
|
targetElement: targetElement,
|
||||||
|
existingCard: existingCard,
|
||||||
};
|
};
|
||||||
|
|
||||||
popupTerm.textContent = cleanTerm;
|
popupTerm.textContent = cleanTerm;
|
||||||
@@ -2387,9 +2415,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
popupContext.textContent = contextSentence ? `“${contextSentence}”` : '';
|
popupContext.textContent = contextSentence ? `“${contextSentence}”` : '';
|
||||||
popupLoading.classList.remove('hidden');
|
popupLoading.classList.remove('hidden');
|
||||||
popupContent.classList.add('hidden');
|
popupContent.classList.add('hidden');
|
||||||
popupSavedBadge.classList.add('hidden');
|
|
||||||
popupSaveBtn.classList.remove('hidden');
|
// Immediate check against already-loaded user cards
|
||||||
popupSaveBtn.disabled = false;
|
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
|
// Position popup near target element
|
||||||
readerWordPopup.classList.remove('hidden');
|
readerWordPopup.classList.remove('hidden');
|
||||||
@@ -2407,6 +2453,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
popupLoading.classList.add('hidden');
|
popupLoading.classList.add('hidden');
|
||||||
popupContent.classList.remove('hidden');
|
popupContent.classList.remove('hidden');
|
||||||
popupAudioBtn.classList.add('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);
|
positionWordPopup(targetElement);
|
||||||
} else {
|
} else {
|
||||||
popupTranslation.textContent = 'Ошибка перевода';
|
popupTranslation.textContent = 'Ошибка перевода';
|
||||||
@@ -2491,8 +2558,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
|
const resData = await resp.json();
|
||||||
popupSaveBtn.classList.add('hidden');
|
popupSaveBtn.classList.add('hidden');
|
||||||
popupSavedBadge.classList.remove('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) {
|
if (d.targetElement) {
|
||||||
d.targetElement.classList.add('status-learning');
|
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
|
// Phrase/Chunk Selection in Reader
|
||||||
if (readerContentBody) {
|
if (readerContentBody) {
|
||||||
readerContentBody.addEventListener('mouseup', () => {
|
readerContentBody.addEventListener('mouseup', () => {
|
||||||
|
|||||||
@@ -811,3 +811,60 @@ def test_gzip_compression():
|
|||||||
resp_raw = client.get("/static/js/app.js", headers={"Accept-Encoding": "identity"})
|
resp_raw = client.get("/static/js/app.js", headers={"Accept-Encoding": "identity"})
|
||||||
assert resp_raw.status_code == 200
|
assert resp_raw.status_code == 200
|
||||||
assert resp_raw.headers.get("content-encoding") is None
|
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
|
||||||
Reference in new issue
Block a user