diff --git a/app.js b/app.js
index 5ccb016..1d76a20 100644
--- a/app.js
+++ b/app.js
@@ -54,7 +54,20 @@ function renderCategories(categories) {
card.setAttribute("tabindex", "0");
const emoji = CATEGORY_EMOJI[cat.name] || "📚";
- card.innerHTML = `${emoji} ${cat.display_name}
${cat.word_count} word${cat.word_count !== 1 ? "s" : ""}`;
+ const emojiSpan = document.createElement("span");
+ emojiSpan.className = "emoji";
+ emojiSpan.textContent = emoji;
+ const nameSpan = document.createElement("span");
+ nameSpan.textContent = cat.display_name;
+ const lineBreak = document.createElement("br");
+ const countSpan = document.createElement("span");
+ countSpan.className = "small";
+ countSpan.textContent = `${cat.word_count} word${cat.word_count !== 1 ? "s" : ""}`;
+ card.appendChild(emojiSpan);
+ card.appendChild(document.createTextNode(" "));
+ card.appendChild(nameSpan);
+ card.appendChild(lineBreak);
+ card.appendChild(countSpan);
const go = () => {
const { lang, difficulty } = getState();
diff --git a/backend/SCORING.md b/backend/SCORING.md
index 83bed6a..e1b6df5 100644
--- a/backend/SCORING.md
+++ b/backend/SCORING.md
@@ -341,7 +341,7 @@ If your voice's fundamental frequency differs from the reference speaker's by 40
### A. Feature weights
-**File:** `feature_comparator.py`, lines 14–20
+**File:** `feature_comparator.py`, lines 13–19
```python
WEIGHTS = {
@@ -359,16 +359,16 @@ Must sum to 1.0.
| Feature | Component | Sigma | File location | More lenient suggestion |
|---------|-----------|-------|---------------|------------------------|
-| Pitch | Contour (DTW) | 50 | `_compare_pitch`, line ~143 | 80–100 |
-| Pitch | Mean | 30 | `_compare_pitch`, line ~146 | 50–60 |
-| Pitch | Range | 40 | `_compare_pitch`, line ~150 | 40 (fine) |
-| Formants | F1/F2/F3 (DTW) | 100 | `_compare_formants`, line ~163 | 200–300 |
-| Intensity | Contour (DTW) | 10 | `_compare_intensity`, line ~186 | 15–20 |
-| Intensity | Mean | 5 | `_compare_intensity`, line ~190 | 10–15 |
-| Duration | Time ratio | 0.3 | `_compare_duration`, line ~208 | 0.3 (fine) |
-| Duration | Voiced fraction | 0.2 | `_compare_duration`, line ~218 | 0.2 (fine) |
-| Voice quality | Jitter | 0.01 | `_compare_voice_quality`, line ~231 | 0.02–0.03 |
-| Voice quality | Shimmer | 0.05 | `_compare_voice_quality`, line ~232 | 0.08–0.10 |
+| Pitch | Contour (DTW) | 50 | `_compare_pitch` | 80–100 |
+| Pitch | Mean | 30 | `_compare_pitch` | 50–60 |
+| Pitch | Range | 40 | `_compare_pitch` | 40 (fine) |
+| Formants | F1/F2/F3 (DTW) | 100 | `_compare_formants` | 200–300 |
+| Intensity | Contour (DTW) | 10 | `_compare_intensity` | 15–20 |
+| Intensity | Mean | 5 | `_compare_intensity` | 10–15 |
+| Duration | Time ratio | 0.3 | `_compare_duration` | 0.3 (fine) |
+| Duration | Voiced fraction | 0.2 | `_compare_duration` | 0.2 (fine) |
+| Voice quality | Jitter | 0.01 | `_compare_voice_quality` | 0.02–0.03 |
+| Voice quality | Shimmer | 0.05 | `_compare_voice_quality` | 0.08–0.10 |
### C. Sub-feature weights
diff --git a/backend/app/routes/audio.py b/backend/app/routes/audio.py
index 140afca..4293926 100644
--- a/backend/app/routes/audio.py
+++ b/backend/app/routes/audio.py
@@ -23,7 +23,16 @@ async def stream_audio(word_id: int, db: aiosqlite.Connection = Depends(get_db))
if not audio_file:
raise HTTPException(404, f"No audio file for word {word_id}")
- path = settings.AUDIO_DIR / audio_file
+ # Resolve path safely and ensure it stays within the configured audio directory
+ try:
+ base_dir = settings.AUDIO_DIR.resolve()
+ path = (base_dir / audio_file).resolve()
+ except Exception:
+ # Any failure resolving the path is treated as an invalid path
+ raise HTTPException(400, "Invalid audio file path")
+ # Prevent path traversal by ensuring the resolved path is inside base_dir
+ if path == base_dir or base_dir not in path.parents:
+ raise HTTPException(400, "Invalid audio file path")
if not path.is_file():
raise HTTPException(404, f"Audio file not found on disk: {audio_file}")
diff --git a/backend/app/routes/words.py b/backend/app/routes/words.py
index 04297d4..fb00917 100644
--- a/backend/app/routes/words.py
+++ b/backend/app/routes/words.py
@@ -8,8 +8,6 @@
router = APIRouter(tags=["words"])
-LANG_COLUMNS = {"en": "translation_en", "fr": "translation_fr", "de": "translation_de"}
-
@router.get("/categories/{category_name}/words", response_model=list[WordOut])
async def list_words(
@@ -25,15 +23,23 @@ async def list_words(
if not cat:
raise HTTPException(404, f"Category '{category_name}' not found")
- col = LANG_COLUMNS[lang]
cursor = await db.execute(
- f"""
- SELECT id, word_lb, {col} AS translation, gender, audio_filename
+ """
+ SELECT
+ id,
+ word_lb,
+ CASE ?
+ WHEN 'en' THEN translation_en
+ WHEN 'fr' THEN translation_fr
+ WHEN 'de' THEN translation_de
+ END AS translation,
+ gender,
+ audio_filename
FROM words
WHERE category_id = ?
ORDER BY id
""",
- (cat["id"],),
+ (lang, cat["id"]),
)
rows = await cursor.fetchall()
return [
diff --git a/topic.js b/topic.js
index 63b45db..8a572b7 100644
--- a/topic.js
+++ b/topic.js
@@ -97,7 +97,7 @@ function updateUI() {
const labels = { en: "English", fr: "French", de: "German" };
const label = labels[TARGET_LANG] || "English";
const translation = word.translation || "";
- meaningText.innerHTML = `Translation (${label}): ${translation}`;
+ meaningText.textContent = `Translation (${label}): ${translation}`;
}
// Audio-only mode hides word + meaning