diff --git a/abogen/web/routes.py b/abogen/web/routes.py index a9ee714..087709a 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -59,6 +59,7 @@ from abogen.entity_analysis import ( from abogen.pronunciation_store import ( delete_override as delete_pronunciation_override, load_overrides as load_pronunciation_overrides, + all_overrides as all_pronunciation_overrides, save_override as save_pronunciation_override, search_overrides as search_pronunciation_overrides, ) @@ -115,6 +116,26 @@ _SPEAKER_MODE_VALUES = {option["value"] for option in _SPEAKER_MODE_OPTIONS} _DEFAULT_ANALYSIS_THRESHOLD = 3 +_WIZARD_STEP_ORDER = ["book", "chapters", "entities"] +_WIZARD_STEP_META = { + "book": { + "index": 1, + "title": "Book parameters", + "hint": "Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.", + }, + "chapters": { + "index": 2, + "title": "Select chapters", + "hint": "Choose which chapters to convert. We'll analyse entities automatically when you continue.", + }, + "entities": { + "index": 3, + "title": "Review entities", + "hint": "Assign pronunciations, voices, and manual overrides before queueing the conversion.", + }, +} + + def _coerce_path(value: Any) -> Optional[Path]: if isinstance(value, Path): return value @@ -2029,6 +2050,88 @@ def voice_profiles_page() -> str: return render_template("voices.html", options=options) +@web_bp.get("/entities") +def entities_page() -> ResponseReturnValue: + options = _template_options() + settings = _load_settings() + languages_map = options.get("languages", {}) + + raw_language = (request.args.get("lang") or settings.get("language") or "a").strip().lower() + language = raw_language if raw_language in languages_map else "a" + + query = (request.args.get("q") or "").strip() + voice_filter = (request.args.get("voice") or "all").strip().lower() + pronunciation_filter = (request.args.get("pronunciation") or "all").strip().lower() + limit_value = _coerce_int(request.args.get("limit"), 200, minimum=10, maximum=500) + + if query: + overrides = search_pronunciation_overrides(language, query, limit=limit_value) + else: + overrides = all_pronunciation_overrides(language) + if limit_value and len(overrides) > limit_value: + overrides = overrides[:limit_value] + + display_rows: List[Dict[str, Any]] = [] + for entry in overrides: + has_voice = bool((entry.get("voice") or "").strip()) + has_pronunciation = bool((entry.get("pronunciation") or "").strip()) + if voice_filter == "with-voice" and not has_voice: + continue + if voice_filter == "without-voice" and has_voice: + continue + if pronunciation_filter == "with-pronunciation" and not has_pronunciation: + continue + if pronunciation_filter == "without-pronunciation" and has_pronunciation: + continue + row = dict(entry) + row["has_voice"] = has_voice + row["has_pronunciation"] = has_pronunciation + try: + updated_dt = datetime.fromtimestamp(float(entry.get("updated_at") or 0)) + created_dt = datetime.fromtimestamp(float(entry.get("created_at") or 0)) + except (TypeError, ValueError): + updated_dt = datetime.fromtimestamp(0) + created_dt = datetime.fromtimestamp(0) + row["updated_at_label"] = updated_dt.strftime("%Y-%m-%d %H:%M") + row["created_at_label"] = created_dt.strftime("%Y-%m-%d %H:%M") + display_rows.append(row) + + stats = { + "total": len(overrides), + "filtered": len(display_rows), + "with_voice": sum(1 for row in display_rows if row["has_voice"]), + "with_pronunciation": sum(1 for row in display_rows if row["has_pronunciation"]), + } + + language_options = sorted(languages_map.items(), key=lambda item: item[1]) + voice_filters = [ + {"value": "all", "label": "All voices"}, + {"value": "with-voice", "label": "Assigned voice"}, + {"value": "without-voice", "label": "No voice"}, + ] + pronunciation_filters = [ + {"value": "all", "label": "All pronunciations"}, + {"value": "with-pronunciation", "label": "Has pronunciation"}, + {"value": "without-pronunciation", "label": "No pronunciation"}, + ] + + context = { + "options": options, + "language": language, + "language_label": languages_map.get(language, language.upper()), + "languages": language_options, + "query": query, + "voice_filter": voice_filter, + "pronunciation_filter": pronunciation_filter, + "voice_filter_options": voice_filters, + "pronunciation_filter_options": pronunciation_filters, + "limit": limit_value, + "overrides": display_rows, + "stats": stats, + } + return render_template("entities.html", **context) + + @web_bp.route("/speakers", methods=["GET", "POST"]) def speaker_configs_page() -> ResponseReturnValue: options = _template_options() @@ -2721,13 +2824,19 @@ def enqueue_job() -> ResponseReturnValue: languages = speaker_config_payload.get("languages") if isinstance(languages, list): pending.speaker_voice_languages = [code for code in languages if isinstance(code, str)] + if _wants_wizard_json(): + return _wizard_json_response(pending, "chapters", embed_scripts=False) return redirect(url_for("web.prepare_job", pending_id=pending.id)) @web_bp.get("/jobs/prepare/") -def prepare_job(pending_id: str) -> str: +def prepare_job(pending_id: str) -> ResponseReturnValue: pending = _require_pending_job(pending_id) - return _render_prepare_page(pending, active_step="chapters") + requested_step = request.args.get("step") or "chapters" + normalized_step = _normalize_wizard_step(requested_step, pending) + if _wants_wizard_json(): + return _wizard_json_response(pending, normalized_step, embed_scripts=False) + return _render_prepare_page(pending, active_step=normalized_step) @web_bp.post("/jobs/prepare//analyze") @@ -2747,15 +2856,33 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: ) = _apply_prepare_form(pending, request.form) if errors: - return _render_prepare_page(pending, error=" ".join(errors), active_step="chapters") + message = " ".join(errors) + if _wants_wizard_json(): + return _wizard_json_response( + pending, + "chapters", + error=message, + embed_scripts=False, + status=400, + ) + return _render_prepare_page(pending, error=message, active_step="chapters") if pending.speaker_mode != "multi": setattr(pending, "analysis_requested", False) pending.chunks = [] pending.speaker_analysis = {} + error_message = "Switch to multi-speaker mode to analyze speakers." + if _wants_wizard_json(): + return _wizard_json_response( + pending, + "chapters", + error=error_message, + embed_scripts=False, + status=400, + ) return _render_prepare_page( pending, - error="Switch to multi-speaker mode to analyze speakers.", + error=error_message, active_step="chapters", ) @@ -2763,9 +2890,18 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: setattr(pending, "analysis_requested", False) pending.chunks = [] pending.speaker_analysis = {} + error_message = "Select at least one chapter to analyze." + if _wants_wizard_json(): + return _wizard_json_response( + pending, + "chapters", + error=error_message, + embed_scripts=False, + status=400, + ) return _render_prepare_page( pending, - error="Select at least one chapter to analyze.", + error=error_message, active_step="chapters", ) @@ -2816,6 +2952,13 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: notice_message = "Entity insights updated." if persist_config_requested and config_name: notice_message = "Entity insights updated and configuration saved." + if _wants_wizard_json(): + return _wizard_json_response( + pending, + "entities", + notice=notice_message, + embed_scripts=False, + ) return _render_prepare_page(pending, notice=notice_message, active_step="entities") @@ -2836,10 +2979,21 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: ) = _apply_prepare_form(pending, request.form) if errors: + active_hint = request.form.get("active_step") or "entities" + normalized_step = _normalize_wizard_step(active_hint, pending) + message = " ".join(errors) + if _wants_wizard_json(): + return _wizard_json_response( + pending, + normalized_step, + error=message, + embed_scripts=False, + status=400, + ) return _render_prepare_page( pending, - error=" ".join(errors), - active_step=request.form.get("active_step") or "entities", + error=message, + active_step=normalized_step, ) if pending.speaker_mode != "multi": @@ -2847,9 +3001,18 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: if not enabled_overrides: pending.chunks = [] + error_message = "Select at least one chapter to convert." + if _wants_wizard_json(): + return _wizard_json_response( + pending, + "chapters", + error=error_message, + embed_scripts=False, + status=400, + ) return _render_prepare_page( pending, - error="Select at least one chapter to convert.", + error=error_message, active_step="chapters", ) @@ -2916,6 +3079,13 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: if persist_config_requested and config_key: notice_message = "Configuration saved. Review entity settings before queuing." service.store_pending_job(pending) + if _wants_wizard_json(): + return _wizard_json_response( + pending, + "entities", + notice=notice_message, + embed_scripts=False, + ) return _render_prepare_page( pending, notice=notice_message, @@ -2972,7 +3142,10 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: if isinstance(config_key, str) and config_key: job.applied_speaker_config = config_key - return redirect(url_for("web.index", _anchor="queue")) + redirect_url = url_for("web.index", _anchor="queue") + if _wants_wizard_json(): + return jsonify({"redirect_url": redirect_url}) + return redirect(redirect_url) @web_bp.post("/jobs/prepare//cancel") @@ -2988,7 +3161,10 @@ def cancel_pending_job(pending_id: str) -> ResponseReturnValue: pending.cover_image_path.unlink() except OSError: pass - return redirect(url_for("web.index", _anchor="queue")) + redirect_url = url_for("web.index", _anchor="queue") + if _wants_wizard_json(): + return jsonify({"cancelled": True, "redirect_url": redirect_url}) + return redirect(redirect_url) def _render_jobs_panel() -> str: @@ -3008,6 +3184,111 @@ def _render_jobs_panel() -> str: ) +def _normalize_wizard_step(step: Optional[str], pending: Optional[PendingJob] = None) -> str: + if pending is None: + default_step = "book" + else: + default_step = "chapters" + if not step: + chosen = default_step + else: + normalized = step.strip().lower() + if normalized in {"", "upload", "settings"}: + chosen = default_step + elif normalized == "speakers": + chosen = "entities" + elif normalized in _WIZARD_STEP_ORDER: + chosen = normalized + else: + chosen = default_step + if chosen == "entities" and pending is not None and pending.speaker_mode != "multi": + return "chapters" + return chosen + + +def _wants_wizard_json() -> bool: + format_hint = request.args.get("format", "").strip().lower() + if format_hint == "json": + return True + accept_header = (request.headers.get("Accept") or "").lower() + if "application/json" in accept_header: + return True + requested_with = (request.headers.get("X-Requested-With") or "").lower() + if requested_with in {"xmlhttprequest", "fetch"}: + return True + wizard_header = (request.headers.get("X-Abogen-Wizard") or "").lower() + return wizard_header == "json" + + +def _render_wizard_partial( + pending: Optional[PendingJob], + step: str, + *, + error: Optional[str] = None, + notice: Optional[str] = None, + embed_scripts: bool = False, +) -> str: + templates = { + "book": "partials/new_job_step_book.html", + "chapters": "partials/new_job_step_chapters.html", + "entities": "partials/new_job_step_entities.html", + } + template_name = templates[step] + context: Dict[str, Any] = { + "pending": pending, + "readonly": False, + "options": _template_options(), + "settings": _load_settings(), + "error": error, + "notice": notice, + "embed_scripts": embed_scripts, + } + return render_template(template_name, **context) + + +def _wizard_step_payload( + pending: Optional[PendingJob], + step: str, + html: str, + *, + error: Optional[str] = None, + notice: Optional[str] = None, +) -> Dict[str, Any]: + meta = _WIZARD_STEP_META.get(step, {}) + try: + 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] + return { + "step": step, + "step_index": int(meta.get("index", active_index + 1)), + "total_steps": len(_WIZARD_STEP_ORDER), + "title": meta.get("title", ""), + "hint": meta.get("hint", ""), + "html": html, + "completed_steps": completed, + "pending_id": pending.id if pending else "", + "filename": pending.original_filename if pending and pending.original_filename else "", + "error": error or "", + "notice": notice or "", + } + + +def _wizard_json_response( + pending: Optional[PendingJob], + step: str, + *, + error: Optional[str] = None, + notice: Optional[str] = None, + embed_scripts: bool = False, + status: int = 200, +) -> ResponseReturnValue: + html = _render_wizard_partial(pending, step, error=error, notice=notice, embed_scripts=embed_scripts) + payload = _wizard_step_payload(pending, step, html, error=error, notice=notice) + return jsonify(payload), status + + def _render_prepare_page( pending: PendingJob, *, @@ -3022,15 +3303,7 @@ def _render_prepare_page( else request.args.get("step") ) or "chapters" - normalized_step = (active_step or "chapters").strip().lower() - if normalized_step == "speakers": - normalized_step = "entities" - if normalized_step not in {"chapters", "entities"}: - normalized_step = "chapters" - - is_multi = pending.speaker_mode == "multi" - if normalized_step == "entities" and not is_multi: - normalized_step = "chapters" + normalized_step = _normalize_wizard_step(active_step, pending) template_name = "prepare_entities.html" if normalized_step == "entities" else "prepare_chapters.html" diff --git a/abogen/web/static/dashboard.js b/abogen/web/static/dashboard.js index c8339ae..2d13461 100644 --- a/abogen/web/static/dashboard.js +++ b/abogen/web/static/dashboard.js @@ -1,7 +1,15 @@ import { initReaderUI } from "./reader.js"; +import { initWizard } from "./wizard.js"; + +const dashboardState = (window.AbogenDashboardState = window.AbogenDashboardState || { + boundKeydown: false, + boundBeforeUnload: false, +}); const initDashboard = () => { - const uploadModal = document.querySelector('[data-role="upload-modal"]'); + const uploadModal = + document.querySelector('[data-role="new-job-modal"]') || + document.querySelector('[data-role="upload-modal"]'); const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]'); const scope = uploadModal || document; const sourceFileInput = scope.querySelector('#source_file'); @@ -138,35 +146,52 @@ const initDashboard = () => { }; openModalButtons.forEach((button) => { + if (!button || button.dataset.dashboardBound === "true") { + return; + } + button.dataset.dashboardBound = "true"; button.addEventListener("click", (event) => { event.preventDefault(); openUploadModal(button); }); }); - if (uploadModal) { + if (uploadModal && uploadModal.dataset.dashboardCloseBound !== "true") { + uploadModal.dataset.dashboardCloseBound = "true"; uploadModal.addEventListener("click", (event) => { const target = event.target; - if (target instanceof Element && target.closest('[data-role="upload-modal-close"]')) { + if ( + target instanceof Element && + (target.closest('[data-role="new-job-modal-close"]') || + target.closest('[data-role="upload-modal-close"]') || + target.closest('[data-role="wizard-close"]') || + target.closest('[data-role="wizard-cancel"]')) + ) { event.preventDefault(); closeUploadModal(); } }); } - document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - if (uploadModal && !uploadModal.hidden) { - closeUploadModal(); - return; + if (!dashboardState.boundKeydown) { + dashboardState.boundKeydown = true; + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + if (uploadModal && !uploadModal.hidden) { + closeUploadModal(); + return; + } } - } - }); + }); + } initReaderUI({ onBeforeOpen: closeUploadModal }); if (sourceFileInput) { - sourceFileInput.addEventListener("change", updateDropzoneFilename); + if (sourceFileInput.dataset.dashboardChangeBound !== "true") { + sourceFileInput.dataset.dashboardChangeBound = "true"; + sourceFileInput.addEventListener("change", updateDropzoneFilename); + } updateDropzoneFilename(); } else { setDropzoneStatus(""); @@ -366,14 +391,16 @@ const initDashboard = () => { } }; - if (previewButton) { + if (previewButton && previewButton.dataset.dashboardBound !== "true") { + previewButton.dataset.dashboardBound = "true"; previewButton.addEventListener("click", (event) => { event.preventDefault(); handleVoicePreview(); }); } - if (dropzone) { + if (dropzone && dropzone.dataset.dashboardDragBound !== "true") { + dropzone.dataset.dashboardDragBound = "true"; let dragDepth = 0; dropzone.addEventListener("dragenter", (event) => { @@ -536,7 +563,10 @@ const initDashboard = () => { if (profileSelect) { const hasSaved = selectFirstProfileIfAvailable(); - profileSelect.addEventListener("change", updateVoiceControls); + if (profileSelect.dataset.dashboardBound !== "true") { + profileSelect.dataset.dashboardBound = "true"; + profileSelect.addEventListener("change", updateVoiceControls); + } updateVoiceControls(); if (!hasSaved) { hydrateDefaultVoice(); @@ -545,14 +575,25 @@ const initDashboard = () => { hydrateDefaultVoice(); } - window.addEventListener("beforeunload", () => { - cancelPreviewRequest(); - stopPreviewAudio(); - }); + if (!dashboardState.boundBeforeUnload) { + dashboardState.boundBeforeUnload = true; + window.addEventListener("beforeunload", () => { + cancelPreviewRequest(); + stopPreviewAudio(); + }); + } +}; + +window.AbogenDashboard = window.AbogenDashboard || {}; +window.AbogenDashboard.init = initDashboard; + +const bootDashboard = () => { + initDashboard(); + initWizard(); }; if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", initDashboard, { once: true }); + document.addEventListener("DOMContentLoaded", bootDashboard, { once: true }); } else { - initDashboard(); + bootDashboard(); } diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index 97742dc..1a84aed 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -1,10 +1,23 @@ -document.addEventListener("DOMContentLoaded", () => { - const form = document.querySelector(".prepare-form"); +const prepareState = (window.AbogenPrepareState = window.AbogenPrepareState || { + modalEventsBound: false, +}); + +const initPrepare = (root = document) => { + const rootEl = root instanceof HTMLElement ? root : document; + const form = rootEl.querySelector(".prepare-form") || document.querySelector(".prepare-form"); if (!form) return; + if (form.dataset.prepareInitialized === "true") { + return; + } + form.dataset.prepareInitialized = "true"; const wizardModal = document.querySelector('[data-role="wizard-modal"]'); - const uploadModal = document.querySelector('[data-role="upload-modal"]'); - const wizardPreviousButtons = Array.from(document.querySelectorAll('[data-role="wizard-previous"]')); + const uploadModal = + document.querySelector('[data-role="new-job-modal"]') || + document.querySelector('[data-role="upload-modal"]'); + const wizardPreviousButtons = Array.from( + document.querySelectorAll('[data-role="wizard-previous"], [data-role="wizard-back"]') + ); const openUploadTriggers = Array.from(document.querySelectorAll('[data-role="open-upload-modal"]')); const showWizardModal = () => { @@ -40,10 +53,21 @@ document.addEventListener("DOMContentLoaded", () => { showWizardModal(); - document.addEventListener("upload-modal:open", hideWizardModal); - document.addEventListener("upload-modal:close", showWizardModal); + if (!prepareState.modalEventsBound) { + prepareState.modalEventsBound = true; + document.addEventListener("upload-modal:open", hideWizardModal); + document.addEventListener("upload-modal:close", showWizardModal); + } wizardPreviousButtons.forEach((button) => { + if (!button || button.dataset.prepareBackBound === "true") { + return; + } + const targetStep = (button.dataset.targetStep || "").toLowerCase(); + if (targetStep && targetStep !== "book") { + return; + } + button.dataset.prepareBackBound = "true"; button.addEventListener("click", (event) => { event.preventDefault(); hideWizardModal(); @@ -1775,4 +1799,13 @@ document.addEventListener("DOMContentLoaded", () => { hideGenderMenus(); } }); -}); +}; + +window.AbogenPrepare = window.AbogenPrepare || {}; +window.AbogenPrepare.init = initPrepare; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => initPrepare()); +} else { + initPrepare(); +} diff --git a/abogen/web/static/wizard.js b/abogen/web/static/wizard.js new file mode 100644 index 0000000..117db20 --- /dev/null +++ b/abogen/web/static/wizard.js @@ -0,0 +1,426 @@ +const STEP_ORDER = ["book", "chapters", "entities"]; +const STEP_META = { + book: { + index: 1, + title: "Book parameters", + hint: "Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.", + }, + chapters: { + index: 2, + title: "Select chapters", + hint: "Choose which chapters to convert. We'll analyse entities automatically when you continue.", + }, + entities: { + index: 3, + title: "Review entities", + hint: "Assign pronunciations, voices, and manual overrides before queueing the conversion.", + }, +}; + +const wizardState = (window.AbogenWizardState = window.AbogenWizardState || { + initialized: false, + modal: null, + stage: null, + submitting: false, +}); + +const normalizeStep = (step) => { + let value = (step || "book").toLowerCase(); + if (value === "speakers") { + value = "entities"; + } + if (value === "settings" || value === "upload" || value === "") { + value = "book"; + } + if (!STEP_ORDER.includes(value)) { + return "book"; + } + return value; +}; + +const setButtonLoading = (button, isLoading) => { + if (!button) { + return; + } + if (isLoading) { + if (!button.dataset.originalDisabled) { + button.dataset.originalDisabled = button.disabled ? "true" : "false"; + } + button.disabled = true; + button.dataset.loading = "true"; + } else { + if (button.dataset.loading) { + delete button.dataset.loading; + } + const original = button.dataset.originalDisabled; + if (original !== undefined) { + button.disabled = original === "true"; + delete button.dataset.originalDisabled; + } else { + button.disabled = false; + } + } +}; + +const setSubmitting = (modal, isSubmitting, button) => { + if (!modal) return; + wizardState.submitting = isSubmitting; + if (isSubmitting) { + modal.dataset.submitting = "true"; + } else { + delete modal.dataset.submitting; + } + setButtonLoading(button, isSubmitting); +}; + +const findModal = () => document.querySelector('[data-role="new-job-modal"]'); + +const ensureModalRef = () => { + if (wizardState.modal && wizardState.modal.isConnected) { + return wizardState.modal; + } + wizardState.modal = findModal(); + return wizardState.modal; +}; + +const dispatchWizardEvent = (modal, type, detail = {}) => { + if (!modal) return; + const event = new CustomEvent(`wizard:${type}`, { bubbles: true, detail }); + modal.dispatchEvent(event); +}; + +const destroyTransientAlerts = (stage) => { + if (!stage) { + return; + } + const alerts = stage.querySelectorAll('[data-role="wizard-error"]'); + alerts.forEach((alert) => alert.remove()); +}; + +const displayTransientError = (modal, message) => { + if (!modal) return; + const stage = modal.querySelector('[data-role="wizard-stage"]'); + if (!stage) return; + const existing = stage.querySelector('[data-role="wizard-error"]'); + if (existing) { + existing.textContent = message; + return; + } + const alert = document.createElement("div"); + alert.className = "alert alert--error"; + alert.dataset.role = "wizard-error"; + alert.textContent = message; + stage.prepend(alert); +}; + +const updateStepIndicators = (modal, activeStep) => { + const indicators = modal.querySelectorAll('[data-role="wizard-step-indicator"]'); + const activeIndex = STEP_ORDER.indexOf(activeStep); + indicators.forEach((indicator) => { + const step = normalizeStep(indicator.dataset.step || "book"); + indicator.classList.remove("is-active", "is-complete"); + const index = STEP_ORDER.indexOf(step); + if (index < activeIndex) { + indicator.classList.add("is-complete"); + } else if (index === activeIndex) { + indicator.classList.add("is-active"); + } + }); +}; + +const updateHeaderCopy = (modal, step, payload) => { + const meta = STEP_META[step]; + if (!meta) { + return; + } + const titleEl = modal.querySelector("#new-job-modal-title"); + const hintEl = modal.querySelector('[data-role="wizard-hint"]'); + if (titleEl) { + titleEl.textContent = payload?.title || meta.title; + } + if (hintEl) { + hintEl.textContent = payload?.hint || meta.hint; + } + updateStepIndicators(modal, step); +}; + +const updateFilenameLabel = (modal, filename) => { + const label = modal.querySelector(".wizard-card__filename"); + if (!label) return; + if (filename) { + label.hidden = false; + label.textContent = filename; + label.setAttribute("title", filename); + } else { + label.hidden = true; + label.textContent = ""; + label.removeAttribute("title"); + } +}; + +const reinitializeStageModules = (stage) => { + if (!stage) return; + if (window.AbogenDashboard?.init) { + window.AbogenDashboard.init(); + } + if (window.AbogenPrepare?.init) { + window.AbogenPrepare.init(stage); + } +}; + +const focusFirstInteractive = (stage) => { + if (!stage) return; + const focusable = stage.querySelector( + 'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled])' + ); + if (focusable instanceof HTMLElement) { + try { + focusable.focus({ preventScroll: true }); + } catch (error) { + // Ignore focus errors, browser may block programmatic focus + } + } +}; + +const applyWizardPayload = (payload) => { + const modal = ensureModalRef(); + if (!modal) { + return; + } + if (payload.pending_id !== undefined) { + modal.dataset.pendingId = payload.pending_id || ""; + } + const step = normalizeStep(payload.step || modal.dataset.step || "book"); + modal.dataset.step = step; + modal.hidden = false; + modal.dataset.open = "true"; + document.body.classList.add("modal-open"); + updateHeaderCopy(modal, step, payload); + updateFilenameLabel(modal, payload.filename); + + const stage = modal.querySelector('[data-role="wizard-stage"]'); + if (stage) { + destroyTransientAlerts(stage); + stage.innerHTML = payload.html || ""; + wizardState.stage = stage; + reinitializeStageModules(stage); + focusFirstInteractive(stage); + } + + const stepDetail = { + step, + index: STEP_META[step]?.index || STEP_ORDER.indexOf(step) + 1, + total: STEP_ORDER.length, + pendingId: modal.dataset.pendingId || "", + notice: payload.notice || "", + error: payload.error || "", + }; + dispatchWizardEvent(modal, "step", stepDetail); +}; + +const handleWizardRedirect = (payload) => { + const modal = ensureModalRef(); + if (!modal) return; + modal.hidden = true; + delete modal.dataset.open; + document.body.classList.remove("modal-open"); + dispatchWizardEvent(modal, "done", { redirectUrl: payload.redirect_url }); + if (payload.redirect_url) { + window.location.assign(payload.redirect_url); + } +}; + +const processResponsePayload = (payload, responseOk) => { + if (!payload) { + return; + } + if (payload.redirect_url) { + handleWizardRedirect(payload); + return; + } + if (!payload.html && !responseOk) { + const modal = ensureModalRef(); + displayTransientError(modal, payload.error || "Something went wrong. Try again."); + return; + } + applyWizardPayload(payload); +}; + +const requestWizardStep = async (url, { method = "GET", body = undefined } = {}) => { + const modal = ensureModalRef(); + if (!modal) return; + try { + const response = await fetch(url, { + method, + body, + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + const text = await response.text(); + const payload = text ? JSON.parse(text) : null; + processResponsePayload(payload, response.ok); + } catch (error) { + console.error("Wizard request failed", error); + displayTransientError(modal, error?.message || "Unable to update the wizard. Try again."); + } +}; + +const submitWizardForm = async (form, submitter) => { + const modal = ensureModalRef(); + if (!modal) return; + if (wizardState.submitting) { + return; + } + const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || window.location.href; + const method = (submitter?.getAttribute("formmethod") || form.getAttribute("method") || "GET").toUpperCase(); + const formData = new FormData(form); + if (submitter && submitter.name && !formData.has(submitter.name)) { + formData.append(submitter.name, submitter.value ?? ""); + } + const allowValidation = !submitter?.hasAttribute("formnovalidate") && !form.noValidate; + if (allowValidation && typeof form.reportValidity === "function" && !form.reportValidity()) { + return; + } + + destroyTransientAlerts(modal.querySelector('[data-role="wizard-stage"]')); + setSubmitting(modal, true, submitter); + try { + const response = await fetch(action, { + method, + body: formData, + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch (parseError) { + console.error("Failed to parse wizard response", parseError); + displayTransientError(modal, "Received an invalid response. Try again."); + return; + } + processResponsePayload(payload, response.ok); + if (!response.ok && (!payload || !payload.html)) { + displayTransientError(modal, payload?.error || `Request failed (${response.status})`); + } + } catch (networkError) { + console.error("Wizard submission failed", networkError); + displayTransientError(modal, networkError?.message || "Unable to submit form. Check your connection and try again."); + } finally { + setSubmitting(modal, false, submitter); + } +}; + +const handleCancel = async (button) => { + const modal = ensureModalRef(); + if (!modal) return; + const pendingId = button?.dataset.pendingId || modal.dataset.pendingId; + const template = modal.dataset.cancelUrlTemplate || ""; + if (!pendingId || !template) { + modal.hidden = true; + delete modal.dataset.open; + document.body.classList.remove("modal-open"); + dispatchWizardEvent(modal, "cancel", { pendingId }); + return; + } + const url = template.replace("__pending__", encodeURIComponent(pendingId)); + try { + const response = await fetch(url, { + method: "POST", + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + if (response.ok) { + const text = await response.text(); + const payload = text ? JSON.parse(text) : null; + if (payload?.redirect_url) { + handleWizardRedirect(payload); + return; + } + } + } catch (error) { + console.error("Cancel request failed", error); + } + modal.hidden = true; + delete modal.dataset.open; + document.body.classList.remove("modal-open"); + dispatchWizardEvent(modal, "cancel", { pendingId }); +}; + +const handleBackToStep = (button) => { + const modal = ensureModalRef(); + if (!modal) return; + const targetStep = normalizeStep(button.dataset.targetStep || "book"); + if (targetStep === "book") { + return; // handled by prepare.js to reopen upload modal + } + const pendingId = button.dataset.pendingId || modal.dataset.pendingId || ""; + const template = modal.dataset.prepareUrlTemplate || ""; + if (!pendingId || !template) { + return; + } + const url = new URL(template.replace("__pending__", encodeURIComponent(pendingId)), window.location.origin); + url.searchParams.set("step", targetStep); + url.searchParams.set("format", "json"); + requestWizardStep(url.toString(), { method: "GET" }); +}; + +const handleWizardClick = (event) => { + const modal = ensureModalRef(); + if (!modal) return; + const cancelButton = event.target.closest('[data-role="wizard-cancel"]'); + if (cancelButton) { + event.preventDefault(); + event.stopPropagation(); + handleCancel(cancelButton); + return; + } + const backButton = event.target.closest('[data-role="wizard-back"]'); + if (backButton) { + const targetStep = normalizeStep(backButton.dataset.targetStep || "book"); + if (targetStep !== "book") { + event.preventDefault(); + event.stopPropagation(); + handleBackToStep(backButton); + } + } +}; + +const handleWizardSubmit = (event) => { + const form = event.target; + if (!(form instanceof HTMLFormElement)) { + return; + } + if (form.dataset.wizardForm !== "true") { + return; + } + const submitter = event.submitter || form.querySelector('button[type="submit"]'); + if (!submitter) { + return; + } + event.preventDefault(); + submitWizardForm(form, submitter); +}; + +const initWizard = () => { + if (wizardState.initialized) { + return; + } + const modal = ensureModalRef(); + if (!modal) { + return; + } + wizardState.initialized = true; + wizardState.modal = modal; + wizardState.stage = modal.querySelector('[data-role="wizard-stage"]'); + modal.addEventListener("submit", handleWizardSubmit, true); + modal.addEventListener("click", handleWizardClick); +}; + +window.AbogenWizard = window.AbogenWizard || {}; +window.AbogenWizard.init = initWizard; +window.AbogenWizard.requestStep = requestWizardStep; +window.AbogenWizard.applyPayload = applyWizardPayload; + +export { initWizard }; diff --git a/abogen/web/templates/base.html b/abogen/web/templates/base.html index 54a0066..be8d77e 100644 --- a/abogen/web/templates/base.html +++ b/abogen/web/templates/base.html @@ -19,7 +19,7 @@ {% set endpoint = request.endpoint or '' %} Dashboard Voice Mixer - Speakers + Entities Find Books Queue Settings diff --git a/abogen/web/templates/entities.html b/abogen/web/templates/entities.html new file mode 100644 index 0000000..63a284c --- /dev/null +++ b/abogen/web/templates/entities.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} + +{% block title %}abogen · Entities{% endblock %} + +{% block content %} +
+

Entities & Pronunciation Overrides

+

Review and refine stored pronunciations so recurring names sound right in every project.

+
+
+ + + + +
+
+ +
+ + Reset +
+
+
+

Looking for saved speaker rosters instead? Manage speaker presets.

+
+ +
+
+

Overrides for {{ language_label }}

+

+ Showing {{ stats.filtered }} of {{ stats.total }} stored overrides · + {{ stats.with_pronunciation }} with pronunciations · {{ stats.with_voice }} with assigned voices +

+
+ {% if overrides %} +
+ + + + + + + + + + + + + {% for override in overrides %} + + + + + + + + + {% endfor %} + +
TokenPronunciationVoiceNotesUsageLast updated
{{ override.token }}{{ override.pronunciation or "—" }}{{ override.voice or "—" }}{{ override.notes or "" }}{{ override.usage_count }}{{ override.updated_at_label }}
+
+ {% else %} +

No overrides matched your filters. Try adjusting the search or create overrides from the Entities step while preparing a job.

+ {% endif %} +
+{% endblock %} + +{% block scripts %} + {{ super() }} +{% endblock %} diff --git a/abogen/web/templates/partials/new_job_step_book.html b/abogen/web/templates/partials/new_job_step_book.html new file mode 100644 index 0000000..b785237 --- /dev/null +++ b/abogen/web/templates/partials/new_job_step_book.html @@ -0,0 +1,306 @@ +{% set pending = pending if pending is defined else None %} +{% set readonly = readonly if readonly is defined else False %} +{% set settings_dict = settings if settings is defined else {} %} +{% set options = options if options is defined else {} %} +{% set form_values = form_values if form_values is defined and form_values else {} %} +{% set language_value = form_values.get('language') if form_values else None %} +{% if not language_value %} + {% if pending and pending.language %} + {% set language_value = pending.language %} + {% else %} + {% set language_value = settings_dict.get('language', '') %} + {% endif %} +{% endif %} +{% if not language_value and options.languages %} + {% set sorted_languages = options.languages|dictsort %} + {% if sorted_languages %} + {% set language_value = sorted_languages[0][0] %} + {% endif %} +{% endif %} +{% set subtitle_value = form_values.get('subtitle_mode') if form_values else None %} +{% if not subtitle_value %} + {% if pending and pending.subtitle_mode %} + {% set subtitle_value = pending.subtitle_mode %} + {% else %} + {% set subtitle_value = settings_dict.get('subtitle_mode', 'Disabled') %} + {% endif %} +{% endif %} +{% set generate_flag = form_values.get('generate_epub3') if form_values else None %} +{% if generate_flag is not none %} + {% set generate_epub3 = True %} +{% else %} + {% set generate_epub3 = pending.generate_epub3 if pending else settings_dict.get('generate_epub3', False) %} +{% endif %} +{% set speaker_mode_value = form_values.get('speaker_mode') if form_values else None %} +{% if not speaker_mode_value %} + {% if pending and pending.speaker_mode %} + {% set speaker_mode_value = pending.speaker_mode %} + {% else %} + {% set speaker_mode_value = settings_dict.get('speaker_mode', 'single') %} + {% endif %} +{% endif %} +{% set chunk_level_value = form_values.get('chunk_level') if form_values else None %} +{% if not chunk_level_value %} + {% if pending and pending.chunk_level %} + {% set chunk_level_value = pending.chunk_level %} + {% else %} + {% set chunk_level_value = settings_dict.get('chunk_level', 'paragraph') %} + {% endif %} +{% endif %} +{% set analysis_threshold_value = form_values.get('speaker_analysis_threshold') if form_values else None %} +{% if not analysis_threshold_value %} + {% if pending and pending.speaker_analysis_threshold %} + {% set analysis_threshold_value = pending.speaker_analysis_threshold %} + {% else %} + {% set analysis_threshold_value = settings_dict.get('speaker_analysis_threshold', 3) %} + {% endif %} +{% endif %} +{% set chapter_delay_value = form_values.get('chapter_intro_delay') if form_values else None %} +{% if not chapter_delay_value %} + {% if pending and pending.chapter_intro_delay is not none %} + {% set chapter_delay_value = pending.chapter_intro_delay %} + {% else %} + {% set chapter_delay_value = settings_dict.get('chapter_intro_delay', 0.5) %} + {% endif %} +{% endif %} +{% set selected_config = form_values.get('speaker_config') if form_values else None %} +{% if selected_config is none %} + {% if pending and pending.applied_speaker_config %} + {% set selected_config = pending.applied_speaker_config %} + {% else %} + {% set selected_config = '' %} + {% endif %} +{% endif %} +{% set narrator_speed_value = form_values.get('speed') if form_values else None %} +{% if narrator_speed_value is none %} + {% if pending and pending.speed %} + {% set narrator_speed_value = pending.speed %} + {% else %} + {% set narrator_speed_value = settings_dict.get('default_speed', 1.0) %} + {% endif %} +{% endif %} +{% set narrator_speed = narrator_speed_value|float if narrator_speed_value is not none else 1.0 %} +{% set speed_display = '%.2f'|format(narrator_speed if narrator_speed else 1.0) %} +{% set form_profile = form_values.get('voice_profile') if form_values else None %} +{% set form_voice = form_values.get('voice') if form_values else None %} +{% set form_formula = form_values.get('voice_formula') if form_values else None %} +{% set narrator_profile = None %} +{% if form_profile is not none and form_profile != '' %} + {% set narrator_profile = form_profile %} +{% elif pending and pending.voice_profile %} + {% set narrator_profile = pending.voice_profile %} +{% else %} + {% set narrator_profile = '' %} +{% endif %} +{% set narrator_voice = None %} +{% if form_voice %} + {% set narrator_voice = form_voice %} +{% elif pending and pending.voice %} + {% set narrator_voice = pending.voice %} +{% else %} + {% set narrator_voice = settings_dict.get('default_voice', options.voices[0] if options.voices else '') %} +{% endif %} +{% set voice_formula_value = '' %} +{% set profile_value = narrator_profile if narrator_profile else '__standard' %} +{% if profile_value == '__formula' %} + {% if form_formula %} + {% set voice_formula_value = form_formula %} + {% elif pending and pending.voice %} + {% set voice_formula_value = pending.voice %} + {% endif %} +{% elif profile_value not in ['__standard', '', None] %} + {% set voice_formula_value = '' %} +{% else %} + {% if form_formula %} + {% set profile_value = '__formula' %} + {% set voice_formula_value = form_formula %} + {% elif narrator_voice and ('+' in narrator_voice or '*' in narrator_voice) %} + {% set profile_value = '__formula' %} + {% set voice_formula_value = narrator_voice %} + {% else %} + {% set profile_value = '__standard' %} + {% set voice_formula_value = '' %} + {% endif %} +{% endif %} +{% if profile_value == '__formula' and not voice_formula_value %} + {% if pending and pending.voice %} + {% set voice_formula_value = pending.voice %} + {% endif %} +{% endif %} +{% if profile_value != '__standard' and profile_value != '__formula' %} + {% set narrator_voice = '' %} +{% endif %} +{% if not narrator_voice and options.voices %} + {% set narrator_voice = options.voices[0] %} +{% endif %} + +{% if error %} +
{{ error }}
+{% endif %} +{% if notice %} +
{{ notice }}
+{% endif %} +
+ +
+ + +
+
diff --git a/abogen/web/templates/partials/new_job_step_chapters.html b/abogen/web/templates/partials/new_job_step_chapters.html new file mode 100644 index 0000000..649501a --- /dev/null +++ b/abogen/web/templates/partials/new_job_step_chapters.html @@ -0,0 +1,125 @@ +{% set is_multi_speaker = pending.speaker_mode == 'multi' %} +
+ + + + +
+ + +
+
diff --git a/abogen/web/templates/partials/new_job_step_entities.html b/abogen/web/templates/partials/new_job_step_entities.html new file mode 100644 index 0000000..f056df3 --- /dev/null +++ b/abogen/web/templates/partials/new_job_step_entities.html @@ -0,0 +1,466 @@ +{% set is_multi_speaker = pending.speaker_mode == 'multi' %} +{% set embed_scripts = embed_scripts if embed_scripts is defined else True %} +
+ + + + +
+ + +
+
+ + + + + + + + + +{% if embed_scripts %} + + + +{% endif %} diff --git a/abogen/web/templates/partials/upload_modal.html b/abogen/web/templates/partials/upload_modal.html index 3cb8e7a..7bf51b9 100644 --- a/abogen/web/templates/partials/upload_modal.html +++ b/abogen/web/templates/partials/upload_modal.html @@ -1,235 +1,72 @@ {% set pending = pending if pending is defined else None %} {% set readonly = readonly if readonly is defined else False %} -{% set modal_step = (active_step if active_step is defined else 'settings') or 'settings' %} {% set settings_dict = settings if settings is defined else {} %} -{% set is_multi = False %} -{% if pending %} - {% set is_multi = pending.speaker_mode == 'multi' %} -{% elif settings_dict %} - {% set is_multi = settings_dict.get('speaker_mode', 'single') == 'multi' %} -{% endif %} -{% set total_steps = 3 if is_multi else 2 %} -{% set language_value = pending.language if pending else settings_dict.get('language', '') %} -{% if not language_value %} - {% set sorted_languages = options.languages|dictsort %} - {% if sorted_languages %} - {% set language_value = sorted_languages[0][0] %} - {% endif %} -{% endif %} -{% set subtitle_value = pending.subtitle_mode if pending else settings_dict.get('subtitle_mode', 'Disabled') %} -{% set generate_epub3 = pending.generate_epub3 if pending else settings_dict.get('generate_epub3', False) %} -{% set speaker_mode_value = pending.speaker_mode if pending else settings_dict.get('speaker_mode', 'single') %} -{% set chunk_level_value = pending.chunk_level if pending else settings_dict.get('chunk_level', 'paragraph') %} -{% set analysis_threshold_value = pending.speaker_analysis_threshold if pending else settings_dict.get('speaker_analysis_threshold', 3) %} -{% set chapter_delay_value = pending.chapter_intro_delay if pending else settings_dict.get('chapter_intro_delay', 0.5) %} -{% set selected_config = pending.applied_speaker_config if pending else '' %} -{% set narrator_voice = pending.voice if pending else settings_dict.get('default_voice', options.voices[0] if options.voices else '') %} -{% set narrator_profile = pending.voice_profile if pending else '' %} -{% set narrator_speed = pending.speed if pending else 1.0 %} -{% set voice_formula_value = '' %} -{% set profile_value = '__standard' %} -{% if narrator_profile %} - {% set profile_value = narrator_profile %} -{% else %} - {% if narrator_voice and ('+' in narrator_voice or '*' in narrator_voice) %} - {% set profile_value = '__formula' %} - {% set voice_formula_value = narrator_voice %} - {% else %} - {% set profile_value = '__standard' %} - {% endif %} -{% endif %} -{% if not narrator_voice and options.voices %} - {% set narrator_voice = options.voices[0] %} -{% endif %} -{% set speed_display = '%.2f'|format(narrator_speed) %} -{% set step1_state = 'is-active' if modal_step in ['settings', 'upload'] else ('is-complete' if modal_step in ['chapters', 'entities'] else '') %} -{% set step2_state = 'is-active' if modal_step == 'chapters' else ('is-complete' if modal_step == 'entities' else '') %} -{% set step3_state = 'is-active' if modal_step == 'entities' else '' %} -