mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
feat: Add filters for entity and people summaries with minimum mention options
This commit is contained in:
@@ -60,12 +60,36 @@ _STOP_LABELS = {
|
||||
"nor",
|
||||
"so",
|
||||
"yet",
|
||||
"dr",
|
||||
"mr",
|
||||
"mrs",
|
||||
"ms",
|
||||
"miss",
|
||||
"sir",
|
||||
"madam",
|
||||
"lady",
|
||||
"lord",
|
||||
}
|
||||
|
||||
_EXCLUDED_NER_LABELS = {
|
||||
"CARDINAL",
|
||||
"DATE",
|
||||
"ORDINAL",
|
||||
"PERCENT",
|
||||
"TIME",
|
||||
"LAW",
|
||||
"MONEY",
|
||||
"QUANTITY",
|
||||
}
|
||||
|
||||
_TITLE_PATTERN = re.compile(r"^(?:" + "|".join(re.escape(prefix) for prefix in _TITLE_PREFIXES) + r")\.?\s+", re.IGNORECASE)
|
||||
_POSSESSIVE_PATTERN = re.compile(r"(?:'s|’s|\u2019s)$", re.IGNORECASE)
|
||||
_NON_WORD_PATTERN = re.compile(r"[^\w\s'-]+")
|
||||
_MULTI_SPACE_PATTERN = re.compile(r"\s+")
|
||||
_SUFFIX_PATTERN = re.compile(
|
||||
r",?\s+(?:jr|sr|ii|iii|iv|v|vi|md|phd|esq|esquire|dds|dvm)\.?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -165,6 +189,7 @@ def _normalize_label(text: str) -> str:
|
||||
if not stripped:
|
||||
return ""
|
||||
stripped = _TITLE_PATTERN.sub("", stripped)
|
||||
stripped = _SUFFIX_PATTERN.sub("", stripped)
|
||||
stripped = _POSSESSIVE_PATTERN.sub("", stripped)
|
||||
stripped = _NON_WORD_PATTERN.sub(" ", stripped)
|
||||
stripped = _MULTI_SPACE_PATTERN.sub(" ", stripped)
|
||||
@@ -252,9 +277,16 @@ def extract_entities(
|
||||
for idx, chapter in enumerate(chapters):
|
||||
text = chapter.get("text") if isinstance(chapter, Mapping) else None
|
||||
text_value = str(text or "")
|
||||
chapter_texts.append((idx, text_value))
|
||||
original_index = idx
|
||||
if isinstance(chapter, Mapping):
|
||||
try:
|
||||
original_index = int(chapter.get("index", idx))
|
||||
except (TypeError, ValueError):
|
||||
original_index = idx
|
||||
chapter_texts.append((original_index, text_value))
|
||||
if text_value:
|
||||
combined_hasher.update(text_value.encode("utf-8", "ignore"))
|
||||
combined_hasher.update(str(original_index).encode("utf-8", "ignore"))
|
||||
cache_key = combined_hasher.hexdigest()
|
||||
|
||||
if not chapter_texts:
|
||||
@@ -279,6 +311,8 @@ def extract_entities(
|
||||
|
||||
def _register_span(span: Any, category_hint: Optional[str] = None) -> None:
|
||||
nonlocal processed_tokens
|
||||
if category_hint is None and span.label_ in _EXCLUDED_NER_LABELS:
|
||||
return
|
||||
cleaned = _normalize_label(span.text)
|
||||
if not cleaned:
|
||||
return
|
||||
@@ -346,6 +380,8 @@ def extract_entities(
|
||||
"tokens": processed_tokens,
|
||||
"chapters": len(chapter_texts),
|
||||
"processed": True,
|
||||
"people": len(people_payload),
|
||||
"entities": len(entity_payload),
|
||||
},
|
||||
"model": {
|
||||
"name": getattr(nlp, "meta", {}).get("name", "unknown"),
|
||||
|
||||
+10
-1
@@ -1115,7 +1115,16 @@ def _refresh_entity_summary(pending: PendingJob, chapters: Iterable[Mapping[str,
|
||||
return
|
||||
|
||||
language = pending.language or "en"
|
||||
result = extract_entities(chapters, language=language)
|
||||
chapter_list: List[Mapping[str, Any]] = [chapter for chapter in chapters if isinstance(chapter, Mapping)]
|
||||
if not chapter_list:
|
||||
pending.entity_summary = {}
|
||||
pending.entity_cache_key = ""
|
||||
pending.pronunciation_overrides = pending.pronunciation_overrides or []
|
||||
return
|
||||
|
||||
enabled_only = [chapter for chapter in chapter_list if chapter.get("enabled")]
|
||||
target_chapters = enabled_only or chapter_list
|
||||
result = extract_entities(target_chapters, language=language)
|
||||
summary = dict(result.summary)
|
||||
tokens: List[str] = []
|
||||
for group in ("people", "entities"):
|
||||
|
||||
+392
-40
@@ -73,6 +73,9 @@ const initPrepare = (root = document) => {
|
||||
const voiceCatalogMap = new Map(voiceCatalog.map((voice) => [voice.id, voice]));
|
||||
|
||||
const sampleIndexState = new WeakMap();
|
||||
const speakerHints = new Map();
|
||||
|
||||
const canonicalizeEntityKey = (value) => (value || "").toLowerCase().replace(/\s+/g, " ").trim();
|
||||
|
||||
const readSpeakerSamples = (speakerItem) => {
|
||||
if (!speakerItem) return [];
|
||||
@@ -111,6 +114,47 @@ const initPrepare = (root = document) => {
|
||||
return normalised;
|
||||
};
|
||||
|
||||
const registerSpeakerHintFromNode = (node) => {
|
||||
if (!node) return;
|
||||
const nameNode = node.querySelector(".speaker-list__name");
|
||||
const label = nameNode?.textContent || "";
|
||||
const key = canonicalizeEntityKey(label);
|
||||
if (!key) return;
|
||||
const genderInput = node.querySelector('[data-role="gender-input"]');
|
||||
const voiceSelect = node.querySelector('[data-role="speaker-voice"]');
|
||||
const formulaInput = node.querySelector('[data-role="speaker-formula"]');
|
||||
let resolvedVoice = "";
|
||||
if (voiceSelect) {
|
||||
const selectedValue = voiceSelect.value || voiceSelect.dataset.prevManual || "";
|
||||
if (selectedValue && selectedValue !== "__custom_mix") {
|
||||
resolvedVoice = selectedValue;
|
||||
} else if (voiceSelect.dataset.defaultVoice) {
|
||||
resolvedVoice = voiceSelect.dataset.defaultVoice;
|
||||
} else if (form.dataset.baseVoice) {
|
||||
resolvedVoice = form.dataset.baseVoice;
|
||||
}
|
||||
} else if (form.dataset.baseVoice) {
|
||||
resolvedVoice = form.dataset.baseVoice;
|
||||
}
|
||||
if (!resolvedVoice && formulaInput?.value?.trim()) {
|
||||
const formula = formulaInput.value.trim();
|
||||
const firstTerm = formula.split("+")[0] || "";
|
||||
const [voiceId] = firstTerm.split("*");
|
||||
if (voiceId) {
|
||||
resolvedVoice = voiceId.trim();
|
||||
}
|
||||
}
|
||||
speakerHints.set(key, {
|
||||
gender: (genderInput?.value || "unknown").toLowerCase(),
|
||||
voice: resolvedVoice,
|
||||
});
|
||||
};
|
||||
|
||||
const rebuildSpeakerHints = () => {
|
||||
speakerHints.clear();
|
||||
form.querySelectorAll(".speaker-list__item").forEach((item) => registerSpeakerHintFromNode(item));
|
||||
};
|
||||
|
||||
const getPronunciationText = (container) => {
|
||||
if (!container) return "";
|
||||
const input = container.querySelector('[data-role="speaker-pronunciation"]');
|
||||
@@ -411,6 +455,7 @@ const initPrepare = (root = document) => {
|
||||
target.value = previous;
|
||||
}
|
||||
updatePreviewVoice(target);
|
||||
registerSpeakerHintFromNode(container);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -430,6 +475,9 @@ const initPrepare = (root = document) => {
|
||||
target.dataset.prevManual = target.value || "";
|
||||
updatePreviewVoice(target);
|
||||
delete target.dataset.suppressFormulaClear;
|
||||
if (container) {
|
||||
registerSpeakerHintFromNode(container);
|
||||
}
|
||||
});
|
||||
updatePreviewVoice(select);
|
||||
});
|
||||
@@ -443,7 +491,9 @@ const initPrepare = (root = document) => {
|
||||
pronunciationInput.addEventListener("input", sync);
|
||||
pronunciationInput.addEventListener("change", sync);
|
||||
}
|
||||
registerSpeakerHintFromNode(item);
|
||||
});
|
||||
rebuildSpeakerHints();
|
||||
|
||||
const activeStepInput = form.querySelector('[data-role="active-step-input"]');
|
||||
const analysisButtons = Array.from(form.querySelectorAll('[data-role="submit-speaker-analysis"]'));
|
||||
@@ -588,6 +638,7 @@ const initPrepare = (root = document) => {
|
||||
|
||||
updatePreviewVoice(select);
|
||||
delete select.dataset.suppressFormulaClear;
|
||||
registerSpeakerHintFromNode(speakerItem);
|
||||
};
|
||||
|
||||
const hideGenderMenus = () => {
|
||||
@@ -620,6 +671,8 @@ const initPrepare = (root = document) => {
|
||||
option.removeAttribute("data-state");
|
||||
}
|
||||
});
|
||||
const speakerItem = genderContainer.closest(".speaker-list__item");
|
||||
registerSpeakerHintFromNode(speakerItem);
|
||||
};
|
||||
|
||||
Array.from(form.querySelectorAll('[data-role="speaker-gender"]')).forEach((container) => {
|
||||
@@ -1071,10 +1124,18 @@ const initPrepare = (root = document) => {
|
||||
cacheKey: typeof entityCacheKeyData === "string" ? entityCacheKeyData : "",
|
||||
manualOverrides: Array.isArray(manualOverridesSeed) ? [...manualOverridesSeed] : [],
|
||||
pronunciationOverrides: Array.isArray(pronunciationOverridesSeed) ? [...pronunciationOverridesSeed] : [],
|
||||
filters: {
|
||||
people: 0,
|
||||
entities: 0,
|
||||
},
|
||||
};
|
||||
|
||||
let highlightedOverrideId = "";
|
||||
const pendingOverrideSaves = new Map();
|
||||
const dirtyOverrideIds = new Set();
|
||||
let activeEntityPanel = "";
|
||||
let overrideFlushPromise = null;
|
||||
let markOverrideDirty = () => {};
|
||||
let flushManualOverrides = () => null;
|
||||
|
||||
if (entityTabs) {
|
||||
const tabButtons = Array.from(entityTabs.querySelectorAll('[data-role="entity-tab"]'));
|
||||
@@ -1082,10 +1143,16 @@ const initPrepare = (root = document) => {
|
||||
Array.from(entityTabs.querySelectorAll('[data-role="entity-panel"]')).map((panel) => [panel.dataset.panel || "", panel])
|
||||
);
|
||||
|
||||
const entitySummaryContainer = entityTabs.querySelector('[data-role="entity-summary"]');
|
||||
const peopleSummaryContainer = entityTabs.querySelector('[data-role="people-summary"]');
|
||||
const peopleStatsNode = peopleSummaryContainer?.querySelector('[data-role="people-stats"]');
|
||||
const peopleListNode = peopleSummaryContainer?.querySelector('[data-role="entity-list-people"]');
|
||||
const peopleFilterNode = peopleSummaryContainer?.querySelector('[data-role="entity-filter-people"]');
|
||||
|
||||
const entitySummaryContainer = entityTabs.querySelector('[data-role="entities-summary"]');
|
||||
const entityStatsNode = entitySummaryContainer?.querySelector('[data-role="entity-stats"]');
|
||||
const entityListNode = entitySummaryContainer?.querySelector('[data-role="entity-list"]');
|
||||
const entityRowTemplate = entitySummaryContainer?.querySelector('template[data-role="entity-row-template"]');
|
||||
const entityListNode = entitySummaryContainer?.querySelector('[data-role="entity-list-entities"]');
|
||||
const entitiesFilterNode = entitySummaryContainer?.querySelector('[data-role="entity-filter-entities"]');
|
||||
const entityRowTemplate = entityTabs.querySelector('template[data-role="entity-row-template"]');
|
||||
const entitiesRefreshButton = entitySummaryContainer?.querySelector('[data-role="entities-refresh"]');
|
||||
|
||||
const manualOverridesRoot = entityTabs.querySelector('[data-role="manual-overrides"]');
|
||||
@@ -1102,6 +1169,25 @@ const initPrepare = (root = document) => {
|
||||
entitiesRefreshButton.setAttribute("aria-disabled", entitiesEnabled ? "false" : "true");
|
||||
}
|
||||
|
||||
const parseThreshold = (value) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
||||
};
|
||||
|
||||
if (peopleFilterNode) {
|
||||
peopleFilterNode.addEventListener("change", () => {
|
||||
entityState.filters.people = parseThreshold(peopleFilterNode.value);
|
||||
renderEntitySummary();
|
||||
});
|
||||
}
|
||||
|
||||
if (entitiesFilterNode) {
|
||||
entitiesFilterNode.addEventListener("change", () => {
|
||||
entityState.filters.entities = parseThreshold(entitiesFilterNode.value);
|
||||
renderEntitySummary();
|
||||
});
|
||||
}
|
||||
|
||||
const cloneTemplate = (template) => {
|
||||
if (!template) return null;
|
||||
if (template.content && template.content.firstElementChild) {
|
||||
@@ -1116,6 +1202,9 @@ const initPrepare = (root = document) => {
|
||||
};
|
||||
|
||||
function activateEntityTab(panelKey) {
|
||||
if (activeEntityPanel === "manual" && panelKey !== activeEntityPanel) {
|
||||
void flushManualOverrides();
|
||||
}
|
||||
tabButtons.forEach((button) => {
|
||||
const isActive = button.dataset.panel === panelKey;
|
||||
button.classList.toggle("is-active", isActive);
|
||||
@@ -1128,6 +1217,7 @@ const initPrepare = (root = document) => {
|
||||
panel.hidden = !isActive;
|
||||
panel.setAttribute("aria-hidden", isActive ? "false" : "true");
|
||||
});
|
||||
activeEntityPanel = panelKey;
|
||||
}
|
||||
|
||||
function populateVoiceOptions(select, selectedVoice) {
|
||||
@@ -1163,22 +1253,60 @@ const initPrepare = (root = document) => {
|
||||
}
|
||||
|
||||
function renderEntitySummary() {
|
||||
if (!entityListNode) return;
|
||||
if (!entitiesEnabled) {
|
||||
if (entityStatsNode) {
|
||||
entityStatsNode.textContent = "Entity recognition is turned off in Settings.";
|
||||
}
|
||||
entityListNode.innerHTML = "";
|
||||
const emptyItem = document.createElement("li");
|
||||
emptyItem.className = "entity-summary__empty";
|
||||
emptyItem.textContent = "Enable entity recognition to populate detected entities.";
|
||||
entityListNode.appendChild(emptyItem);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = entityState.summary || {};
|
||||
const stats = summary.stats || {};
|
||||
const errors = Array.isArray(summary.errors) ? summary.errors : [];
|
||||
const peopleEntries = Array.isArray(summary.people) ? summary.people : [];
|
||||
const entityEntries = Array.isArray(summary.entities) ? summary.entities : [];
|
||||
const peopleThreshold = Number.parseInt(entityState.filters?.people, 10) || 0;
|
||||
const entityThreshold = Number.parseInt(entityState.filters?.entities, 10) || 0;
|
||||
|
||||
const ensureSelectValue = (node, value) => {
|
||||
if (!node) return;
|
||||
const target = String(value);
|
||||
if (node.value !== target) {
|
||||
node.value = target;
|
||||
}
|
||||
};
|
||||
|
||||
ensureSelectValue(peopleFilterNode, peopleThreshold);
|
||||
ensureSelectValue(entitiesFilterNode, entityThreshold);
|
||||
|
||||
const renderDisabled = (listNode, message) => {
|
||||
if (!listNode) return;
|
||||
listNode.innerHTML = "";
|
||||
const emptyItem = document.createElement("li");
|
||||
emptyItem.className = "entity-summary__empty";
|
||||
emptyItem.textContent = message;
|
||||
listNode.appendChild(emptyItem);
|
||||
};
|
||||
|
||||
if (!entitiesEnabled) {
|
||||
const disabledMessage = "Entity recognition is turned off in Settings.";
|
||||
if (entityStatsNode) {
|
||||
entityStatsNode.textContent = disabledMessage;
|
||||
}
|
||||
if (peopleStatsNode) {
|
||||
peopleStatsNode.textContent = disabledMessage;
|
||||
}
|
||||
if (peopleFilterNode) {
|
||||
peopleFilterNode.disabled = true;
|
||||
}
|
||||
if (entitiesFilterNode) {
|
||||
entitiesFilterNode.disabled = true;
|
||||
}
|
||||
renderDisabled(peopleListNode, disabledMessage);
|
||||
renderDisabled(entityListNode, "Enable entity recognition to populate detected entities.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (peopleFilterNode) {
|
||||
peopleFilterNode.disabled = false;
|
||||
}
|
||||
if (entitiesFilterNode) {
|
||||
entitiesFilterNode.disabled = false;
|
||||
}
|
||||
|
||||
if (entityStatsNode) {
|
||||
if (errors.length) {
|
||||
entityStatsNode.textContent = errors.join(" · ");
|
||||
@@ -1190,28 +1318,38 @@ const initPrepare = (root = document) => {
|
||||
if (typeof stats.tokens === "number") {
|
||||
parts.push(`${stats.tokens.toLocaleString()} tokens processed`);
|
||||
}
|
||||
if (typeof stats.people === "number") {
|
||||
parts.push(`${stats.people} character${stats.people === 1 ? "" : "s"}`);
|
||||
}
|
||||
if (typeof stats.entities === "number") {
|
||||
parts.push(`${stats.entities} ${stats.entities === 1 ? "entity" : "entities"}`);
|
||||
}
|
||||
entityStatsNode.textContent = parts.length ? parts.join(" · ") : "Entity analysis up to date.";
|
||||
} else {
|
||||
entityStatsNode.textContent = "Entity analysis will populate once you continue from chapters.";
|
||||
}
|
||||
}
|
||||
|
||||
entityListNode.innerHTML = "";
|
||||
const entries = Array.isArray(summary.entities) ? summary.entities : [];
|
||||
if (!entries.length) {
|
||||
const renderGroup = (listNode, entries, threshold, options) => {
|
||||
if (!listNode) {
|
||||
return { visible: 0, total: entries.length };
|
||||
}
|
||||
listNode.innerHTML = "";
|
||||
const filtered = entries.filter((entry) => Number(entry?.count || 0) >= threshold);
|
||||
if (!filtered.length) {
|
||||
const emptyItem = document.createElement("li");
|
||||
emptyItem.className = "entity-summary__empty";
|
||||
emptyItem.textContent = "No entities detected yet.";
|
||||
entityListNode.appendChild(emptyItem);
|
||||
return;
|
||||
emptyItem.textContent = entries.length ? options.filteredEmptyText : options.emptyText;
|
||||
listNode.appendChild(emptyItem);
|
||||
return { visible: 0, total: entries.length };
|
||||
}
|
||||
|
||||
entries.forEach((entity) => {
|
||||
filtered.forEach((entity) => {
|
||||
const item = cloneTemplate(entityRowTemplate);
|
||||
if (!item) return;
|
||||
const normalized = entity.normalized || entity.label || entity.token || "";
|
||||
const tokenLabel = entity.label || entity.token || normalized || "Untitled entity";
|
||||
item.dataset.entityId = entity.id || normalized || tokenLabel;
|
||||
item.dataset.entityCategory = options.groupKey;
|
||||
|
||||
const labelEl = item.querySelector('[data-role="entity-label"]');
|
||||
if (labelEl) {
|
||||
@@ -1221,8 +1359,13 @@ const initPrepare = (root = document) => {
|
||||
const kindEl = item.querySelector('[data-role="entity-kind"]');
|
||||
if (kindEl) {
|
||||
const kind = entity.kind || entity.category || "";
|
||||
if (options.hideKind || !kind) {
|
||||
kindEl.textContent = "";
|
||||
kindEl.hidden = true;
|
||||
} else {
|
||||
kindEl.hidden = false;
|
||||
kindEl.textContent = kind;
|
||||
kindEl.hidden = !kind;
|
||||
}
|
||||
}
|
||||
|
||||
const countEl = item.querySelector('[data-role="entity-count"]');
|
||||
@@ -1261,6 +1404,8 @@ const initPrepare = (root = document) => {
|
||||
});
|
||||
button.dataset.entityToken = entity.label || entity.token || "";
|
||||
button.dataset.entityNormalized = normalized;
|
||||
button.dataset.entityCategory = options.groupKey;
|
||||
button.dataset.entityCount = String(entity.count || 0);
|
||||
const sampleContext = Array.isArray(entity.samples) && entity.samples.length
|
||||
? typeof entity.samples[0] === "string"
|
||||
? entity.samples[0]
|
||||
@@ -1269,11 +1414,66 @@ const initPrepare = (root = document) => {
|
||||
if (sampleContext) {
|
||||
button.dataset.entityContext = sampleContext;
|
||||
}
|
||||
if (entity.kind) {
|
||||
button.dataset.entityKind = entity.kind;
|
||||
}
|
||||
button.textContent = hasOverride ? "Edit manual override" : "Add manual override";
|
||||
}
|
||||
|
||||
entityListNode.appendChild(item);
|
||||
listNode.appendChild(item);
|
||||
});
|
||||
return { visible: filtered.length, total: entries.length };
|
||||
};
|
||||
|
||||
const peopleRender = renderGroup(peopleListNode, peopleEntries, peopleThreshold, {
|
||||
groupKey: "people",
|
||||
hideKind: true,
|
||||
emptyText: "No characters detected yet.",
|
||||
filteredEmptyText: "No characters match the selected mention filter.",
|
||||
});
|
||||
|
||||
if (peopleFilterNode && !peopleEntries.length) {
|
||||
peopleFilterNode.value = "0";
|
||||
}
|
||||
|
||||
if (peopleStatsNode) {
|
||||
if (!peopleEntries.length) {
|
||||
peopleStatsNode.textContent = "No characters detected yet.";
|
||||
} else if (!peopleRender.visible) {
|
||||
peopleStatsNode.textContent = "Adjust the mention filter to see additional characters.";
|
||||
} else {
|
||||
let label = "all mentions";
|
||||
if (peopleThreshold > 1) {
|
||||
label = `${peopleThreshold}+ mentions`;
|
||||
} else if (peopleThreshold === 1) {
|
||||
label = "1+ mention";
|
||||
}
|
||||
peopleStatsNode.textContent = `Showing ${peopleRender.visible} of ${peopleRender.total} characters (${label}).`;
|
||||
}
|
||||
}
|
||||
|
||||
const entitiesRender = renderGroup(entityListNode, entityEntries, entityThreshold, {
|
||||
groupKey: "entities",
|
||||
hideKind: false,
|
||||
emptyText: "No entities detected yet.",
|
||||
filteredEmptyText: "No entities match the selected mention filter.",
|
||||
});
|
||||
|
||||
if (entitiesFilterNode && !entityEntries.length) {
|
||||
entitiesFilterNode.value = "0";
|
||||
}
|
||||
|
||||
if (
|
||||
entityStatsNode &&
|
||||
!errors.length &&
|
||||
stats.processed &&
|
||||
typeof stats.entities === "number" &&
|
||||
entityThreshold > 0 &&
|
||||
stats.entities > entitiesRender.visible
|
||||
) {
|
||||
const filterLabel = entityThreshold > 1 ? `${entityThreshold}+ mentions` : "1+ mention";
|
||||
entityStatsNode.textContent += ` · Filter hiding ${stats.entities - entitiesRender.visible} entries (${filterLabel})`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderManualOverrides() {
|
||||
@@ -1400,6 +1600,87 @@ const initPrepare = (root = document) => {
|
||||
renderManualOverrides();
|
||||
}
|
||||
|
||||
const filterVoicesByGender = (voices, genderHint) => {
|
||||
const normalized = (genderHint || "unknown").toLowerCase();
|
||||
if (normalized === "female") {
|
||||
return voices.filter((voice) => (voice.gender_code || "").toLowerCase() === "f");
|
||||
}
|
||||
if (normalized === "male") {
|
||||
return voices.filter((voice) => (voice.gender_code || "").toLowerCase() === "m");
|
||||
}
|
||||
if (normalized === "either") {
|
||||
return voices.filter((voice) => {
|
||||
const code = (voice.gender_code || "").toLowerCase();
|
||||
return code === "f" || code === "m";
|
||||
});
|
||||
}
|
||||
return voices.slice();
|
||||
};
|
||||
|
||||
const pickRandomVoiceForOverride = (genderHint) => {
|
||||
if (!Array.isArray(voiceCatalog) || !voiceCatalog.length) {
|
||||
return baseVoice || "";
|
||||
}
|
||||
const normalizedLanguage = (languageCode || "").trim().toLowerCase();
|
||||
const languageCandidates = () => {
|
||||
const keys = [];
|
||||
if (normalizedLanguage) keys.push(normalizedLanguage);
|
||||
if (languageCode && languageCode !== normalizedLanguage) {
|
||||
keys.push(languageCode);
|
||||
}
|
||||
for (const key of keys) {
|
||||
const mapped = languageMap?.[key];
|
||||
if (Array.isArray(mapped) && mapped.length) {
|
||||
const lookup = new Set(mapped);
|
||||
const matches = voiceCatalog.filter((voice) => lookup.has(voice.id));
|
||||
if (matches.length) {
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
}
|
||||
const direct = voiceCatalog.filter((voice) => (voice.language || "").toLowerCase() === normalizedLanguage);
|
||||
return direct;
|
||||
};
|
||||
|
||||
const preferred = languageCandidates();
|
||||
let pool = filterVoicesByGender(preferred.length ? preferred : voiceCatalog, genderHint);
|
||||
if (!pool.length) {
|
||||
pool = voiceCatalog.slice();
|
||||
}
|
||||
if (!pool.length) {
|
||||
return baseVoice || "";
|
||||
}
|
||||
const selected = pool[Math.floor(Math.random() * pool.length)];
|
||||
return selected?.id || baseVoice || "";
|
||||
};
|
||||
|
||||
const resolveOverrideVoice = (data) => {
|
||||
if (data.voice) {
|
||||
return data.voice;
|
||||
}
|
||||
const normalizedKey = canonicalizeEntityKey(data.normalized || data.token || "");
|
||||
let genderHint = (data.gender || "").toLowerCase();
|
||||
if (normalizedKey) {
|
||||
const speakerHint = speakerHints.get(normalizedKey);
|
||||
if (speakerHint) {
|
||||
if (speakerHint.voice) {
|
||||
return speakerHint.voice;
|
||||
}
|
||||
if (!genderHint || genderHint === "unknown") {
|
||||
genderHint = speakerHint.gender || genderHint;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!genderHint || genderHint === "unknown") {
|
||||
if (data.category === "people" || data.kind === "PERSON") {
|
||||
genderHint = "either";
|
||||
} else {
|
||||
genderHint = "";
|
||||
}
|
||||
}
|
||||
return pickRandomVoiceForOverride(genderHint);
|
||||
};
|
||||
|
||||
function collectOverridePayload(overrideId) {
|
||||
if (!overrideId || !manualOverrideList) return null;
|
||||
const selectorId = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(overrideId) : overrideId.replace(/["\\]/g, "\\$&");
|
||||
@@ -1420,7 +1701,7 @@ const initPrepare = (root = document) => {
|
||||
|
||||
async function saveManualOverride(overrideId) {
|
||||
const payload = collectOverridePayload(overrideId);
|
||||
if (!payload || !manualUpsertUrl) return;
|
||||
if (!payload || !manualUpsertUrl) return false;
|
||||
try {
|
||||
const response = await fetch(manualUpsertUrl, {
|
||||
method: "POST",
|
||||
@@ -1432,25 +1713,52 @@ const initPrepare = (root = document) => {
|
||||
}
|
||||
const data = await response.json();
|
||||
applyEntityPayload(data, { highlightId: data.override?.id || payload.id });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save manual override", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleManualOverrideSave(overrideId) {
|
||||
markOverrideDirty = (overrideId) => {
|
||||
if (!overrideId) return;
|
||||
if (pendingOverrideSaves.has(overrideId)) {
|
||||
clearTimeout(pendingOverrideSaves.get(overrideId));
|
||||
dirtyOverrideIds.add(overrideId);
|
||||
};
|
||||
|
||||
flushManualOverrides = (options = {}) => {
|
||||
const { overrideId } = options || {};
|
||||
const pendingIds = Array.isArray(overrideId) ? overrideId : overrideId ? [overrideId] : Array.from(dirtyOverrideIds);
|
||||
const targetIds = pendingIds.filter((id) => dirtyOverrideIds.has(id));
|
||||
if (!targetIds.length) return null;
|
||||
targetIds.forEach((id) => dirtyOverrideIds.delete(id));
|
||||
const runFlush = async () => {
|
||||
if (overrideFlushPromise) {
|
||||
try {
|
||||
await overrideFlushPromise;
|
||||
} catch (error) {
|
||||
console.warn("Previous override flush failed", error);
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
pendingOverrideSaves.delete(overrideId);
|
||||
saveManualOverride(overrideId);
|
||||
}, 400);
|
||||
pendingOverrideSaves.set(overrideId, timer);
|
||||
}
|
||||
const results = await Promise.all(targetIds.map((id) => saveManualOverride(id)));
|
||||
results.forEach((ok, index) => {
|
||||
if (!ok) {
|
||||
dirtyOverrideIds.add(targetIds[index]);
|
||||
}
|
||||
});
|
||||
};
|
||||
overrideFlushPromise = runFlush()
|
||||
.catch((error) => {
|
||||
console.error("Failed flushing manual overrides", error);
|
||||
})
|
||||
.finally(() => {
|
||||
overrideFlushPromise = null;
|
||||
});
|
||||
return overrideFlushPromise;
|
||||
};
|
||||
|
||||
async function deleteManualOverride(overrideId) {
|
||||
if (!overrideId || !manualDeleteUrlTemplate) return;
|
||||
dirtyOverrideIds.delete(overrideId);
|
||||
const targetUrl = manualDeleteUrlTemplate.replace("__OVERRIDE_ID__", encodeURIComponent(overrideId));
|
||||
try {
|
||||
const response = await fetch(targetUrl, { method: "DELETE" });
|
||||
@@ -1543,12 +1851,14 @@ const initPrepare = (root = document) => {
|
||||
if (!data || !manualUpsertUrl) return;
|
||||
const token = (data.token || "").trim();
|
||||
if (!token) return;
|
||||
const normalized = (data.normalized || "").trim();
|
||||
const voiceChoice = resolveOverrideVoice({ ...data, token, normalized });
|
||||
const payload = {
|
||||
token,
|
||||
normalized: (data.normalized || "").trim(),
|
||||
normalized,
|
||||
context: data.context || "",
|
||||
pronunciation: data.pronunciation || "",
|
||||
voice: data.voice || "",
|
||||
voice: voiceChoice || "",
|
||||
source: data.source || "manual",
|
||||
};
|
||||
try {
|
||||
@@ -1580,6 +1890,7 @@ const initPrepare = (root = document) => {
|
||||
|
||||
if (entitiesRefreshButton) {
|
||||
entitiesRefreshButton.addEventListener("click", () => {
|
||||
void flushManualOverrides();
|
||||
performEntitiesRefresh(true);
|
||||
});
|
||||
}
|
||||
@@ -1593,6 +1904,8 @@ const initPrepare = (root = document) => {
|
||||
token: trigger.dataset.entityToken || "",
|
||||
normalized: trigger.dataset.entityNormalized || "",
|
||||
context: trigger.dataset.entityContext || "",
|
||||
category: trigger.dataset.entityCategory || "",
|
||||
kind: trigger.dataset.entityKind || "",
|
||||
source: "entity",
|
||||
});
|
||||
});
|
||||
@@ -1649,7 +1962,7 @@ const initPrepare = (root = document) => {
|
||||
previewButton.dataset.previewText = input.value.trim() || input.placeholder || "";
|
||||
}
|
||||
if (overrideId) {
|
||||
scheduleManualOverrideSave(overrideId);
|
||||
markOverrideDirty(overrideId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1663,11 +1976,15 @@ const initPrepare = (root = document) => {
|
||||
previewButton.dataset.voice = select.value || baseVoice || select.dataset.defaultVoice || "";
|
||||
}
|
||||
if (overrideId) {
|
||||
scheduleManualOverrideSave(overrideId);
|
||||
markOverrideDirty(overrideId);
|
||||
}
|
||||
});
|
||||
|
||||
manualOverrideList.addEventListener("click", (event) => {
|
||||
const previewButton = event.target.closest('[data-role="speaker-preview"]');
|
||||
if (previewButton?.dataset.overrideId) {
|
||||
void flushManualOverrides({ overrideId: previewButton.dataset.overrideId });
|
||||
}
|
||||
const deleteButton = event.target.closest('[data-role="manual-override-delete"]');
|
||||
if (!deleteButton) return;
|
||||
event.preventDefault();
|
||||
@@ -1683,7 +2000,42 @@ const initPrepare = (root = document) => {
|
||||
renderManualOverrides();
|
||||
}
|
||||
|
||||
const handleDeferredSubmit = (event) => {
|
||||
const hasDirty = dirtyOverrideIds.size > 0;
|
||||
const hasPendingFlush = Boolean(overrideFlushPromise);
|
||||
if (!hasDirty && !hasPendingFlush) return;
|
||||
if (form.dataset.skipDirtyFlush === "true") {
|
||||
delete form.dataset.skipDirtyFlush;
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const submitter = event.submitter || null;
|
||||
const resumeSubmission = () => {
|
||||
form.dataset.skipDirtyFlush = "true";
|
||||
if (typeof form.requestSubmit === "function") {
|
||||
form.requestSubmit(submitter || undefined);
|
||||
} else if (submitter && typeof submitter.click === "function") {
|
||||
submitter.click();
|
||||
} else if (typeof form.submit === "function") {
|
||||
form.submit();
|
||||
}
|
||||
};
|
||||
|
||||
const flushPromise = overrideFlushPromise || flushManualOverrides();
|
||||
if (!flushPromise) {
|
||||
resumeSubmission();
|
||||
return;
|
||||
}
|
||||
flushPromise.finally(resumeSubmission);
|
||||
};
|
||||
|
||||
form.addEventListener("submit", handleDeferredSubmit);
|
||||
|
||||
form.addEventListener("click", (event) => {
|
||||
const previewFromOverride = event.target.closest('[data-role="speaker-preview"]');
|
||||
if (previewFromOverride?.dataset.overrideId) {
|
||||
void flushManualOverrides({ overrideId: previewFromOverride.dataset.overrideId });
|
||||
}
|
||||
const genderMenu = event.target.closest('[data-role="gender-menu"]');
|
||||
const genderPill = event.target.closest('[data-role="gender-pill"]');
|
||||
if (!genderMenu && !genderPill) {
|
||||
|
||||
@@ -1719,6 +1719,24 @@ button.step-indicator__item:focus-visible {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.entity-summary__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.entity-summary__filters .field,
|
||||
.entity-summary__filters .button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.field--inline {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.entity-summary__titles h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -332,6 +332,29 @@
|
||||
{% else %}
|
||||
<p class="hint">No additional speakers detected yet. The narrator voice will be used for all dialogue.</p>
|
||||
{% endif %}
|
||||
|
||||
<section class="entity-summary entity-summary--people" data-role="people-summary">
|
||||
<header class="entity-summary__header">
|
||||
<div class="entity-summary__titles">
|
||||
<h2>Detected people</h2>
|
||||
<p class="hint">Characters surfaced by entity recognition. Filter by mention count to focus on recurring names.</p>
|
||||
</div>
|
||||
<div class="entity-summary__filters">
|
||||
<label class="field field--inline" for="people-mention-filter">
|
||||
<span>Minimum mentions</span>
|
||||
<select id="people-mention-filter" data-role="entity-filter-people">
|
||||
<option value="0">All</option>
|
||||
<option value="1">1+</option>
|
||||
<option value="2">2+</option>
|
||||
<option value="5">5+</option>
|
||||
<option value="10">10+</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
<dl class="entity-summary__stats" data-role="people-stats"></dl>
|
||||
<ol class="entity-summary__list" data-role="entity-list-people"></ol>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="entity-tabs__panel"
|
||||
@@ -341,16 +364,28 @@
|
||||
role="tabpanel"
|
||||
aria-labelledby="entity-tab-entities"
|
||||
hidden>
|
||||
<div class="entity-summary" data-role="entity-summary">
|
||||
<div class="entity-summary" data-role="entities-summary">
|
||||
<header class="entity-summary__header">
|
||||
<div class="entity-summary__titles">
|
||||
<h2>Detected entities</h2>
|
||||
<p class="hint">Assign pronunciations and voices for recurring names and terminology across the manuscript.</p>
|
||||
</div>
|
||||
<div class="entity-summary__filters">
|
||||
<label class="field field--inline" for="entities-mention-filter">
|
||||
<span>Minimum mentions</span>
|
||||
<select id="entities-mention-filter" data-role="entity-filter-entities">
|
||||
<option value="0">All</option>
|
||||
<option value="1">1+</option>
|
||||
<option value="2">2+</option>
|
||||
<option value="5">5+</option>
|
||||
<option value="10">10+</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="button button--ghost button--small" data-role="entities-refresh" {% if not recognition_enabled %}disabled aria-disabled="true"{% endif %}>Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
<dl class="entity-summary__stats" data-role="entity-stats"></dl>
|
||||
<ol class="entity-summary__list" data-role="entity-list"></ol>
|
||||
<ol class="entity-summary__list" data-role="entity-list-entities"></ol>
|
||||
<template data-role="entity-row-template">
|
||||
<li class="entity-summary__item">
|
||||
<header class="entity-summary__item-header">
|
||||
|
||||
Reference in New Issue
Block a user