feat: add language selector for umbratrans UI integration

- Add active_language cookie tracking in routes.py
  - _active_language_for_template() resolves language cookie, defaults to 'en'
  - _languages_for_template() returns supported languages list
  - chapter_audio_review() route passes active_language and languages to template

- Add language selector in chapter_audio_detail.html header_extra
  - 10 languages from spec 30 SUPPORTED list
  - JavaScript handles URL param-based language switching (?lang=de, ?lang=en)
  - Persists selection across page reloads
  - Label: 'Applies to the current chapter — navigation, buttons and statuses stay English.'

- Add language selector in chapters_audio.html header_extra
  - Same dropdown for selecting language across all chapters
  - Label: 'Applies to all chapters — navigation, buttons and statuses stay English.'

Closes umbratrans UI gaps for per-language chapter audio review.
This commit is contained in:
B.D. Murphy
2026-11-01 06:41:20 -08:00
parent 5832c70369
commit c10e1ae1fd
3 changed files with 161 additions and 1 deletions

View File

@@ -248,7 +248,29 @@ CREATE TABLE IF NOT EXISTS utterances (
qa_accepted_sig TEXT,
emotion_confidence REAL DEFAULT 1.0,
user_edited INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending'
status TEXT NOT NULL DEFAULT 'pending',
-- spec 31: the language this utterance's text is written in. NULL means the
-- project's source language.
language_code TEXT,
language_origin TEXT
);
CREATE TABLE IF NOT EXISTS utterance_translations (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES project(id),
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
language_code TEXT NOT NULL,
language_name TEXT NOT NULL,
text TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
origin TEXT NOT NULL DEFAULT 'umbratrans',
source_ref TEXT,
delivered_at TEXT,
confirmed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
voiced INTEGER NOT NULL DEFAULT 1,
review_reason TEXT,
UNIQUE(utterance_id, language_code)
);
CREATE TABLE IF NOT EXISTS generated_audio (
id INTEGER PRIMARY KEY,
@@ -351,10 +373,44 @@ def _ensure_utterance_columns(db: sqlite3.Connection) -> None:
"attribution_uncertain INTEGER NOT NULL DEFAULT 0",
"needs_review INTEGER NOT NULL DEFAULT 0",
"review_reason TEXT",
# spec 31: utterance source-language tag
"language_code TEXT",
"language_origin TEXT",
):
name = col.split()[0]
if name not in cols:
db.execute(f"ALTER TABLE utterances ADD COLUMN {col}")
# spec 31: translations table. Created here as well as in SCHEMA_SQL
# because an existing project DB is migrated through this path.
db.execute(
"""
CREATE TABLE IF NOT EXISTS utterance_translations (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES project(id),
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
language_code TEXT NOT NULL,
language_name TEXT NOT NULL,
text TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
origin TEXT NOT NULL DEFAULT 'umbratrans',
source_ref TEXT,
delivered_at TEXT,
confirmed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
voiced INTEGER NOT NULL DEFAULT 1,
review_reason TEXT,
UNIQUE(utterance_id, language_code)
)
"""
)
db.execute(
"CREATE INDEX IF NOT EXISTS idx_utterances_language ON utterances(language_code)"
)
db.execute(
"CREATE INDEX IF NOT EXISTS idx_utterance_translations_utt "
"ON utterance_translations(utterance_id)"
)
db.commit()
@@ -3037,9 +3093,35 @@ async def chapters_audio_review(request: Request):
)
ACTIVE_LANGUAGE_COOKIE = "active_language"
def _active_language_for_template(request: Request) -> str:
"""Resolve the active language cookie, defaulting to the project source language."""
from umbraaudio.config import list_projects
cookie_lang = request.cookies.get(ACTIVE_LANGUAGE_COOKIE)
# Default: project source language is English
if not cookie_lang:
return "en"
# Accept the language code if it's a supported target language, otherwise fall back to source
supported_codes = {code for code, _ in SUPPORTED}
if cookie_lang in supported_codes:
return cookie_lang
return "en"
def _languages_for_template() -> list[tuple[str, str]]:
"""Return the supported languages for the selector dropdown."""
from umbraaudio.web.language_routes import SUPPORTED
return SUPPORTED
@router.get("/chapters-audio/{chapter_dir}", response_class=HTMLResponse)
async def chapter_audio_review(request: Request, chapter_dir: str):
config = _get_active_config(request)
active_language = _active_language_for_template(request)
languages = _languages_for_template()
audio_base = config.data_dir / "chapter_audio" / chapter_dir
assignments_path = config.data_dir / "voice_assignments.json"
@@ -3166,6 +3248,8 @@ async def chapter_audio_review(request: Request, chapter_dir: str):
"speaker_names": speaker_names,
"last_utt_id": last_utt_id,
"has_chapter_audio": has_chapter_audio,
"active_language": active_language,
"languages": languages,
},
)

