mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
feat: Enhance entity processing and UI with new filters and manual override options
This commit is contained in:
@@ -362,7 +362,14 @@ def extract_entities(
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
people_records = [record for record in records.values() if record.category == "people"]
|
||||
entity_records = [record for record in records.values() if record.category == "entities"]
|
||||
people_keys = {record.key[1] for record in people_records}
|
||||
entity_records = [
|
||||
record
|
||||
for record in records.values()
|
||||
if record.category == "entities"
|
||||
and record.key[1] not in people_keys
|
||||
and record.kind != "PERSON"
|
||||
]
|
||||
|
||||
people_records.sort(key=lambda rec: (-rec.count, rec.label))
|
||||
entity_records.sort(key=lambda rec: (-rec.count, rec.label))
|
||||
|
||||
+123
-4
@@ -1509,6 +1509,94 @@ def _apply_prepare_form(
|
||||
apply_config_requested,
|
||||
persist_config_requested,
|
||||
)
|
||||
|
||||
|
||||
def _apply_book_step_form(
|
||||
pending: PendingJob,
|
||||
form: Mapping[str, Any],
|
||||
*,
|
||||
settings: Mapping[str, Any],
|
||||
profiles: Mapping[str, Any],
|
||||
) -> None:
|
||||
language_fallback = pending.language or settings.get("language", "en")
|
||||
raw_language = (form.get("language") or language_fallback or "en").strip()
|
||||
if raw_language:
|
||||
pending.language = raw_language
|
||||
|
||||
subtitle_mode = (form.get("subtitle_mode") or pending.subtitle_mode or "Disabled").strip()
|
||||
if subtitle_mode:
|
||||
pending.subtitle_mode = subtitle_mode
|
||||
|
||||
pending.generate_epub3 = _coerce_bool(form.get("generate_epub3"), bool(pending.generate_epub3))
|
||||
|
||||
chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower()
|
||||
raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or chunk_level_default).strip().lower()
|
||||
if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
|
||||
raw_chunk_level = chunk_level_default if chunk_level_default in _CHUNK_LEVEL_VALUES else (pending.chunk_level or "paragraph")
|
||||
pending.chunk_level = raw_chunk_level
|
||||
|
||||
threshold_default = pending.speaker_analysis_threshold or settings.get("speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD)
|
||||
raw_threshold = form.get("speaker_analysis_threshold")
|
||||
if raw_threshold is not None:
|
||||
pending.speaker_analysis_threshold = _coerce_int(
|
||||
raw_threshold,
|
||||
threshold_default,
|
||||
minimum=1,
|
||||
maximum=25,
|
||||
)
|
||||
|
||||
raw_delay = form.get("chapter_intro_delay")
|
||||
if raw_delay is not None:
|
||||
try:
|
||||
pending.chapter_intro_delay = max(0.0, float(str(raw_delay).strip() or 0.0))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
speed_value = form.get("speed")
|
||||
if speed_value is not None:
|
||||
try:
|
||||
pending.speed = float(speed_value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip()
|
||||
custom_formula_raw = (form.get("voice_formula") or "").strip()
|
||||
narrator_voice = (form.get("voice") or pending.voice or settings.get("default_voice") or "").strip()
|
||||
|
||||
if profile_selection in {"__standard", "", None}:
|
||||
profile_name = ""
|
||||
custom_formula = ""
|
||||
elif profile_selection == "__formula":
|
||||
profile_name = ""
|
||||
custom_formula = custom_formula_raw
|
||||
else:
|
||||
profile_name = profile_selection
|
||||
custom_formula = ""
|
||||
|
||||
profile_map = profiles if isinstance(profiles, dict) else dict(profiles)
|
||||
voice_choice, resolved_language, selected_profile = _resolve_voice_choice(
|
||||
pending.language,
|
||||
narrator_voice,
|
||||
profile_name,
|
||||
custom_formula,
|
||||
profile_map,
|
||||
)
|
||||
|
||||
if resolved_language:
|
||||
pending.language = resolved_language
|
||||
|
||||
if profile_selection == "__formula" and custom_formula_raw:
|
||||
pending.voice = custom_formula_raw
|
||||
pending.voice_profile = None
|
||||
elif profile_selection not in {"__standard", "", None, "__formula"}:
|
||||
pending.voice_profile = selected_profile or profile_selection
|
||||
pending.voice = voice_choice
|
||||
else:
|
||||
pending.voice_profile = None
|
||||
pending.voice = voice_choice or narrator_voice
|
||||
|
||||
pending.applied_speaker_config = (form.get("speaker_config") or "").strip() or None
|
||||
|
||||
_SUPPLEMENT_TITLE_PATTERNS: List[tuple[re.Pattern[str], float]] = [
|
||||
(re.compile(r"\btitle\s+page\b"), 3.0),
|
||||
(re.compile(r"\bcopyright\b"), 2.4),
|
||||
@@ -2617,6 +2705,24 @@ def enqueue_job() -> ResponseReturnValue:
|
||||
|
||||
file = request.files.get("source_file")
|
||||
text_input = request.form.get("source_text", "").strip()
|
||||
pending_id = (request.form.get("pending_id") or "").strip()
|
||||
|
||||
settings = _load_settings()
|
||||
profiles = load_profiles()
|
||||
|
||||
if pending_id and not file and not text_input:
|
||||
pending = service.get_pending_job(pending_id)
|
||||
if not pending:
|
||||
abort(404, "Pending job not found")
|
||||
previous_language = pending.language
|
||||
_apply_book_step_form(pending, request.form, settings=settings, profiles=profiles)
|
||||
setattr(pending, "analysis_requested", False)
|
||||
if pending.language != previous_language:
|
||||
_refresh_entity_summary(pending, pending.chapters)
|
||||
service.store_pending_job(pending)
|
||||
if _wants_wizard_json():
|
||||
return _wizard_json_response(pending, "chapters")
|
||||
return redirect(url_for("web.index"))
|
||||
|
||||
if not file and not text_input:
|
||||
return redirect(url_for("web.index"))
|
||||
@@ -2696,9 +2802,6 @@ def enqueue_job() -> ResponseReturnValue:
|
||||
|
||||
_ensure_at_least_one_chapter_enabled(chapters_payload)
|
||||
|
||||
profiles = load_profiles()
|
||||
settings = _load_settings()
|
||||
|
||||
language = request.form.get("language", "a")
|
||||
base_voice = request.form.get("voice", "af_alloy")
|
||||
profile_selection = (request.form.get("voice_profile") or "__standard").strip()
|
||||
@@ -3216,7 +3319,23 @@ def _wizard_step_payload(
|
||||
active_index = _WIZARD_STEP_ORDER.index(step)
|
||||
except ValueError:
|
||||
active_index = 0
|
||||
completed = [slug for idx, slug in enumerate(_WIZARD_STEP_ORDER) if idx < active_index]
|
||||
max_recorded_index = active_index
|
||||
if pending is not None:
|
||||
stored_index = int(getattr(pending, "wizard_max_step_index", -1))
|
||||
if stored_index < 0:
|
||||
stored_index = -1
|
||||
max_recorded_index = max(active_index, stored_index)
|
||||
max_allowed = len(_WIZARD_STEP_ORDER) - 1
|
||||
if max_recorded_index > max_allowed:
|
||||
max_recorded_index = max_allowed
|
||||
if stored_index != max_recorded_index:
|
||||
pending.wizard_max_step_index = max_recorded_index
|
||||
_service().store_pending_job(pending)
|
||||
else:
|
||||
max_allowed = len(_WIZARD_STEP_ORDER) - 1
|
||||
if max_recorded_index > max_allowed:
|
||||
max_recorded_index = max_allowed
|
||||
completed = [slug for idx, slug in enumerate(_WIZARD_STEP_ORDER) if idx <= max_recorded_index]
|
||||
return {
|
||||
"step": step,
|
||||
"step_index": int(meta.get("index", active_index + 1)),
|
||||
|
||||
@@ -247,6 +247,7 @@ class PendingJob:
|
||||
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
entity_cache_key: Optional[str] = None
|
||||
wizard_max_step_index: int = 0
|
||||
|
||||
|
||||
class ConversionService:
|
||||
|
||||
+338
-36
@@ -1127,15 +1127,18 @@ const initPrepare = (root = document) => {
|
||||
filters: {
|
||||
people: 0,
|
||||
entities: 0,
|
||||
entitiesKind: "all",
|
||||
},
|
||||
};
|
||||
|
||||
let highlightedOverrideId = "";
|
||||
let highlightMode = "manual";
|
||||
const dirtyOverrideIds = new Set();
|
||||
let activeEntityPanel = "";
|
||||
let overrideFlushPromise = null;
|
||||
let markOverrideDirty = () => {};
|
||||
let flushManualOverrides = () => null;
|
||||
let hasTriggeredEntitiesRefresh = false;
|
||||
|
||||
if (entityTabs) {
|
||||
const tabButtons = Array.from(entityTabs.querySelectorAll('[data-role="entity-tab"]'));
|
||||
@@ -1143,17 +1146,19 @@ const initPrepare = (root = document) => {
|
||||
Array.from(entityTabs.querySelectorAll('[data-role="entity-panel"]')).map((panel) => [panel.dataset.panel || "", panel])
|
||||
);
|
||||
|
||||
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 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-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 entitySummaryContainer = entityTabs.querySelector('[data-role="entities-summary"]');
|
||||
const entityStatsNode = entitySummaryContainer?.querySelector('[data-role="entity-stats"]');
|
||||
const entityListNode = entitySummaryContainer?.querySelector('[data-role="entity-list-entities"]');
|
||||
const entitiesFilterNode = entitySummaryContainer?.querySelector('[data-role="entity-filter-entities"]');
|
||||
const entitiesKindFilterNode = entitySummaryContainer?.querySelector('[data-role="entity-filter-kind"]');
|
||||
const entityRowTemplate = entityTabs.querySelector('template[data-role="entity-row-template"]');
|
||||
const entitiesRefreshButton = entitySummaryContainer?.querySelector('[data-role="entities-refresh"]');
|
||||
const entitySpinner = entitySummaryContainer?.querySelector('[data-role="entities-spinner"]');
|
||||
|
||||
const manualOverridesRoot = entityTabs.querySelector('[data-role="manual-overrides"]');
|
||||
const manualOverrideList = manualOverridesRoot?.querySelector('[data-role="manual-override-list"]');
|
||||
@@ -1188,6 +1193,14 @@ const initPrepare = (root = document) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (entitiesKindFilterNode) {
|
||||
entitiesKindFilterNode.addEventListener("change", () => {
|
||||
const value = (entitiesKindFilterNode.value || "all").trim();
|
||||
entityState.filters.entitiesKind = value || "all";
|
||||
renderEntitySummary();
|
||||
});
|
||||
}
|
||||
|
||||
const cloneTemplate = (template) => {
|
||||
if (!template) return null;
|
||||
if (template.content && template.content.firstElementChild) {
|
||||
@@ -1201,6 +1214,75 @@ const initPrepare = (root = document) => {
|
||||
return `${count.toLocaleString()} mention${count === 1 ? "" : "s"}`;
|
||||
};
|
||||
|
||||
const formatEntityKindLabel = (value) => {
|
||||
if (!value) {
|
||||
return "Unknown";
|
||||
}
|
||||
if (/^[A-Z0-9_]+$/.test(value) && value.length <= 4) {
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
const lowerParts = value.toLowerCase().split("_");
|
||||
return lowerParts
|
||||
.map((part, index) => {
|
||||
if (index > 0 && ["of", "and", "the", "in", "on", "at"].includes(part)) {
|
||||
return part;
|
||||
}
|
||||
return part.charAt(0).toUpperCase() + part.slice(1);
|
||||
})
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
const buildOverrideLookup = () => {
|
||||
const map = new Map();
|
||||
const overrides = Array.isArray(entityState.manualOverrides) ? entityState.manualOverrides : [];
|
||||
overrides.forEach((entry) => {
|
||||
if (!entry || typeof entry !== "object") return;
|
||||
const key = canonicalizeEntityKey(entry.normalized || entry.token || "");
|
||||
if (key) {
|
||||
map.set(key, entry);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
const setEntitiesLoading = (isLoading) => {
|
||||
if (!entitySummaryContainer) {
|
||||
return;
|
||||
}
|
||||
if (isLoading) {
|
||||
entitySummaryContainer.dataset.loading = "true";
|
||||
} else {
|
||||
delete entitySummaryContainer.dataset.loading;
|
||||
}
|
||||
if (entitySpinner) {
|
||||
entitySpinner.hidden = !isLoading;
|
||||
entitySpinner.setAttribute("aria-hidden", isLoading ? "false" : "true");
|
||||
}
|
||||
if (entitiesRefreshButton) {
|
||||
const disabled = isLoading || !entitiesEnabled;
|
||||
entitiesRefreshButton.disabled = disabled;
|
||||
entitiesRefreshButton.setAttribute("aria-disabled", disabled ? "true" : "false");
|
||||
}
|
||||
if (entityStatsNode && isLoading) {
|
||||
entityStatsNode.textContent = "Updating entity analysis…";
|
||||
}
|
||||
};
|
||||
|
||||
function triggerEntitiesRefresh(force = false) {
|
||||
if (!entitiesEnabled) {
|
||||
return;
|
||||
}
|
||||
if (force) {
|
||||
hasTriggeredEntitiesRefresh = true;
|
||||
performEntitiesRefresh(true);
|
||||
return;
|
||||
}
|
||||
if (!hasTriggeredEntitiesRefresh) {
|
||||
hasTriggeredEntitiesRefresh = true;
|
||||
performEntitiesRefresh(true);
|
||||
}
|
||||
}
|
||||
|
||||
function activateEntityTab(panelKey) {
|
||||
if (activeEntityPanel === "manual" && panelKey !== activeEntityPanel) {
|
||||
void flushManualOverrides();
|
||||
@@ -1218,6 +1300,9 @@ const initPrepare = (root = document) => {
|
||||
panel.setAttribute("aria-hidden", isActive ? "false" : "true");
|
||||
});
|
||||
activeEntityPanel = panelKey;
|
||||
if (panelKey === "entities") {
|
||||
triggerEntitiesRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
function populateVoiceOptions(select, selectedVoice) {
|
||||
@@ -1258,8 +1343,10 @@ const initPrepare = (root = document) => {
|
||||
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 overrideLookup = buildOverrideLookup();
|
||||
const peopleThreshold = Number.parseInt(entityState.filters?.people, 10) || 0;
|
||||
const entityThreshold = Number.parseInt(entityState.filters?.entities, 10) || 0;
|
||||
const entityKindSelection = (entityState.filters?.entitiesKind || "all").toLowerCase();
|
||||
|
||||
const ensureSelectValue = (node, value) => {
|
||||
if (!node) return;
|
||||
@@ -1271,6 +1358,34 @@ const initPrepare = (root = document) => {
|
||||
|
||||
ensureSelectValue(peopleFilterNode, peopleThreshold);
|
||||
ensureSelectValue(entitiesFilterNode, entityThreshold);
|
||||
ensureSelectValue(entitiesKindFilterNode, entityKindSelection || "all");
|
||||
|
||||
if (entitiesKindFilterNode) {
|
||||
const seenKinds = new Set();
|
||||
const existingValue = entitiesKindFilterNode.value || "all";
|
||||
const options = Array.from(entityEntries || [])
|
||||
.map((entry) => String(entry?.kind || ""))
|
||||
.filter((kind) => kind && kind !== "PERSON");
|
||||
entitiesKindFilterNode.innerHTML = "";
|
||||
const addOption = (value, label) => {
|
||||
if (seenKinds.has(value)) return;
|
||||
const opt = document.createElement("option");
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
entitiesKindFilterNode.appendChild(opt);
|
||||
seenKinds.add(value);
|
||||
};
|
||||
addOption("all", "All");
|
||||
addOption("proper_noun", "Proper nouns");
|
||||
options.forEach((kind) => {
|
||||
const normalized = kind.toLowerCase();
|
||||
addOption(normalized, formatEntityKindLabel(kind));
|
||||
});
|
||||
if (!seenKinds.has(existingValue)) {
|
||||
entityState.filters.entitiesKind = "all";
|
||||
}
|
||||
ensureSelectValue(entitiesKindFilterNode, entityState.filters.entitiesKind || "all");
|
||||
}
|
||||
|
||||
const renderDisabled = (listNode, message) => {
|
||||
if (!listNode) return;
|
||||
@@ -1295,6 +1410,10 @@ const initPrepare = (root = document) => {
|
||||
if (entitiesFilterNode) {
|
||||
entitiesFilterNode.disabled = true;
|
||||
}
|
||||
if (entitiesKindFilterNode) {
|
||||
entitiesKindFilterNode.disabled = true;
|
||||
entitiesKindFilterNode.value = "all";
|
||||
}
|
||||
renderDisabled(peopleListNode, disabledMessage);
|
||||
renderDisabled(entityListNode, "Enable entity recognition to populate detected entities.");
|
||||
return;
|
||||
@@ -1306,6 +1425,9 @@ const initPrepare = (root = document) => {
|
||||
if (entitiesFilterNode) {
|
||||
entitiesFilterNode.disabled = false;
|
||||
}
|
||||
if (entitiesKindFilterNode) {
|
||||
entitiesKindFilterNode.disabled = false;
|
||||
}
|
||||
|
||||
if (entityStatsNode) {
|
||||
if (errors.length) {
|
||||
@@ -1335,7 +1457,10 @@ const initPrepare = (root = document) => {
|
||||
return { visible: 0, total: entries.length };
|
||||
}
|
||||
listNode.innerHTML = "";
|
||||
const filtered = entries.filter((entry) => Number(entry?.count || 0) >= threshold);
|
||||
let filtered = entries.filter((entry) => Number(entry?.count || 0) >= threshold);
|
||||
if (options.kindFilter) {
|
||||
filtered = filtered.filter((entry) => options.kindFilter(entry));
|
||||
}
|
||||
if (!filtered.length) {
|
||||
const emptyItem = document.createElement("li");
|
||||
emptyItem.className = "entity-summary__empty";
|
||||
@@ -1350,6 +1475,10 @@ const initPrepare = (root = document) => {
|
||||
const tokenLabel = entity.label || entity.token || normalized || "Untitled entity";
|
||||
item.dataset.entityId = entity.id || normalized || tokenLabel;
|
||||
item.dataset.entityCategory = options.groupKey;
|
||||
item.dataset.normalized = normalized.toLowerCase();
|
||||
if (entity.kind) {
|
||||
item.dataset.entityKind = entity.kind;
|
||||
}
|
||||
|
||||
const labelEl = item.querySelector('[data-role="entity-label"]');
|
||||
if (labelEl) {
|
||||
@@ -1364,7 +1493,7 @@ const initPrepare = (root = document) => {
|
||||
kindEl.hidden = true;
|
||||
} else {
|
||||
kindEl.hidden = false;
|
||||
kindEl.textContent = kind;
|
||||
kindEl.textContent = formatEntityKindLabel(kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1396,28 +1525,88 @@ const initPrepare = (root = document) => {
|
||||
}
|
||||
}
|
||||
|
||||
const button = item.querySelector('[data-role="entity-add-override"]');
|
||||
if (button) {
|
||||
const overrideButton = item.querySelector('[data-role="entity-add-override"]');
|
||||
if (overrideButton) {
|
||||
const hasOverride = entityState.manualOverrides.some((entry) => {
|
||||
const candidate = entry?.normalized || entry?.token || "";
|
||||
return candidate && candidate.toLowerCase() === normalized.toLowerCase();
|
||||
});
|
||||
button.dataset.entityToken = entity.label || entity.token || "";
|
||||
button.dataset.entityNormalized = normalized;
|
||||
button.dataset.entityCategory = options.groupKey;
|
||||
button.dataset.entityCount = String(entity.count || 0);
|
||||
overrideButton.dataset.entityToken = entity.label || entity.token || "";
|
||||
overrideButton.dataset.entityNormalized = normalized;
|
||||
overrideButton.dataset.entityCategory = options.groupKey;
|
||||
overrideButton.dataset.entityCount = String(entity.count || 0);
|
||||
const sampleContext = Array.isArray(entity.samples) && entity.samples.length
|
||||
? typeof entity.samples[0] === "string"
|
||||
? entity.samples[0]
|
||||
: entity.samples[0]?.excerpt || ""
|
||||
: "";
|
||||
if (sampleContext) {
|
||||
button.dataset.entityContext = sampleContext;
|
||||
overrideButton.dataset.entityContext = sampleContext;
|
||||
}
|
||||
if (entity.kind) {
|
||||
button.dataset.entityKind = entity.kind;
|
||||
overrideButton.dataset.entityKind = entity.kind;
|
||||
}
|
||||
overrideButton.textContent = hasOverride ? "Edit manual override" : "Add manual override";
|
||||
if (hasOverride) {
|
||||
item.dataset.hasOverride = "true";
|
||||
}
|
||||
}
|
||||
|
||||
const inlineOverride = item.querySelector('[data-role="inline-override"]');
|
||||
if (inlineOverride) {
|
||||
const override = overrideLookup.get(normalized.toLowerCase());
|
||||
if (override) {
|
||||
inlineOverride.hidden = false;
|
||||
item.dataset.hasOverride = "true";
|
||||
item.dataset.overrideId = override.id || override.normalized || override.token || "";
|
||||
const pronInput = inlineOverride.querySelector('[data-role="manual-override-pronunciation"]');
|
||||
if (pronInput) {
|
||||
pronInput.value = override.pronunciation || "";
|
||||
}
|
||||
const voiceSelect = inlineOverride.querySelector('[data-role="manual-override-voice"]');
|
||||
if (voiceSelect) {
|
||||
populateVoiceOptions(voiceSelect, override.voice || "");
|
||||
}
|
||||
const previewButton = inlineOverride.querySelector('[data-role="speaker-preview"]');
|
||||
if (previewButton) {
|
||||
previewButton.dataset.previewText = override.pronunciation || override.token || tokenLabel;
|
||||
previewButton.dataset.voice = override.voice || override.voice_profile || baseVoice || "";
|
||||
previewButton.dataset.language = languageCode;
|
||||
previewButton.dataset.speed = defaultSpeed;
|
||||
previewButton.dataset.useGpu = useGpuDefault;
|
||||
}
|
||||
if (
|
||||
highlightMode === "inline" &&
|
||||
highlightedOverrideId &&
|
||||
(override.id === highlightedOverrideId || override.normalized === highlightedOverrideId || override.token === highlightedOverrideId)
|
||||
) {
|
||||
item.classList.add("is-highlighted");
|
||||
inlineOverride.hidden = false;
|
||||
setTimeout(() => item.classList.remove("is-highlighted"), 2200);
|
||||
highlightedOverrideId = "";
|
||||
}
|
||||
} else {
|
||||
inlineOverride.hidden = true;
|
||||
delete item.dataset.overrideId;
|
||||
}
|
||||
}
|
||||
|
||||
const previewButton = item.querySelector('[data-role="entity-preview"]');
|
||||
if (previewButton) {
|
||||
const previewVoice = overrideLookup.get(normalized.toLowerCase())?.voice || baseVoice || "";
|
||||
const previewText = Array.isArray(entity.samples) && entity.samples.length
|
||||
? typeof entity.samples[0] === "string"
|
||||
? entity.samples[0]
|
||||
: entity.samples[0]?.excerpt || tokenLabel
|
||||
: tokenLabel;
|
||||
previewButton.hidden = !previewText;
|
||||
if (previewText) {
|
||||
previewButton.dataset.previewText = previewText;
|
||||
previewButton.dataset.voice = previewVoice;
|
||||
previewButton.dataset.language = languageCode;
|
||||
previewButton.dataset.speed = defaultSpeed;
|
||||
previewButton.dataset.useGpu = useGpuDefault;
|
||||
}
|
||||
button.textContent = hasOverride ? "Edit manual override" : "Add manual override";
|
||||
}
|
||||
|
||||
listNode.appendChild(item);
|
||||
@@ -1457,6 +1646,16 @@ const initPrepare = (root = document) => {
|
||||
hideKind: false,
|
||||
emptyText: "No entities detected yet.",
|
||||
filteredEmptyText: "No entities match the selected mention filter.",
|
||||
kindFilter: (entry) => {
|
||||
if (!entityKindSelection || entityKindSelection === "all") {
|
||||
return true;
|
||||
}
|
||||
const kind = (entry.kind || "").toLowerCase();
|
||||
if (entityKindSelection === "proper_noun") {
|
||||
return !kind || kind === "propn" || kind === "noun" || kind === "proper_noun";
|
||||
}
|
||||
return kind === entityKindSelection;
|
||||
},
|
||||
});
|
||||
|
||||
if (entitiesFilterNode && !entityEntries.length) {
|
||||
@@ -1774,6 +1973,7 @@ const initPrepare = (root = document) => {
|
||||
|
||||
async function performEntitiesRefresh(force = false) {
|
||||
if (!entitiesUrl) return;
|
||||
setEntitiesLoading(true);
|
||||
try {
|
||||
const url = new URL(entitiesUrl, window.location.origin);
|
||||
if (force) {
|
||||
@@ -1790,6 +1990,8 @@ const initPrepare = (root = document) => {
|
||||
applyEntityPayload(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh entity summary", error);
|
||||
} finally {
|
||||
setEntitiesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1873,9 +2075,14 @@ const initPrepare = (root = document) => {
|
||||
const body = await response.json();
|
||||
const highlighted = body.override?.id || payload.normalized || payload.token;
|
||||
applyEntityPayload(body, { highlightId: highlighted });
|
||||
activateEntityTab("manual");
|
||||
if (manualOverrideResultsList) {
|
||||
manualOverrideResultsList.innerHTML = "";
|
||||
if (highlightMode === "inline") {
|
||||
highlightedOverrideId = highlighted;
|
||||
activateEntityTab("entities");
|
||||
} else {
|
||||
activateEntityTab("manual");
|
||||
if (manualOverrideResultsList) {
|
||||
manualOverrideResultsList.innerHTML = "";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create manual override", error);
|
||||
@@ -1884,6 +2091,7 @@ const initPrepare = (root = document) => {
|
||||
|
||||
tabButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
highlightMode = button.dataset.panel === "manual" ? "manual" : "entities";
|
||||
activateEntityTab(button.dataset.panel || "people");
|
||||
});
|
||||
});
|
||||
@@ -1891,23 +2099,114 @@ const initPrepare = (root = document) => {
|
||||
if (entitiesRefreshButton) {
|
||||
entitiesRefreshButton.addEventListener("click", () => {
|
||||
void flushManualOverrides();
|
||||
performEntitiesRefresh(true);
|
||||
triggerEntitiesRefresh(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (entityListNode) {
|
||||
entityListNode.addEventListener("click", (event) => {
|
||||
const trigger = event.target.closest('[data-role="entity-add-override"]');
|
||||
if (!trigger) return;
|
||||
event.preventDefault();
|
||||
createOverrideFromData({
|
||||
token: trigger.dataset.entityToken || "",
|
||||
normalized: trigger.dataset.entityNormalized || "",
|
||||
context: trigger.dataset.entityContext || "",
|
||||
category: trigger.dataset.entityCategory || "",
|
||||
kind: trigger.dataset.entityKind || "",
|
||||
source: "entity",
|
||||
});
|
||||
const addTrigger = event.target.closest('[data-role="entity-add-override"]');
|
||||
if (addTrigger) {
|
||||
event.preventDefault();
|
||||
const row = addTrigger.closest('[data-role="entity-row"]');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const inlineOverride = row.querySelector('[data-role="inline-override"]');
|
||||
if (!inlineOverride) {
|
||||
highlightMode = "manual";
|
||||
createOverrideFromData({
|
||||
token: addTrigger.dataset.entityToken || "",
|
||||
normalized: addTrigger.dataset.entityNormalized || "",
|
||||
context: addTrigger.dataset.entityContext || "",
|
||||
category: addTrigger.dataset.entityCategory || "",
|
||||
kind: addTrigger.dataset.entityKind || "",
|
||||
source: "entity",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
inlineOverride.hidden = false;
|
||||
const pronInput = inlineOverride.querySelector('[data-role="manual-override-pronunciation"]');
|
||||
const voiceSelect = inlineOverride.querySelector('[data-role="manual-override-voice"]');
|
||||
const previewButton = inlineOverride.querySelector('[data-role="speaker-preview"]');
|
||||
const token = addTrigger.dataset.entityToken || row.querySelector('[data-role="entity-label"]')?.textContent || "";
|
||||
const normalized = addTrigger.dataset.entityNormalized || row.dataset.normalized || token;
|
||||
const context = addTrigger.dataset.entityContext || "";
|
||||
|
||||
if (!row.dataset.hasOverride) {
|
||||
if (pronInput) {
|
||||
pronInput.value = "";
|
||||
}
|
||||
if (voiceSelect) {
|
||||
populateVoiceOptions(voiceSelect, "");
|
||||
}
|
||||
}
|
||||
|
||||
inlineOverride.dataset.pendingToken = token;
|
||||
inlineOverride.dataset.pendingNormalized = normalized;
|
||||
inlineOverride.dataset.pendingContext = context;
|
||||
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;
|
||||
}
|
||||
|
||||
highlightMode = "inline";
|
||||
highlightedOverrideId = row.dataset.overrideId || "";
|
||||
pronInput?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const saveTrigger = event.target.closest('[data-role="inline-override-save"]');
|
||||
if (saveTrigger) {
|
||||
event.preventDefault();
|
||||
const inlineOverride = saveTrigger.closest('[data-role="inline-override"]');
|
||||
const row = saveTrigger.closest('[data-role="entity-row"]');
|
||||
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",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const removeTrigger = event.target.closest('[data-role="inline-override-remove"]');
|
||||
if (removeTrigger) {
|
||||
event.preventDefault();
|
||||
const row = removeTrigger.closest('[data-role="entity-row"]');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const overrideId = row.dataset.overrideId;
|
||||
if (overrideId) {
|
||||
deleteManualOverride(overrideId);
|
||||
} else {
|
||||
const inlineOverride = removeTrigger.closest('[data-role="inline-override"]');
|
||||
if (inlineOverride) {
|
||||
inlineOverride.hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1931,6 +2230,7 @@ const initPrepare = (root = document) => {
|
||||
manualOverrideAddCustomButton.addEventListener("click", () => {
|
||||
const token = window.prompt("Enter the token to override:");
|
||||
if (!token) return;
|
||||
highlightMode = "manual";
|
||||
createOverrideFromData({ token: token.trim(), source: "manual" });
|
||||
});
|
||||
}
|
||||
@@ -1940,6 +2240,7 @@ const initPrepare = (root = document) => {
|
||||
const resultButton = event.target.closest('[data-role="manual-override-result"]');
|
||||
if (!resultButton) return;
|
||||
event.preventDefault();
|
||||
highlightMode = "manual";
|
||||
createOverrideFromData({
|
||||
token: resultButton.dataset.token || "",
|
||||
normalized: resultButton.dataset.normalized || "",
|
||||
@@ -1998,6 +2299,7 @@ const initPrepare = (root = document) => {
|
||||
activateEntityTab(initialTab?.dataset.panel || "people");
|
||||
renderEntitySummary();
|
||||
renderManualOverrides();
|
||||
triggerEntitiesRefresh();
|
||||
}
|
||||
|
||||
const handleDeferredSubmit = (event) => {
|
||||
|
||||
@@ -141,21 +141,26 @@ const displayTransientError = (modal, message) => {
|
||||
stage.prepend(alert);
|
||||
};
|
||||
|
||||
const updateStepIndicators = (modal, activeStep) => {
|
||||
const updateStepIndicators = (modal, activeStep, payload) => {
|
||||
const indicators = modal.querySelectorAll('[data-role="wizard-step-indicator"]');
|
||||
const activeIndex = STEP_ORDER.indexOf(activeStep);
|
||||
const completedList = Array.isArray(payload?.completed_steps) ? payload.completed_steps : [];
|
||||
const completedSet = new Set(completedList.map((step) => normalizeStep(step)));
|
||||
indicators.forEach((indicator) => {
|
||||
const step = normalizeStep(indicator.dataset.step || "book");
|
||||
indicator.classList.remove("is-active", "is-complete");
|
||||
const index = STEP_ORDER.indexOf(step);
|
||||
const isComplete = index > -1 && index < activeIndex;
|
||||
const isActive = index === activeIndex;
|
||||
const visited = completedSet.has(step);
|
||||
const isComplete = !isActive && (visited || (index > -1 && index < activeIndex));
|
||||
indicator.classList.toggle("is-complete", isComplete);
|
||||
indicator.classList.toggle("is-active", isActive);
|
||||
if (indicator instanceof HTMLButtonElement) {
|
||||
indicator.disabled = !(isComplete);
|
||||
const clickable = isComplete && !isActive;
|
||||
indicator.disabled = !clickable;
|
||||
indicator.setAttribute("aria-disabled", clickable ? "false" : "true");
|
||||
indicator.setAttribute("aria-current", isActive ? "step" : "false");
|
||||
if (isComplete) {
|
||||
if (clickable) {
|
||||
indicator.dataset.state = "clickable";
|
||||
} else {
|
||||
delete indicator.dataset.state;
|
||||
@@ -177,7 +182,7 @@ const updateHeaderCopy = (modal, step, payload) => {
|
||||
if (hintEl) {
|
||||
hintEl.textContent = payload?.hint || meta.hint;
|
||||
}
|
||||
updateStepIndicators(modal, step);
|
||||
updateStepIndicators(modal, step, payload);
|
||||
};
|
||||
|
||||
const updateFilenameLabel = (modal, filename) => {
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
data-step="book"
|
||||
data-pending-id="{{ pending.id if pending else '' }}"
|
||||
{% if not readonly %}enctype="multipart/form-data"{% endif %}>
|
||||
<input type="hidden" name="pending_id" value="{{ pending.id if pending else '' }}">
|
||||
<div class="modal__body wizard-card__body upload-form__sections">
|
||||
<section class="form-section">
|
||||
<h3 class="form-section__title">Manuscript & subtitles</h3>
|
||||
@@ -255,7 +256,7 @@
|
||||
<p class="hint">Paragraphs work well for long-form narration; sentences give finer subtitle sync.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="speaker_analysis_threshold">Speaker analysis minimum mentions</label>
|
||||
<label for="speaker_analysis_threshold">Minimum mentions</label>
|
||||
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ analysis_threshold_value }}" {{ 'disabled' if readonly else '' }}>
|
||||
<p class="hint">Entities appearing less often fall back to the narrator voice.</p>
|
||||
</div>
|
||||
|
||||
@@ -381,13 +381,22 @@
|
||||
<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>
|
||||
<label class="field field--inline" for="entities-kind-filter">
|
||||
<span>Entity type</span>
|
||||
<select id="entities-kind-filter" data-role="entity-filter-kind">
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="entity-summary__refresh-group">
|
||||
<span class="spinner spinner--sm spinner--muted" data-role="entities-spinner" aria-hidden="true" hidden></span>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
<dl class="entity-summary__stats" data-role="entity-stats"></dl>
|
||||
<ol class="entity-summary__list" data-role="entity-list-entities"></ol>
|
||||
<template data-role="entity-row-template">
|
||||
<li class="entity-summary__item">
|
||||
<li class="entity-summary__item" data-role="entity-row">
|
||||
<header class="entity-summary__item-header">
|
||||
<div>
|
||||
<h3 class="entity-summary__label" data-role="entity-label"></h3>
|
||||
@@ -395,9 +404,31 @@
|
||||
</div>
|
||||
<div class="entity-summary__actions">
|
||||
<span class="badge badge--muted" data-role="entity-count"></span>
|
||||
<button type="button" class="button button--ghost button--small" data-role="entity-preview" hidden>Preview</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="entity-add-override">Add manual override</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="entity-inline-override" data-role="inline-override" hidden>
|
||||
<div class="entity-inline-override__fields">
|
||||
<label class="field" data-role="inline-override-pronunciation-label">
|
||||
<span>Pronunciation</span>
|
||||
<input type="text" data-role="manual-override-pronunciation" value="">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Assigned voice</span>
|
||||
<select data-role="manual-override-voice" data-default-voice="{{ pending.voice }}">
|
||||
<option value="">Use narrator voice ({{ pending.voice }})</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="entity-inline-override__actions">
|
||||
<div class="entity-inline-override__buttons">
|
||||
<button type="button" class="button button--ghost button--small" data-role="speaker-preview" data-preview-source="manual">Preview</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="inline-override-save">Save</button>
|
||||
</div>
|
||||
<button type="button" class="button button--ghost button--small" data-role="inline-override-remove">Remove override</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entity-summary__samples" data-role="entity-samples"></div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user