mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Enhance manual override functionality with status updates and improved UI elements
This commit is contained in:
+180
-25
@@ -1168,6 +1168,36 @@ const initPrepare = (root = document) => {
|
||||
const manualOverrideSearchButton = manualOverridesRoot?.querySelector('[data-role="manual-override-search"]');
|
||||
const manualOverrideAddCustomButton = manualOverridesRoot?.querySelector('[data-role="manual-override-add-custom"]');
|
||||
const manualOverridesEmpty = manualOverridesRoot?.querySelector('[data-role="manual-overrides-empty"]');
|
||||
const manualOverrideSaveButton = manualOverridesRoot?.querySelector('[data-role="manual-override-save-all"]');
|
||||
const manualOverrideStatusNode = manualOverridesRoot?.querySelector('[data-role="manual-override-status"]');
|
||||
let manualOverrideStatusTimer = null;
|
||||
let manualOverrideStatusNonce = 0;
|
||||
|
||||
const setManualOverrideStatus = (message, state = "") => {
|
||||
if (!manualOverrideStatusNode) return;
|
||||
manualOverrideStatusNonce += 1;
|
||||
const nonce = manualOverrideStatusNonce;
|
||||
if (manualOverrideStatusTimer) {
|
||||
window.clearTimeout(manualOverrideStatusTimer);
|
||||
manualOverrideStatusTimer = null;
|
||||
}
|
||||
manualOverrideStatusNode.textContent = message || "";
|
||||
if (state) {
|
||||
manualOverrideStatusNode.dataset.state = state;
|
||||
} else {
|
||||
manualOverrideStatusNode.removeAttribute("data-state");
|
||||
}
|
||||
if (state === "success" && message) {
|
||||
manualOverrideStatusTimer = window.setTimeout(() => {
|
||||
if (manualOverrideStatusNonce !== nonce) {
|
||||
return;
|
||||
}
|
||||
manualOverrideStatusNode.textContent = "";
|
||||
manualOverrideStatusNode.removeAttribute("data-state");
|
||||
manualOverrideStatusTimer = null;
|
||||
}, 4000);
|
||||
}
|
||||
};
|
||||
|
||||
if (entitiesRefreshButton) {
|
||||
entitiesRefreshButton.disabled = !entitiesEnabled;
|
||||
@@ -1932,6 +1962,7 @@ const initPrepare = (root = document) => {
|
||||
markOverrideDirty = (overrideId) => {
|
||||
if (!overrideId) return;
|
||||
dirtyOverrideIds.add(overrideId);
|
||||
setManualOverrideStatus("Unsaved changes", "pending");
|
||||
};
|
||||
|
||||
flushManualOverrides = (options = {}) => {
|
||||
@@ -1939,6 +1970,7 @@ const initPrepare = (root = document) => {
|
||||
const pendingIds = Array.isArray(overrideId) ? overrideId : overrideId ? [overrideId] : Array.from(dirtyOverrideIds);
|
||||
const targetIds = pendingIds.filter((id) => dirtyOverrideIds.has(id));
|
||||
if (!targetIds.length) return null;
|
||||
setManualOverrideStatus("Saving overrides…", "pending");
|
||||
targetIds.forEach((id) => dirtyOverrideIds.delete(id));
|
||||
const runFlush = async () => {
|
||||
if (overrideFlushPromise) {
|
||||
@@ -1949,15 +1981,23 @@ const initPrepare = (root = document) => {
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(targetIds.map((id) => saveManualOverride(id)));
|
||||
let hasFailure = false;
|
||||
results.forEach((ok, index) => {
|
||||
if (!ok) {
|
||||
hasFailure = true;
|
||||
dirtyOverrideIds.add(targetIds[index]);
|
||||
}
|
||||
});
|
||||
if (hasFailure) {
|
||||
setManualOverrideStatus("Some overrides failed to save.", "error");
|
||||
} else {
|
||||
setManualOverrideStatus("Overrides saved.", "success");
|
||||
}
|
||||
};
|
||||
overrideFlushPromise = runFlush()
|
||||
.catch((error) => {
|
||||
console.error("Failed flushing manual overrides", error);
|
||||
setManualOverrideStatus("Failed to save overrides.", "error");
|
||||
})
|
||||
.finally(() => {
|
||||
overrideFlushPromise = null;
|
||||
@@ -2060,9 +2100,9 @@ const initPrepare = (root = document) => {
|
||||
}
|
||||
|
||||
async function createOverrideFromData(data) {
|
||||
if (!data || !manualUpsertUrl) return;
|
||||
if (!data || !manualUpsertUrl) return false;
|
||||
const token = (data.token || "").trim();
|
||||
if (!token) return;
|
||||
if (!token) return false;
|
||||
const normalized = (data.normalized || "").trim();
|
||||
const voiceChoice = resolveOverrideVoice({ ...data, token, normalized });
|
||||
const payload = {
|
||||
@@ -2095,8 +2135,10 @@ const initPrepare = (root = document) => {
|
||||
manualOverrideResultsList.innerHTML = "";
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to create manual override", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2114,6 +2156,102 @@ const initPrepare = (root = document) => {
|
||||
});
|
||||
}
|
||||
|
||||
const extractInlineOverrideState = (inlineOverride, row) => {
|
||||
if (!inlineOverride || inlineOverride.hidden) {
|
||||
return null;
|
||||
}
|
||||
const container = row || inlineOverride.closest('[data-role="entity-row"]');
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
const tokenLabel = inlineOverride.dataset.pendingToken || container.querySelector('[data-role="entity-label"]')?.textContent || "";
|
||||
if (!tokenLabel) {
|
||||
return null;
|
||||
}
|
||||
const normalized = (inlineOverride.dataset.pendingNormalized || container.dataset.normalized || tokenLabel || "").trim();
|
||||
const context = (inlineOverride.dataset.pendingContext || "").trim();
|
||||
const pronInput = inlineOverride.querySelector('[data-role="manual-override-pronunciation"]');
|
||||
const voiceSelect = inlineOverride.querySelector('[data-role="manual-override-voice"]');
|
||||
const pronunciationRaw = pronInput?.value ?? "";
|
||||
const pronunciation = pronunciationRaw.trim();
|
||||
const voiceRaw = voiceSelect?.value ?? "";
|
||||
const voice = voiceRaw;
|
||||
const previewVoice = voice || baseVoice || "";
|
||||
const previewText = pronunciation || tokenLabel;
|
||||
return {
|
||||
token: tokenLabel,
|
||||
normalized,
|
||||
context,
|
||||
pronunciation,
|
||||
voice,
|
||||
previewVoice,
|
||||
previewText,
|
||||
category: inlineOverride.dataset.pendingCategory || container.dataset.entityCategory || "",
|
||||
kind: inlineOverride.dataset.pendingKind || container.dataset.entityKind || "",
|
||||
};
|
||||
};
|
||||
|
||||
const syncInlinePreviewUI = (row, inlineOverride, state, targetButton) => {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const previewText = state.previewText || state.token;
|
||||
const previewVoice = state.previewVoice || baseVoice || "";
|
||||
const applyDatasets = (button) => {
|
||||
if (!button) return;
|
||||
button.dataset.previewText = previewText;
|
||||
button.dataset.voice = previewVoice;
|
||||
button.dataset.language = languageCode;
|
||||
button.dataset.speed = defaultSpeed;
|
||||
button.dataset.useGpu = useGpuDefault;
|
||||
if (!button.dataset.previewSource || button.dataset.previewSource === "entity") {
|
||||
button.dataset.previewSource = "manual";
|
||||
}
|
||||
};
|
||||
|
||||
if (targetButton) {
|
||||
applyDatasets(targetButton);
|
||||
return;
|
||||
}
|
||||
|
||||
if (inlineOverride) {
|
||||
applyDatasets(inlineOverride.querySelector('[data-role="speaker-preview"]'));
|
||||
}
|
||||
if (row) {
|
||||
applyDatasets(row.querySelector('[data-entity-preview="true"]'));
|
||||
}
|
||||
};
|
||||
|
||||
const persistInlineOverrideState = (state) => {
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
highlightMode = "inline";
|
||||
setManualOverrideStatus("Saving overrides…", "pending");
|
||||
return createOverrideFromData({
|
||||
token: state.token,
|
||||
normalized: state.normalized,
|
||||
context: state.context,
|
||||
pronunciation: state.pronunciation,
|
||||
voice: state.voice,
|
||||
category: state.category,
|
||||
kind: state.kind,
|
||||
source: "entity-inline",
|
||||
})
|
||||
.then((ok) => {
|
||||
if (ok) {
|
||||
setManualOverrideStatus("Overrides saved.", "success");
|
||||
} else {
|
||||
setManualOverrideStatus("Failed to save overrides.", "error");
|
||||
}
|
||||
return ok;
|
||||
})
|
||||
.catch((error) => {
|
||||
setManualOverrideStatus("Failed to save overrides.", "error");
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
const handleEntityListClick = (event) => {
|
||||
const addTrigger = event.target.closest('[data-role="entity-add-override"]');
|
||||
if (addTrigger) {
|
||||
@@ -2160,13 +2298,8 @@ const initPrepare = (root = document) => {
|
||||
inlineOverride.dataset.pendingCategory = addTrigger.dataset.entityCategory || row.dataset.entityCategory || "";
|
||||
inlineOverride.dataset.pendingKind = addTrigger.dataset.entityKind || row.dataset.entityKind || "";
|
||||
|
||||
if (previewButton) {
|
||||
previewButton.dataset.previewText = pronInput?.value || token;
|
||||
previewButton.dataset.voice = voiceSelect?.value || baseVoice || "";
|
||||
previewButton.dataset.language = languageCode;
|
||||
previewButton.dataset.speed = defaultSpeed;
|
||||
previewButton.dataset.useGpu = useGpuDefault;
|
||||
}
|
||||
const inlineState = extractInlineOverrideState(inlineOverride, row);
|
||||
syncInlinePreviewUI(row, inlineOverride, inlineState, previewButton);
|
||||
|
||||
highlightMode = "inline";
|
||||
highlightedOverrideId = row.dataset.overrideId || "";
|
||||
@@ -2182,22 +2315,9 @@ const initPrepare = (root = document) => {
|
||||
if (!inlineOverride || !row) {
|
||||
return;
|
||||
}
|
||||
const pronInput = inlineOverride.querySelector('[data-role="manual-override-pronunciation"]');
|
||||
const voiceSelect = inlineOverride.querySelector('[data-role="manual-override-voice"]');
|
||||
const token = inlineOverride.dataset.pendingToken || row.querySelector('[data-role="entity-label"]')?.textContent || "";
|
||||
const normalized = inlineOverride.dataset.pendingNormalized || row.dataset.normalized || token;
|
||||
const context = inlineOverride.dataset.pendingContext || "";
|
||||
highlightMode = "inline";
|
||||
createOverrideFromData({
|
||||
token,
|
||||
normalized,
|
||||
context,
|
||||
pronunciation: pronInput?.value || "",
|
||||
voice: voiceSelect?.value || "",
|
||||
category: inlineOverride.dataset.pendingCategory || row.dataset.entityCategory || "",
|
||||
kind: inlineOverride.dataset.pendingKind || row.dataset.entityKind || "",
|
||||
source: "entity-inline",
|
||||
});
|
||||
const inlineState = extractInlineOverrideState(inlineOverride, row);
|
||||
syncInlinePreviewUI(row, inlineOverride, inlineState);
|
||||
void persistInlineOverrideState(inlineState);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2351,6 +2471,15 @@ const initPrepare = (root = document) => {
|
||||
|
||||
form.addEventListener("click", (event) => {
|
||||
const previewFromOverride = event.target.closest('[data-role="speaker-preview"]');
|
||||
if (previewFromOverride) {
|
||||
const entityRow = previewFromOverride.closest('[data-role="entity-row"]');
|
||||
const inlineOverride = entityRow?.querySelector('[data-role="inline-override"]');
|
||||
if (inlineOverride && !inlineOverride.hidden) {
|
||||
const inlineState = extractInlineOverrideState(inlineOverride, entityRow);
|
||||
syncInlinePreviewUI(entityRow, inlineOverride, inlineState, previewFromOverride);
|
||||
void persistInlineOverrideState(inlineState);
|
||||
}
|
||||
}
|
||||
if (previewFromOverride?.dataset.overrideId) {
|
||||
void flushManualOverrides({ overrideId: previewFromOverride.dataset.overrideId });
|
||||
}
|
||||
@@ -2439,6 +2568,32 @@ const initPrepare = (root = document) => {
|
||||
select.dataset.prevManual = select.value || "";
|
||||
});
|
||||
|
||||
form.addEventListener("input", (event) => {
|
||||
const inlineOverride = event.target.closest('[data-role="inline-override"]');
|
||||
if (!inlineOverride || inlineOverride.hidden) {
|
||||
return;
|
||||
}
|
||||
const entityRow = inlineOverride.closest('[data-role="entity-row"]');
|
||||
const inlineState = extractInlineOverrideState(inlineOverride, entityRow);
|
||||
syncInlinePreviewUI(entityRow, inlineOverride, inlineState);
|
||||
if (inlineState) {
|
||||
setManualOverrideStatus("Unsaved changes", "pending");
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener("change", (event) => {
|
||||
const inlineOverride = event.target.closest('[data-role="inline-override"]');
|
||||
if (!inlineOverride || inlineOverride.hidden) {
|
||||
return;
|
||||
}
|
||||
const entityRow = inlineOverride.closest('[data-role="entity-row"]');
|
||||
const inlineState = extractInlineOverrideState(inlineOverride, entityRow);
|
||||
syncInlinePreviewUI(entityRow, inlineOverride, inlineState);
|
||||
if (inlineState) {
|
||||
setManualOverrideStatus("Unsaved changes", "pending");
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!form.contains(event.target)) {
|
||||
hideGenderMenus();
|
||||
|
||||
@@ -1816,10 +1816,47 @@ button.step-indicator__item:focus-visible {
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.manual-overrides__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.manual-overrides__copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.manual-overrides__header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.manual-overrides__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.manual-overrides__status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.manual-overrides__status[data-state="success"] {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.manual-overrides__status[data-state="error"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.manual-overrides__status[data-state="pending"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.manual-overrides__search {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
@@ -1849,6 +1886,16 @@ button.step-indicator__item:focus-visible {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.manual-overrides__header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.manual-overrides__header-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.entity-tabs__nav {
|
||||
flex-direction: column;
|
||||
padding: 0.35rem;
|
||||
|
||||
@@ -21,6 +21,33 @@
|
||||
data-entities-enabled="{{ 'true' if recognition_enabled else 'false' }}">
|
||||
<input type="hidden" name="active_step" value="entities" data-role="active-step-input">
|
||||
|
||||
<div class="wizard-hidden-inputs" aria-hidden="true">
|
||||
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
|
||||
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
|
||||
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
|
||||
{% if pending.generate_epub3 %}
|
||||
<input type="hidden" name="generate_epub3" value="true">
|
||||
{% endif %}
|
||||
{% for chapter in pending.chapters %}
|
||||
{% set idx = loop.index0 %}
|
||||
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
|
||||
{% set selected_option = '__default' %}
|
||||
{% if chapter.voice_profile %}
|
||||
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
|
||||
{% elif chapter.voice %}
|
||||
{% set selected_option = 'voice:' ~ chapter.voice %}
|
||||
{% elif chapter.voice_formula %}
|
||||
{% set selected_option = 'formula' %}
|
||||
{% endif %}
|
||||
{% if is_enabled %}
|
||||
<input type="hidden" name="chapter-{{ idx }}-enabled" value="on">
|
||||
{% endif %}
|
||||
<input type="hidden" name="chapter-{{ idx }}-title" value="{{ chapter.title }}">
|
||||
<input type="hidden" name="chapter-{{ idx }}-voice" value="{{ selected_option }}">
|
||||
<input type="hidden" name="chapter-{{ idx }}-formula" value="{{ chapter.voice_formula or '' }}">
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="modal__body wizard-card__body">
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
@@ -444,8 +471,14 @@
|
||||
hidden>
|
||||
<div class="manual-overrides" data-role="manual-overrides">
|
||||
<header class="manual-overrides__header">
|
||||
<div class="manual-overrides__copy">
|
||||
<h2>Manual overrides</h2>
|
||||
<p class="hint">Search tokens from the book or add custom entries. Set pronunciations and assign voices to ensure previews and conversions use your preferred delivery.</p>
|
||||
</div>
|
||||
<div class="manual-overrides__header-actions">
|
||||
<span class="manual-overrides__status" data-role="manual-override-status" aria-live="polite"></span>
|
||||
<button type="button" class="button button--ghost button--small" data-role="manual-override-save-all">Save overrides</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="manual-overrides__search">
|
||||
<label class="field" for="manual-override-query">
|
||||
|
||||
Reference in New Issue
Block a user