feat: Enhance entity processing and UI with new filters and manual override options

This commit is contained in:
JB
2025-10-12 11:38:06 -07:00
parent 17534d7890
commit 82d780db0d
7 changed files with 515 additions and 49 deletions
+8 -1
View File
@@ -362,7 +362,14 @@ def extract_entities(
elapsed = time.perf_counter() - start elapsed = time.perf_counter() - start
people_records = [record for record in records.values() if record.category == "people"] 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)) people_records.sort(key=lambda rec: (-rec.count, rec.label))
entity_records.sort(key=lambda rec: (-rec.count, rec.label)) entity_records.sort(key=lambda rec: (-rec.count, rec.label))
+123 -4
View File
@@ -1509,6 +1509,94 @@ def _apply_prepare_form(
apply_config_requested, apply_config_requested,
persist_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]] = [ _SUPPLEMENT_TITLE_PATTERNS: List[tuple[re.Pattern[str], float]] = [
(re.compile(r"\btitle\s+page\b"), 3.0), (re.compile(r"\btitle\s+page\b"), 3.0),
(re.compile(r"\bcopyright\b"), 2.4), (re.compile(r"\bcopyright\b"), 2.4),
@@ -2617,6 +2705,24 @@ def enqueue_job() -> ResponseReturnValue:
file = request.files.get("source_file") file = request.files.get("source_file")
text_input = request.form.get("source_text", "").strip() 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: if not file and not text_input:
return redirect(url_for("web.index")) return redirect(url_for("web.index"))
@@ -2696,9 +2802,6 @@ def enqueue_job() -> ResponseReturnValue:
_ensure_at_least_one_chapter_enabled(chapters_payload) _ensure_at_least_one_chapter_enabled(chapters_payload)
profiles = load_profiles()
settings = _load_settings()
language = request.form.get("language", "a") language = request.form.get("language", "a")
base_voice = request.form.get("voice", "af_alloy") base_voice = request.form.get("voice", "af_alloy")
profile_selection = (request.form.get("voice_profile") or "__standard").strip() 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) active_index = _WIZARD_STEP_ORDER.index(step)
except ValueError: except ValueError:
active_index = 0 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 { return {
"step": step, "step": step,
"step_index": int(meta.get("index", active_index + 1)), "step_index": int(meta.get("index", active_index + 1)),
+1
View File
@@ -247,6 +247,7 @@ class PendingJob:
manual_overrides: List[Dict[str, Any]] = field(default_factory=list) manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
pronunciation_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 entity_cache_key: Optional[str] = None
wizard_max_step_index: int = 0
class ConversionService: class ConversionService:
+338 -36
View File
@@ -1127,15 +1127,18 @@ const initPrepare = (root = document) => {
filters: { filters: {
people: 0, people: 0,
entities: 0, entities: 0,
entitiesKind: "all",
}, },
}; };
let highlightedOverrideId = ""; let highlightedOverrideId = "";
let highlightMode = "manual";
const dirtyOverrideIds = new Set(); const dirtyOverrideIds = new Set();
let activeEntityPanel = ""; let activeEntityPanel = "";
let overrideFlushPromise = null; let overrideFlushPromise = null;
let markOverrideDirty = () => {}; let markOverrideDirty = () => {};
let flushManualOverrides = () => null; let flushManualOverrides = () => null;
let hasTriggeredEntitiesRefresh = false;
if (entityTabs) { if (entityTabs) {
const tabButtons = Array.from(entityTabs.querySelectorAll('[data-role="entity-tab"]')); 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]) Array.from(entityTabs.querySelectorAll('[data-role="entity-panel"]')).map((panel) => [panel.dataset.panel || "", panel])
); );
const peopleSummaryContainer = entityTabs.querySelector('[data-role="people-summary"]'); const peopleSummaryContainer = entityTabs.querySelector('[data-role="people-summary"]');
const peopleStatsNode = peopleSummaryContainer?.querySelector('[data-role="people-stats"]'); const peopleStatsNode = peopleSummaryContainer?.querySelector('[data-role="people-stats"]');
const peopleListNode = peopleSummaryContainer?.querySelector('[data-role="entity-list-people"]'); const peopleListNode = peopleSummaryContainer?.querySelector('[data-role="entity-list-people"]');
const peopleFilterNode = peopleSummaryContainer?.querySelector('[data-role="entity-filter-people"]'); const peopleFilterNode = peopleSummaryContainer?.querySelector('[data-role="entity-filter-people"]');
const entitySummaryContainer = entityTabs.querySelector('[data-role="entities-summary"]'); const entitySummaryContainer = entityTabs.querySelector('[data-role="entities-summary"]');
const entityStatsNode = entitySummaryContainer?.querySelector('[data-role="entity-stats"]'); const entityStatsNode = entitySummaryContainer?.querySelector('[data-role="entity-stats"]');
const entityListNode = entitySummaryContainer?.querySelector('[data-role="entity-list-entities"]'); const entityListNode = entitySummaryContainer?.querySelector('[data-role="entity-list-entities"]');
const entitiesFilterNode = entitySummaryContainer?.querySelector('[data-role="entity-filter-entities"]'); const entitiesFilterNode = entitySummaryContainer?.querySelector('[data-role="entity-filter-entities"]');
const entityRowTemplate = entityTabs.querySelector('template[data-role="entity-row-template"]'); const entitiesKindFilterNode = entitySummaryContainer?.querySelector('[data-role="entity-filter-kind"]');
const entitiesRefreshButton = entitySummaryContainer?.querySelector('[data-role="entities-refresh"]'); 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 manualOverridesRoot = entityTabs.querySelector('[data-role="manual-overrides"]');
const manualOverrideList = manualOverridesRoot?.querySelector('[data-role="manual-override-list"]'); 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) => { const cloneTemplate = (template) => {
if (!template) return null; if (!template) return null;
if (template.content && template.content.firstElementChild) { if (template.content && template.content.firstElementChild) {
@@ -1201,6 +1214,75 @@ const initPrepare = (root = document) => {
return `${count.toLocaleString()} mention${count === 1 ? "" : "s"}`; 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) { function activateEntityTab(panelKey) {
if (activeEntityPanel === "manual" && panelKey !== activeEntityPanel) { if (activeEntityPanel === "manual" && panelKey !== activeEntityPanel) {
void flushManualOverrides(); void flushManualOverrides();
@@ -1218,6 +1300,9 @@ const initPrepare = (root = document) => {
panel.setAttribute("aria-hidden", isActive ? "false" : "true"); panel.setAttribute("aria-hidden", isActive ? "false" : "true");
}); });
activeEntityPanel = panelKey; activeEntityPanel = panelKey;
if (panelKey === "entities") {
triggerEntitiesRefresh();
}
} }
function populateVoiceOptions(select, selectedVoice) { function populateVoiceOptions(select, selectedVoice) {
@@ -1258,8 +1343,10 @@ const initPrepare = (root = document) => {
const errors = Array.isArray(summary.errors) ? summary.errors : []; const errors = Array.isArray(summary.errors) ? summary.errors : [];
const peopleEntries = Array.isArray(summary.people) ? summary.people : []; const peopleEntries = Array.isArray(summary.people) ? summary.people : [];
const entityEntries = Array.isArray(summary.entities) ? summary.entities : []; const entityEntries = Array.isArray(summary.entities) ? summary.entities : [];
const overrideLookup = buildOverrideLookup();
const peopleThreshold = Number.parseInt(entityState.filters?.people, 10) || 0; const peopleThreshold = Number.parseInt(entityState.filters?.people, 10) || 0;
const entityThreshold = Number.parseInt(entityState.filters?.entities, 10) || 0; const entityThreshold = Number.parseInt(entityState.filters?.entities, 10) || 0;
const entityKindSelection = (entityState.filters?.entitiesKind || "all").toLowerCase();
const ensureSelectValue = (node, value) => { const ensureSelectValue = (node, value) => {
if (!node) return; if (!node) return;
@@ -1271,6 +1358,34 @@ const initPrepare = (root = document) => {
ensureSelectValue(peopleFilterNode, peopleThreshold); ensureSelectValue(peopleFilterNode, peopleThreshold);
ensureSelectValue(entitiesFilterNode, entityThreshold); 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) => { const renderDisabled = (listNode, message) => {
if (!listNode) return; if (!listNode) return;
@@ -1295,6 +1410,10 @@ const initPrepare = (root = document) => {
if (entitiesFilterNode) { if (entitiesFilterNode) {
entitiesFilterNode.disabled = true; entitiesFilterNode.disabled = true;
} }
if (entitiesKindFilterNode) {
entitiesKindFilterNode.disabled = true;
entitiesKindFilterNode.value = "all";
}
renderDisabled(peopleListNode, disabledMessage); renderDisabled(peopleListNode, disabledMessage);
renderDisabled(entityListNode, "Enable entity recognition to populate detected entities."); renderDisabled(entityListNode, "Enable entity recognition to populate detected entities.");
return; return;
@@ -1306,6 +1425,9 @@ const initPrepare = (root = document) => {
if (entitiesFilterNode) { if (entitiesFilterNode) {
entitiesFilterNode.disabled = false; entitiesFilterNode.disabled = false;
} }
if (entitiesKindFilterNode) {
entitiesKindFilterNode.disabled = false;
}
if (entityStatsNode) { if (entityStatsNode) {
if (errors.length) { if (errors.length) {
@@ -1335,7 +1457,10 @@ const initPrepare = (root = document) => {
return { visible: 0, total: entries.length }; return { visible: 0, total: entries.length };
} }
listNode.innerHTML = ""; 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) { if (!filtered.length) {
const emptyItem = document.createElement("li"); const emptyItem = document.createElement("li");
emptyItem.className = "entity-summary__empty"; emptyItem.className = "entity-summary__empty";
@@ -1350,6 +1475,10 @@ const initPrepare = (root = document) => {
const tokenLabel = entity.label || entity.token || normalized || "Untitled entity"; const tokenLabel = entity.label || entity.token || normalized || "Untitled entity";
item.dataset.entityId = entity.id || normalized || tokenLabel; item.dataset.entityId = entity.id || normalized || tokenLabel;
item.dataset.entityCategory = options.groupKey; 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"]'); const labelEl = item.querySelector('[data-role="entity-label"]');
if (labelEl) { if (labelEl) {
@@ -1364,7 +1493,7 @@ const initPrepare = (root = document) => {
kindEl.hidden = true; kindEl.hidden = true;
} else { } else {
kindEl.hidden = false; 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"]'); const overrideButton = item.querySelector('[data-role="entity-add-override"]');
if (button) { if (overrideButton) {
const hasOverride = entityState.manualOverrides.some((entry) => { const hasOverride = entityState.manualOverrides.some((entry) => {
const candidate = entry?.normalized || entry?.token || ""; const candidate = entry?.normalized || entry?.token || "";
return candidate && candidate.toLowerCase() === normalized.toLowerCase(); return candidate && candidate.toLowerCase() === normalized.toLowerCase();
}); });
button.dataset.entityToken = entity.label || entity.token || ""; overrideButton.dataset.entityToken = entity.label || entity.token || "";
button.dataset.entityNormalized = normalized; overrideButton.dataset.entityNormalized = normalized;
button.dataset.entityCategory = options.groupKey; overrideButton.dataset.entityCategory = options.groupKey;
button.dataset.entityCount = String(entity.count || 0); overrideButton.dataset.entityCount = String(entity.count || 0);
const sampleContext = Array.isArray(entity.samples) && entity.samples.length const sampleContext = Array.isArray(entity.samples) && entity.samples.length
? typeof entity.samples[0] === "string" ? typeof entity.samples[0] === "string"
? entity.samples[0] ? entity.samples[0]
: entity.samples[0]?.excerpt || "" : entity.samples[0]?.excerpt || ""
: ""; : "";
if (sampleContext) { if (sampleContext) {
button.dataset.entityContext = sampleContext; overrideButton.dataset.entityContext = sampleContext;
} }
if (entity.kind) { 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); listNode.appendChild(item);
@@ -1457,6 +1646,16 @@ const initPrepare = (root = document) => {
hideKind: false, hideKind: false,
emptyText: "No entities detected yet.", emptyText: "No entities detected yet.",
filteredEmptyText: "No entities match the selected mention filter.", 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) { if (entitiesFilterNode && !entityEntries.length) {
@@ -1774,6 +1973,7 @@ const initPrepare = (root = document) => {
async function performEntitiesRefresh(force = false) { async function performEntitiesRefresh(force = false) {
if (!entitiesUrl) return; if (!entitiesUrl) return;
setEntitiesLoading(true);
try { try {
const url = new URL(entitiesUrl, window.location.origin); const url = new URL(entitiesUrl, window.location.origin);
if (force) { if (force) {
@@ -1790,6 +1990,8 @@ const initPrepare = (root = document) => {
applyEntityPayload(data); applyEntityPayload(data);
} catch (error) { } catch (error) {
console.error("Failed to refresh entity summary", 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 body = await response.json();
const highlighted = body.override?.id || payload.normalized || payload.token; const highlighted = body.override?.id || payload.normalized || payload.token;
applyEntityPayload(body, { highlightId: highlighted }); applyEntityPayload(body, { highlightId: highlighted });
activateEntityTab("manual"); if (highlightMode === "inline") {
if (manualOverrideResultsList) { highlightedOverrideId = highlighted;
manualOverrideResultsList.innerHTML = ""; activateEntityTab("entities");
} else {
activateEntityTab("manual");
if (manualOverrideResultsList) {
manualOverrideResultsList.innerHTML = "";
}
} }
} catch (error) { } catch (error) {
console.error("Failed to create manual override", error); console.error("Failed to create manual override", error);
@@ -1884,6 +2091,7 @@ const initPrepare = (root = document) => {
tabButtons.forEach((button) => { tabButtons.forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
highlightMode = button.dataset.panel === "manual" ? "manual" : "entities";
activateEntityTab(button.dataset.panel || "people"); activateEntityTab(button.dataset.panel || "people");
}); });
}); });
@@ -1891,23 +2099,114 @@ const initPrepare = (root = document) => {
if (entitiesRefreshButton) { if (entitiesRefreshButton) {
entitiesRefreshButton.addEventListener("click", () => { entitiesRefreshButton.addEventListener("click", () => {
void flushManualOverrides(); void flushManualOverrides();
performEntitiesRefresh(true); triggerEntitiesRefresh(true);
}); });
} }
if (entityListNode) { if (entityListNode) {
entityListNode.addEventListener("click", (event) => { entityListNode.addEventListener("click", (event) => {
const trigger = event.target.closest('[data-role="entity-add-override"]'); const addTrigger = event.target.closest('[data-role="entity-add-override"]');
if (!trigger) return; if (addTrigger) {
event.preventDefault(); event.preventDefault();
createOverrideFromData({ const row = addTrigger.closest('[data-role="entity-row"]');
token: trigger.dataset.entityToken || "", if (!row) {
normalized: trigger.dataset.entityNormalized || "", return;
context: trigger.dataset.entityContext || "", }
category: trigger.dataset.entityCategory || "", const inlineOverride = row.querySelector('[data-role="inline-override"]');
kind: trigger.dataset.entityKind || "", if (!inlineOverride) {
source: "entity", 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", () => { manualOverrideAddCustomButton.addEventListener("click", () => {
const token = window.prompt("Enter the token to override:"); const token = window.prompt("Enter the token to override:");
if (!token) return; if (!token) return;
highlightMode = "manual";
createOverrideFromData({ token: token.trim(), source: "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"]'); const resultButton = event.target.closest('[data-role="manual-override-result"]');
if (!resultButton) return; if (!resultButton) return;
event.preventDefault(); event.preventDefault();
highlightMode = "manual";
createOverrideFromData({ createOverrideFromData({
token: resultButton.dataset.token || "", token: resultButton.dataset.token || "",
normalized: resultButton.dataset.normalized || "", normalized: resultButton.dataset.normalized || "",
@@ -1998,6 +2299,7 @@ const initPrepare = (root = document) => {
activateEntityTab(initialTab?.dataset.panel || "people"); activateEntityTab(initialTab?.dataset.panel || "people");
renderEntitySummary(); renderEntitySummary();
renderManualOverrides(); renderManualOverrides();
triggerEntitiesRefresh();
} }
const handleDeferredSubmit = (event) => { const handleDeferredSubmit = (event) => {
+10 -5
View File
@@ -141,21 +141,26 @@ const displayTransientError = (modal, message) => {
stage.prepend(alert); stage.prepend(alert);
}; };
const updateStepIndicators = (modal, activeStep) => { const updateStepIndicators = (modal, activeStep, payload) => {
const indicators = modal.querySelectorAll('[data-role="wizard-step-indicator"]'); const indicators = modal.querySelectorAll('[data-role="wizard-step-indicator"]');
const activeIndex = STEP_ORDER.indexOf(activeStep); 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) => { indicators.forEach((indicator) => {
const step = normalizeStep(indicator.dataset.step || "book"); const step = normalizeStep(indicator.dataset.step || "book");
indicator.classList.remove("is-active", "is-complete"); indicator.classList.remove("is-active", "is-complete");
const index = STEP_ORDER.indexOf(step); const index = STEP_ORDER.indexOf(step);
const isComplete = index > -1 && index < activeIndex;
const isActive = 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-complete", isComplete);
indicator.classList.toggle("is-active", isActive); indicator.classList.toggle("is-active", isActive);
if (indicator instanceof HTMLButtonElement) { 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"); indicator.setAttribute("aria-current", isActive ? "step" : "false");
if (isComplete) { if (clickable) {
indicator.dataset.state = "clickable"; indicator.dataset.state = "clickable";
} else { } else {
delete indicator.dataset.state; delete indicator.dataset.state;
@@ -177,7 +182,7 @@ const updateHeaderCopy = (modal, step, payload) => {
if (hintEl) { if (hintEl) {
hintEl.textContent = payload?.hint || meta.hint; hintEl.textContent = payload?.hint || meta.hint;
} }
updateStepIndicators(modal, step); updateStepIndicators(modal, step, payload);
}; };
const updateFilenameLabel = (modal, filename) => { const updateFilenameLabel = (modal, filename) => {
@@ -141,6 +141,7 @@
data-step="book" data-step="book"
data-pending-id="{{ pending.id if pending else '' }}" data-pending-id="{{ pending.id if pending else '' }}"
{% if not readonly %}enctype="multipart/form-data"{% endif %}> {% 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"> <div class="modal__body wizard-card__body upload-form__sections">
<section class="form-section"> <section class="form-section">
<h3 class="form-section__title">Manuscript &amp; subtitles</h3> <h3 class="form-section__title">Manuscript &amp; subtitles</h3>
@@ -255,7 +256,7 @@
<p class="hint">Paragraphs work well for long-form narration; sentences give finer subtitle sync.</p> <p class="hint">Paragraphs work well for long-form narration; sentences give finer subtitle sync.</p>
</div> </div>
<div class="field"> <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 '' }}> <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> <p class="hint">Entities appearing less often fall back to the narrator voice.</p>
</div> </div>
@@ -381,13 +381,22 @@
<option value="10">10+</option> <option value="10">10+</option>
</select> </select>
</label> </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> </div>
</header> </header>
<dl class="entity-summary__stats" data-role="entity-stats"></dl> <dl class="entity-summary__stats" data-role="entity-stats"></dl>
<ol class="entity-summary__list" data-role="entity-list-entities"></ol> <ol class="entity-summary__list" data-role="entity-list-entities"></ol>
<template data-role="entity-row-template"> <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"> <header class="entity-summary__item-header">
<div> <div>
<h3 class="entity-summary__label" data-role="entity-label"></h3> <h3 class="entity-summary__label" data-role="entity-label"></h3>
@@ -395,9 +404,31 @@
</div> </div>
<div class="entity-summary__actions"> <div class="entity-summary__actions">
<span class="badge badge--muted" data-role="entity-count"></span> <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> <button type="button" class="button button--ghost button--small" data-role="entity-add-override">Add manual override</button>
</div> </div>
</header> </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> <div class="entity-summary__samples" data-role="entity-samples"></div>
</li> </li>
</template> </template>