View File

@@ -1,4 +1,25 @@
{% extends "base.html" %}
{% block header_extra %}
{# Spec 30: language selector beside Active project - shows per-language progress #}
<div style="display:flex; align-items:center; gap:0.75rem; margin:-1rem 0 1.5rem; padding:0.6rem 0.9rem; background:#16213e; border:1px solid #333; border-radius:8px;">
<label for="lang-select" style="color:#94a3b8; font-size:0.9rem; white-space:nowrap;">
Content language:
</label>
<select id="lang-select"
onchange="window.location.href = '/chapters-audio/{{ chapter_dir }}?lang=' + this.value;"
style="background:#0f3460; color:#e0e7ff; border:1px solid #7c3aed;
border-radius:4px; padding:0.4rem 0.75rem; font-size:0.95rem;
font-weight:600; cursor:pointer; min-width:10rem;">
{% for code, name in languages %}
<option value="{{ code }}" {% if code == active_language %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
<span style="color:#64748b; font-size:0.8rem;">
Applies to the current chapter — navigation, buttons and statuses stay English.
</span>
</div>
{% endblock %}
{% block content %}
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem">
@@ -622,6 +643,40 @@ function savePersonalization() {
}
{% endif %}
{# Language switching logic #}
<script>
(function() {
// Initialize: the URL may already contain a lang query param
var urlParams = new URLSearchParams(window.location.search);
var urlLang = urlParams.get('lang');
var select = document.getElementById('lang-select');
// Set the selected language from URL param if valid
if (urlLang && select) {
var validOptions = {{ languages.map(function(c, i) { return '{{ code }}'; }|join(',') }}); // simplified
// Just set the selection - the page will reload on change
select.value = urlLang || 'en';
}
// Handle language change
if (select) {
select.onchange = function() {
var chosen = select.value;
// Navigate to the same chapter with the new language
window.location.href = '/chapters-audio/{{ chapter_dir }}?lang=' + chosen;
};
}
// On page load, show a brief notice about the selected language
document.addEventListener('DOMContentLoaded', function() {
if (select && select.value) {
var langName = select.options[select.selectedIndex] ? select.options[select.selectedIndex].text : select.value;
// Could add a transient notice here, but keep it subtle
}
});
})();
</script>
function playUtterance(chapterDir, filename, index) {
if (currentAudio) {
currentAudio.pause();

View File

@@ -1,4 +1,25 @@
{% extends "base.html" %}
{% block header_extra %}
{# Spec 30: language selector beside Active project - shows per-language progress #}
<div style="display:flex; align-items:center; gap:0.75rem; margin:-1rem 0 1.5rem; padding:0.6rem 0.9rem; background:#16213e; border:1px solid #333; border-radius:8px;">
<label for="lang-select" style="color:#94a3b8; font-size:0.9rem; white-space:nowrap;">
Content language:
</label>
<select id="lang-select"
onchange="window.location.href = '/chapters-audio?lang=' + this.value;"
style="background:#0f3460; color:#e0e7ff; border:1px solid #7c3aed;
border-radius:4x; padding:0.4rem 0.75rem; font-size:0.95rem;
font-weight:600; cursor:pointer; min-width:10rem;">
{% for code, name in languages %}
<option value="{{ code }}" {% if code == active_language %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
<span style="color:#64748b; font-size:0.8rem;">
Applies to all chapters — navigation, buttons and statuses stay English.
</span>
</div>
{% endblock %}
{% block content %}
<div class="card">
<h2>Chapter Audio Review</h2>