feat: Enhance entity management with filtering and preview functionality

This commit is contained in:
JB
2025-11-11 09:57:44 -08:00
parent 95f0307be1
commit 504b5ab5e5
4 changed files with 456 additions and 23 deletions
+31 -7
View File
@@ -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))
+226
View File
@@ -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();
})();
+102 -2
View File
@@ -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);
}
+97 -14
View File
@@ -3,7 +3,12 @@
{% block title %}abogen · Entities{% endblock %}
{% block content %}
<section class="card card--panel">
<section
class="card card--panel"
data-override-root
data-preview-url="{{ url_for('api.api_entity_pronunciation_preview') }}"
data-language="{{ language }}"
>
<h1 class="card__title">Entities &amp; Pronunciation Overrides</h1>
<p class="card__subtitle">Review and refine stored pronunciations so recurring names sound right in every project.</p>
<form class="entity-filter" method="get">
@@ -64,24 +69,91 @@
{{ status_error or status_message }}
</div>
{% endif %}
<div class="entity-table-tools">
<label class="field entity-table-tools__filter">
<span>Filter visible overrides</span>
<input
type="search"
data-role="override-filter"
placeholder="Type a name or voice to filter"
autocomplete="off"
>
</label>
<button type="button" class="button button--ghost" data-role="override-filter-clear">Clear</button>
</div>
<form
id="override-create-form"
class="form-section entity-create"
method="post"
action="{{ url_for('web.entities_override_update') }}"
>
<h3 class="form-section__title">Add manual override</h3>
<p class="entity-create__description">Create pronunciations or assign default voices without preparing a job.</p>
<input type="hidden" name="lang" value="{{ language }}">
<input type="hidden" name="action" value="save">
<input type="hidden" name="state_voice" value="{{ voice_filter }}">
<input type="hidden" name="state_pronunciation" value="{{ pronunciation_filter }}">
<input type="hidden" name="state_limit" value="{{ limit }}">
<input type="hidden" name="state_query" value="{{ query }}">
<div class="field-grid field-grid--compact">
<label class="field">
<span>Token</span>
<input type="text" name="token" placeholder="e.g. Arrakis" required>
</label>
<label class="field">
<span>Pronunciation</span>
<input type="text" name="pronunciation" placeholder="Optional phonetic spelling">
</label>
<label class="field">
<span>Voice override</span>
<select name="voice">
<option value="" selected>No override</option>
{% for voice in options.voice_catalog %}
<option value="{{ voice.id }}">{{ voice.display_name }} - {{ voice.language_label }} - {{ voice.gender }}</option>
{% endfor %}
</select>
</label>
</div>
<div class="entity-create__actions">
<button
type="button"
class="button button--ghost"
data-role="preview-button"
data-form-id="override-create-form"
>Preview</button>
<button type="submit" class="button">Add override</button>
</div>
<div class="entity-preview" data-role="preview-container">
<div class="entity-preview__status" data-role="preview-message"></div>
<audio
class="entity-preview__audio"
data-role="preview-audio"
controls
hidden
></audio>
</div>
</form>
{% if overrides %}
<div class="table-wrapper">
<table class="jobs-table overrides-table">
<table class="jobs-table overrides-table" data-role="override-table">
<thead>
<tr>
<th scope="col">Token</th>
<th scope="col">Pronunciation</th>
<th scope="col">Voice</th>
<th scope="col">Notes</th>
<th scope="col">Usage</th>
<th scope="col">Last updated</th>
<th scope="col">Preview</th>
<th scope="col" class="overrides-table__actions-heading">Actions</th>
</tr>
</thead>
<tbody>
{% for override in overrides %}
{% set form_id = "override-form-" ~ loop.index %}
<tr>
<tr
data-role="override-row"
data-token="{{ override.token | lower }}"
>
<td data-label="Token"><span class="overrides-table__token">{{ override.token }}</span></td>
<td data-label="Pronunciation">
<input
@@ -107,18 +179,27 @@
{% endfor %}
</select>
</td>
<td data-label="Notes">
<input
class="overrides-table__input"
type="text"
name="notes"
form="{{ form_id }}"
value="{{ override.notes or '' }}"
placeholder="Add notes"
>
</td>
<td data-label="Usage">{{ override.usage_count }}</td>
<td data-label="Last updated">{{ override.updated_at_label }}</td>
<td data-label="Preview">
<div class="entity-preview" data-role="preview-container">
<div class="entity-preview__controls">
<button
type="button"
class="button button--ghost button--small"
data-role="preview-button"
data-form-id="{{ form_id }}"
>Preview</button>
<span class="entity-preview__status" data-role="preview-message"></span>
</div>
<audio
class="entity-preview__audio"
data-role="preview-audio"
controls
hidden
></audio>
</div>
</td>
<td data-label="Actions">
<form
id="{{ form_id }}"
@@ -143,6 +224,7 @@
</tbody>
</table>
</div>
<p class="hint" data-role="filter-empty" hidden>No overrides match your current filter.</p>
{% else %}
<p class="hint">No overrides matched your filters. Try adjusting the search or create overrides from the Entities step while preparing a job.</p>
{% endif %}
@@ -151,4 +233,5 @@
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='entities.js') }}" defer></script>
{% endblock %}