diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 42d90ba..76e5e64 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -3543,8 +3543,10 @@ def entities_page() -> ResponseReturnValue: ] status_message = "" - if status_code == "saved": + if status_code in {"saved", "updated"}: status_message = f"Updated override for {status_token or 'override'}." + elif status_code == "created": + status_message = f"Added override for {status_token or 'override'}." elif status_code == "deleted": status_message = f"Deleted override for {status_token or 'override'}." @@ -3579,7 +3581,8 @@ def entities_override_update() -> ResponseReturnValue: action = (request.form.get("action") or "save").strip().lower() pronunciation_value = (request.form.get("pronunciation") or "").strip() voice_value = (request.form.get("voice") or "").strip() - notes_value = (request.form.get("notes") or "").strip() + notes_present = "notes" in request.form + notes_value = (request.form.get("notes") or "").strip() if notes_present else "" redirect_params: Dict[str, Any] = {"lang": language} state_mappings = ( @@ -3598,21 +3601,42 @@ def entities_override_update() -> ResponseReturnValue: redirect_params["error"] = "Missing override token." return redirect(url_for("web.entities_page", **redirect_params)) - status_code = "saved" + normalized_token = normalize_entity_token(token_value) + if not normalized_token: + redirect_params["status"] = "error" + redirect_params["error"] = "Token is too generic to override." + return redirect(url_for("web.entities_page", **redirect_params)) + + existing_map = load_pronunciation_overrides(language=language, tokens=[token_value]) + existing_override = existing_map.get(normalized_token) + + if notes_present: + notes_payload: Optional[str] = notes_value or None + elif existing_override: + notes_payload = existing_override.get("notes") + else: + notes_payload = None + + status_code = "updated" + saved_override: Optional[Dict[str, Any]] = None try: if action == "delete": delete_pronunciation_override(language=language, token=token_value) status_code = "deleted" else: - save_pronunciation_override( + saved_override = save_pronunciation_override( language=language, token=token_value, pronunciation=pronunciation_value or None, voice=voice_value or None, - notes=notes_value or None, + notes=notes_payload, context=None, ) - status_code = "saved" + status_code = "updated" if existing_override else "created" + except ValueError as exc: + redirect_params["status"] = "error" + redirect_params["error"] = str(exc) + return redirect(url_for("web.entities_page", **redirect_params)) except Exception as exc: # pragma: no cover - defensive logging current_app.logger.exception("Failed to %s override for token %s", action, token_value) redirect_params["status"] = "error" @@ -3620,7 +3644,7 @@ def entities_override_update() -> ResponseReturnValue: return redirect(url_for("web.entities_page", **redirect_params)) redirect_params["status"] = status_code - redirect_params["token"] = token_value + redirect_params["token"] = (saved_override or {}).get("token") or token_value return redirect(url_for("web.entities_page", **redirect_params)) diff --git a/abogen/web/static/entities.js b/abogen/web/static/entities.js new file mode 100644 index 0000000..36e706e --- /dev/null +++ b/abogen/web/static/entities.js @@ -0,0 +1,226 @@ +(function () { + const root = document.querySelector('[data-override-root]'); + if (!root) { + return; + } + + const previewUrl = root.dataset.previewUrl || ""; + const defaultLanguage = root.dataset.language || "a"; + const table = root.querySelector('[data-role="override-table"]'); + const rows = table ? Array.from(table.querySelectorAll('[data-role="override-row"]')) : []; + const filterInput = root.querySelector('[data-role="override-filter"]'); + const filterClearButton = root.querySelector('[data-role="override-filter-clear"]'); + const filterEmptyMessage = root.querySelector('[data-role="filter-empty"]'); + + function base64ToBlob(base64, mimeType) { + const binary = atob(base64); + const length = binary.length; + const bytes = new Uint8Array(length); + for (let index = 0; index < length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new Blob([bytes], { type: mimeType }); + } + + function getControl(form, selector) { + if (!form) { + return null; + } + const direct = form.querySelector(selector); + if (direct) { + return direct; + } + if (!form.id) { + return null; + } + return root.querySelector(`${selector}[form="${form.id}"]`) || document.querySelector(`${selector}[form="${form.id}"]`); + } + + function resetPreview(container) { + if (!container) { + return; + } + const messageEl = container.querySelector('[data-role="preview-message"]'); + const audioEl = container.querySelector('[data-role="preview-audio"]'); + if (messageEl) { + messageEl.textContent = ""; + messageEl.removeAttribute('data-state'); + } + if (audioEl) { + const priorUrl = audioEl.dataset.objectUrl; + if (priorUrl) { + URL.revokeObjectURL(priorUrl); + delete audioEl.dataset.objectUrl; + } + audioEl.pause(); + audioEl.removeAttribute('src'); + audioEl.hidden = true; + } + } + + function buildPreviewPayload(form) { + if (!form) { + return null; + } + const tokenInput = getControl(form, 'input[name="token"]'); + const pronunciationInput = getControl(form, 'input[name="pronunciation"]'); + const voiceSelect = getControl(form, 'select[name="voice"]'); + const languageInput = getControl(form, 'input[name="lang"]'); + + const token = tokenInput && 'value' in tokenInput ? tokenInput.value.trim() : ""; + const pronunciation = pronunciationInput && 'value' in pronunciationInput ? pronunciationInput.value.trim() : ""; + const voice = voiceSelect && 'value' in voiceSelect ? voiceSelect.value.trim() : ""; + const language = languageInput && 'value' in languageInput ? languageInput.value.trim() : defaultLanguage; + + if (!token && !pronunciation) { + return null; + } + return { + token, + pronunciation, + voice, + language, + }; + } + + async function requestPreview(button) { + if (!previewUrl) { + return; + } + const formId = button.dataset.formId || ""; + const form = formId ? document.getElementById(formId) : button.closest('form'); + const container = button.closest('[data-role="preview-container"]'); + const messageEl = container ? container.querySelector('[data-role="preview-message"]') : null; + const audioEl = container ? container.querySelector('[data-role="preview-audio"]') : null; + + resetPreview(container); + + const payload = buildPreviewPayload(form); + if (!payload) { + if (messageEl) { + messageEl.textContent = "Enter a token or pronunciation first."; + messageEl.dataset.state = "error"; + } + return; + } + + button.disabled = true; + button.setAttribute('data-loading', 'true'); + + try { + const response = await fetch(previewUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + const data = await response.json(); + if (!response.ok || data.error) { + throw new Error(data.error || 'Preview failed.'); + } + if (!data.audio_base64) { + throw new Error('Preview did not return audio.'); + } + + if (audioEl) { + const blob = base64ToBlob(data.audio_base64, 'audio/wav'); + const objectUrl = URL.createObjectURL(blob); + audioEl.src = objectUrl; + audioEl.dataset.objectUrl = objectUrl; + audioEl.hidden = false; + audioEl.load(); + audioEl.play().catch(() => { + /* playback might require user interaction; ignore */ + }); + } + + if (messageEl) { + messageEl.textContent = data.normalized_text || data.text || 'Preview ready.'; + messageEl.dataset.state = "success"; + } + } catch (error) { + if (messageEl) { + messageEl.textContent = error instanceof Error ? error.message : 'Preview failed.'; + messageEl.dataset.state = "error"; + } + } finally { + button.disabled = false; + button.removeAttribute('data-loading'); + } + } + + function attachPreviewHandlers() { + const previewButtons = root.querySelectorAll('[data-role="preview-button"]'); + previewButtons.forEach((button) => { + button.addEventListener('click', () => { + requestPreview(button); + }); + }); + } + + function applyFilter() { + if (!filterInput || rows.length === 0) { + return; + } + const term = filterInput.value.trim().toLowerCase(); + let visibleCount = 0; + rows.forEach((row) => { + const token = row.dataset.token || ""; + const pronunciationInput = row.querySelector('input[name="pronunciation"]'); + const voiceSelect = row.querySelector('select[name="voice"]'); + + const pronunciationValue = pronunciationInput && 'value' in pronunciationInput + ? pronunciationInput.value.trim().toLowerCase() + : ""; + const voiceOption = voiceSelect && 'selectedIndex' in voiceSelect && voiceSelect.selectedIndex >= 0 + ? voiceSelect.options[voiceSelect.selectedIndex] + : null; + const voiceValue = voiceOption && voiceOption.textContent + ? voiceOption.textContent.trim().toLowerCase() + : ""; + + if (!term || token.includes(term) || pronunciationValue.includes(term) || voiceValue.includes(term)) { + row.hidden = false; + visibleCount += 1; + } else { + row.hidden = true; + } + }); + + if (filterEmptyMessage) { + filterEmptyMessage.hidden = visibleCount !== 0; + } + } + + if (filterInput) { + filterInput.addEventListener('input', applyFilter); + } + + if (filterClearButton && filterInput) { + filterClearButton.addEventListener('click', () => { + filterInput.value = ""; + applyFilter(); + filterInput.focus(); + }); + } + + if (table) { + table.addEventListener('input', (event) => { + const target = event.target; + if (target && (target.matches('input[name="pronunciation"]') || target.matches('select[name="voice"]'))) { + applyFilter(); + } + }); + table.addEventListener('change', (event) => { + const target = event.target; + if (target && target.matches('select[name="voice"]')) { + applyFilter(); + } + }); + } + + attachPreviewHandlers(); + applyFilter(); +})(); diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index a96c117..76460cf 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -1595,6 +1595,77 @@ button.step-indicator__item:focus-visible { background: rgba(56, 189, 248, 0.08); } + +.entity-table-tools { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; + margin-bottom: 1.5rem; +} + +.entity-table-tools__filter { + flex: 1 1 240px; + max-width: 360px; +} + +.entity-table-tools__filter input { + max-width: none; +} + +.entity-table-tools__clear { + align-self: center; +} + +.entity-create { + gap: 1.25rem; +} + +.entity-create__description { + margin: 0; + color: var(--muted); + font-size: 0.95rem; +} + +.entity-create__actions { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.75rem; +} + +.entity-preview { + display: grid; + gap: 0.6rem; + align-items: flex-start; +} + +.entity-preview__controls { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.entity-preview__audio { + width: min(100%, 220px); + border-radius: 14px; + background: rgba(15, 23, 42, 0.45); +} + +.entity-preview__status { + font-size: 0.85rem; + color: var(--muted); +} + +.entity-preview__status[data-state="success"] { + color: var(--success); +} + +.entity-preview__status[data-state="error"] { + color: var(--danger); +} + .overrides-table__token { font-weight: 600; letter-spacing: 0.02em; @@ -1639,6 +1710,26 @@ button.step-indicator__item:focus-visible { margin: 0; } +.button[data-loading="true"][data-role="preview-button"] { + position: relative; + padding-left: 2.3rem; + pointer-events: none; +} + +.button[data-loading="true"][data-role="preview-button"]::after { + content: ""; + position: absolute; + left: 0.9rem; + top: 50%; + width: 1rem; + height: 1rem; + margin-top: -0.5rem; + border-radius: 50%; + border: 2px solid rgba(148, 163, 184, 0.4); + border-right-color: rgba(56, 189, 248, 0.8); + animation: spin 0.85s linear infinite; +} + .progress-bar { width: 100%; height: 9px; @@ -1872,8 +1963,17 @@ button.step-indicator__item:focus-visible { } .button--danger { - background: linear-gradient(135deg, var(--danger), #ef4444); - box-shadow: 0 12px 30px rgba(239, 68, 68, 0.2); + background: rgba(248, 113, 113, 0.16); + color: var(--danger); + border: 1px solid rgba(248, 113, 113, 0.45); + box-shadow: 0 10px 24px rgba(248, 113, 113, 0.18); +} + +.button--danger:hover { + background: linear-gradient(135deg, rgba(248, 113, 113, 0.85), #ef4444); + color: #fff; + border-color: rgba(248, 113, 113, 0.65); + box-shadow: 0 16px 36px rgba(248, 113, 113, 0.32); } diff --git a/abogen/web/templates/entities.html b/abogen/web/templates/entities.html index ac06d81..e1fd5f6 100644 --- a/abogen/web/templates/entities.html +++ b/abogen/web/templates/entities.html @@ -3,7 +3,12 @@ {% block title %}abogen ยท Entities{% endblock %} {% block content %} -
+

Entities & Pronunciation Overrides

Review and refine stored pronunciations so recurring names sound right in every project.

@@ -64,24 +69,91 @@ {{ status_error or status_message }} {% endif %} +
+ + +
+ +

Add manual override

+

Create pronunciations or assign default voices without preparing a job.

+ + + + + + +
+ + + +
+
+ + +
+
+
+ +
+
{% if overrides %}
- +
- + {% for override in overrides %} {% set form_id = "override-form-" ~ loop.index %} - + - +
Token Pronunciation VoiceNotes Usage Last updatedPreview Actions
{{ override.token }} - - {{ override.usage_count }} {{ override.updated_at_label }} +
+
+ + +
+ +
+
+ {% else %}

No overrides matched your filters. Try adjusting the search or create overrides from the Entities step while preparing a job.

{% endif %} @@ -151,4 +233,5 @@ {% block scripts %} {{ super() }} + {% endblock %}