mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Enhance entity management with filtering and preview functionality
This commit is contained in:
+31
-7
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 & 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 %}
|
||||
|
||||
Reference in New Issue
Block a user