diff --git a/app/static/js/modules/srs.js b/app/static/js/modules/srs.js
index 4071f33..96aaf0d 100644
--- a/app/static/js/modules/srs.js
+++ b/app/static/js/modules/srs.js
@@ -3,7 +3,7 @@
// =======================================================================
import { api } from './api.js';
-import { escapeHtml, escapeRegex, shuffleArray, showToast, openModal, closeModal, playAudio } from './ui.js';
+import { escapeHtml, escapeRegex, shuffleArray, showToast, openModal, closeModal, playAudio, cleanPromptTranslation } from './ui.js';
import { getAllCards } from './dictionary.js';
let srsQueue = [];
@@ -330,7 +330,7 @@ export function initSrs() {
fcBackPos.style.display = card.part_of_speech ? 'inline-block' : 'none';
} else {
fcFrontDirBadge.textContent = 'RU β EN';
- fcFrontMain.textContent = card.translation;
+ fcFrontMain.textContent = cleanPromptTranslation(card.translation);
fcFrontPos.textContent = card.part_of_speech || '';
fcFrontPhoneticWrap.style.display = 'none';
fcHintBtn.style.display = 'none';
@@ -447,18 +447,18 @@ export function initSrs() {
if (quizAudioBtn) quizAudioBtn.classList.remove('hidden');
- const correctText = card.translation;
+ const correctText = cleanPromptTranslation(card.translation);
const all = getAllCards();
const candidates = all
- .filter(c => c.id !== card.id && c.translation && c.translation !== correctText)
- .map(c => c.translation);
+ .filter(c => c.id !== card.id && c.translation && cleanPromptTranslation(c.translation) !== correctText)
+ .map(c => cleanPromptTranslation(c.translation));
shuffleArray(candidates);
- const options = [correctText, ...candidates.slice(0, 3)];
+ let options = [correctText, ...candidates.slice(0, 3)];
while (options.length < 4) {
options.push(`ΠΠ°ΡΠΈΠ°Π½Ρ ${options.length + 1}`);
}
- shuffleArray(options);
+ options = shuffleArray(options);
quizCurrentOptions = options;
quizOptionsGrid.innerHTML = '';
@@ -543,7 +543,7 @@ export function initSrs() {
typingRatingBar.classList.add('hidden');
typingPos.textContent = card.part_of_speech || '';
- typingPromptRu.textContent = card.translation;
+ typingPromptRu.textContent = cleanPromptTranslation(card.translation);
if (card.context_sentence) {
typingContextHint.textContent = `ΠΠΎΠ½ΡΠ΅ΠΊΡΡ: β${card.context_sentence}β`;
typingContextHint.classList.remove('hidden');
@@ -590,7 +590,7 @@ export function initSrs() {
clozeRatingBar.classList.add('hidden');
clozePos.textContent = card.part_of_speech || '';
- clozeTargetTranslation.textContent = card.translation;
+ clozeTargetTranslation.textContent = cleanPromptTranslation(card.translation);
const context = card.context_sentence || `The word is ${card.term}.`;
const re = new RegExp(escapeRegex(card.term), 'gi');
@@ -635,7 +635,7 @@ export function initSrs() {
dictationDetailsBox.classList.add('hidden');
dictationRatingBar.classList.add('hidden');
dictationHintText.classList.add('hidden');
- dictationHintText.textContent = card.translation;
+ dictationHintText.textContent = cleanPromptTranslation(card.translation);
dictationInput.value = '';
dictationInput.disabled = false;
diff --git a/app/static/js/modules/ui.js b/app/static/js/modules/ui.js
index a9715c9..473f258 100644
--- a/app/static/js/modules/ui.js
+++ b/app/static/js/modules/ui.js
@@ -27,12 +27,26 @@ export function slugify(text) {
export function shuffleArray(array) {
if (!array || !Array.isArray(array)) return [];
- const arr = [...array];
- for (let i = arr.length - 1; i > 0; i--) {
+ for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
- [arr[i], arr[j]] = [arr[j], arr[i]];
+ [array[i], array[j]] = [array[j], array[i]];
}
- return arr;
+ return array;
+}
+
+export function cleanPromptTranslation(text) {
+ if (!text || typeof text !== 'string') return '';
+ // Strip grammatical inflection notes that contain Latin words to prevent spoilers in tests
+ return text.replace(/\s*\([Π°-ΡΠ-Π―ΡΠ0-9\s.,-]*\bΠΎΡ\s+[a-zA-Z\x27-]+\)/gi, '').trim();
+}
+
+export function formatTranslationWithSpoiler(text) {
+ if (!text || typeof text !== 'string') return '';
+ const noteMatch = text.match(/\s*\(([Π°-ΡΠ-Π―ΡΠ0-9\s.,-]*\bΠΎΡ\s+[a-zA-Z\x27-]+)\)/i);
+ if (!noteMatch) return escapeHtml(text);
+ const cleanText = text.replace(noteMatch[0], '').trim();
+ const noteText = noteMatch[1];
+ return `${escapeHtml(cleanText)}
βΉοΈ ΡΠΎΡΠΌΠ°${escapeHtml(noteText)}`;
}
// -----------------------------------------------------------------------
@@ -270,6 +284,8 @@ export const ui = {
updateHeaderStats,
playAudio,
playTextSpeech,
+ cleanPromptTranslation,
+ formatTranslationWithSpoiler,
};
export default ui;
diff --git a/docker-compose.yml b/docker-compose.yml
index 5cf2d17..254adc1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -18,7 +18,7 @@ services:
- DB_PATH=/app/data/notes.db
- OLLAMA_BASE_URL=http://ollama:11434
- OLLAMA_MODEL=qwen2.5:3b
- - OLLAMA_KEEP_ALIVE=5m
+ - OLLAMA_KEEP_ALIVE=1h
depends_on:
- ollama
@@ -31,4 +31,4 @@ services:
volumes:
- ./data/ollama:/root/.ollama
environment:
- - OLLAMA_KEEP_ALIVE=5m
+ - OLLAMA_KEEP_ALIVE=1h
diff --git a/tests/test_api.py b/tests/test_api.py
index c50d326..c698750 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -2497,6 +2497,56 @@ def test_lessons_debrief_and_history():
assert last_session["errors_count"] == 2
+def test_reverse_dictionary_lookup():
+ """
+ BUG-06 regression test: Searching Russian words should perform reverse lookup,
+ returning the corresponding English term, phonetics, and Russian meaning.
+ """
+ from app.auth import _login_failures
+ _login_failures.clear()
+ login_resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
+ assert login_resp.status_code == 200
+ token = login_resp.json()["token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ resp = client.get("/api/lookup?q=ΡΠΎΠ±Π°ΠΊΠ°", headers=headers)
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["is_reverse"] is True
+ assert data["term"].lower() == "dog"
+ assert "ΡΠΎΠ±Π°ΠΊΠ°" in data["translation"].lower()
+ assert data["phonetic"] != ""
+
+
+def test_wikipedia_parsoid_html_cleaning():
+ """
+ BUG-05 regression test: Wikipedia Parsoid HTML containing data-mw attributes with
+ embedded
[ and literal '>' chars should be cleanly stripped without leaking into text.
+ """
+ from app.reader import clean_wiki_html
+
+ sample_html = (
+ ''
+ 'RefText]"}}}]}\'>'
+ ''
+ '
Linux is a Unix-like operating system.
'
+ '
'
+ ''
+ )
+ chapters, cover, words = clean_wiki_html(sample_html, "Test Article")
+ assert len(chapters) >= 1
+ content = chapters[0]["content"]
+ assert "data-mw" not in content
+ assert "{" not in content
+ assert "Infobox" not in content
+ assert "RefText" not in content
+ assert "Linux is a Unix-like operating system." in content
+ assert "Times New Roman" not in content # stripped cleanly from table
+ assert "Clean Cell" in content
+
+
+