feat: Enhance job management UI and add apostrophe normalization

- Updated styles for job cards to include a paused state and improved title styling.
- Modified job card template to display job status and progress more clearly, including pause/resume functionality.
- Introduced a new script for managing chapter row states in the prepare job form, allowing for dynamic enabling/disabling of inputs.
- Created a new template for preparing jobs, featuring a summary of metadata and chapter details.
- Added a comprehensive apostrophe normalization module to handle various cases of apostrophe usage in text.
This commit is contained in:
JB
2025-10-07 05:34:53 -07:00
parent 85310ad916
commit 718a3fa1c0
9 changed files with 1426 additions and 26 deletions
+22 -8
View File
@@ -7,23 +7,37 @@
{% if active_jobs %}
<ul class="job-cards">
{% for job in active_jobs %}
<li class="job-card">
{% set progress_value = ((job.progress or 0) * 100)|round(1) %}
<li class="job-card" data-status="{{ job.status.value }}">
<div class="job-card__header">
<div>
<strong>{{ job.original_filename }}</strong>
<div class="job-card__meta">{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}</div>
<a class="job-card__title" href="{{ url_for('web.job_detail', job_id=job.id) }}">{{ job.original_filename }}</a>
<div class="job-card__meta">
{% if job.queue_position %}Position #{{ job.queue_position }} · {% endif %}
{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}
</div>
{% if job.pause_requested and job.status == JobStatus.RUNNING %}
<div class="job-card__meta job-card__meta--warning">Pause requested — waiting for a safe point…</div>
{% endif %}
</div>
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
</div>
<div class="job-card__progress">
<div class="progress-bar" style="--progress: {{ ((job.progress or 0) * 100)|round(1) }}%">
<div class="progress-bar" style="--progress: {{ progress_value }}%">
<div class="progress-bar__fill"></div>
</div>
<small>{{ job.processed_characters }} / {{ job.total_characters or '—' }}</small>
<small>{{ progress_value }}% · {{ job.processed_characters }} / {{ job.total_characters or '—' }}</small>
</div>
<div class="job-card__footer">
<a class="button button--ghost" href="{{ url_for('web.job_detail', job_id=job.id) }}">Inspect</a>
{% if job.status in [JobStatus.PENDING, JobStatus.RUNNING] %}
<a class="button button--ghost" href="{{ url_for('web.job_detail', job_id=job.id) }}">Details</a>
{% if job.status == JobStatus.RUNNING %}
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.pause_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Pause</button>
{% elif job.status == JobStatus.PAUSED %}
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.resume_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Resume</button>
{% elif job.status == JobStatus.PENDING %}
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.pause_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Pause</button>
{% endif %}
{% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %}
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.cancel_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Cancel this conversion?">Cancel</button>
{% endif %}
</div>
@@ -48,7 +62,7 @@
<li class="job-card">
<div class="job-card__header">
<div>
<strong>{{ job.original_filename }}</strong>
<a class="job-card__title" href="{{ url_for('web.job_detail', job_id=job.id) }}">{{ job.original_filename }}</a>
<div class="job-card__meta">Finished {{ job.finished_at|datetimeformat }}</div>
</div>
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
+120
View File
@@ -0,0 +1,120 @@
{% extends "base.html" %}
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
{% block content %}
<section class="card">
<div class="card__title">Prepare audiobook</div>
<p class="card__subtitle">Review the detected chapters, choose which ones to render, and optionally override the voice per chapter before sending the job to the queue.</p>
<div class="prepare-summary">
<dl>
<div>
<dt>Source</dt>
<dd>{{ pending.original_filename }}</dd>
</div>
<div>
<dt>Language</dt>
<dd>{{ options.languages.get(pending.language, pending.language|upper) }}</dd>
</div>
<div>
<dt>Default voice</dt>
<dd>{% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}</dd>
</div>
<div>
<dt>Total chapters</dt>
<dd>{{ pending.chapters|length }}</dd>
</div>
<div>
<dt>Characters</dt>
<dd>{{ pending.total_characters|default(0) }}</dd>
</div>
</dl>
{% if pending.metadata_tags %}
<div class="prepare-metadata">
<h2>Metadata</h2>
<ul>
{% for key, value in pending.metadata_tags.items() %}
<li><strong>{{ key|replace('_', ' ')|title }}:</strong> {{ value }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if error %}
<div class="alert alert--error">{{ error }}</div>
{% endif %}
<form method="post" action="{{ url_for('web.finalize_job', pending_id=pending.id) }}" class="prepare-form" id="prepare-form">
<div class="chapter-grid">
{% for chapter in pending.chapters %}
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
{% set selected_option = '__default' %}
{% if chapter.voice_profile %}
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
{% elif chapter.voice %}
{% set selected_option = 'voice:' ~ chapter.voice %}
{% elif chapter.voice_formula %}
{% set selected_option = 'formula' %}
{% endif %}
<article class="chapter-card" data-role="chapter-row">
<header class="chapter-card__header">
<div class="chapter-card__title">
<label class="chapter-card__checkbox">
<input type="checkbox" name="chapter-{{ loop.index0 }}-enabled" data-role="chapter-enabled" {% if is_enabled %}checked{% endif %}>
<span>Chapter {{ loop.index }} · {{ chapter.title }}</span>
</label>
</div>
<div class="chapter-card__metrics">
{{ chapter.characters }} characters
</div>
</header>
<div class="chapter-card__body">
<div class="chapter-card__field">
<label for="chapter-{{ loop.index0 }}-title">Title</label>
<input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
</div>
<div class="chapter-card__preview">
<details>
<summary>Preview text</summary>
<pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre>
</details>
</div>
<div class="chapter-card__field">
<label for="chapter-{{ loop.index0 }}-voice">Voice override</label>
<select id="chapter-{{ loop.index0 }}-voice" name="chapter-{{ loop.index0 }}-voice" data-role="voice-select">
<option value="__default" {% if selected_option == '__default' %}selected{% endif %}>Use job default</option>
<optgroup label="Voices">
{% for voice in options.voices %}
<option value="voice:{{ voice }}" {% if selected_option == 'voice:' ~ voice %}selected{% endif %}>{{ voice }}</option>
{% endfor %}
</optgroup>
{% if options.voice_profile_options %}
<optgroup label="Profiles">
{% for profile in options.voice_profile_options %}
<option value="profile:{{ profile.name }}" {% if selected_option == 'profile:' ~ profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="formula" {% if selected_option == 'formula' %}selected{% endif %}>Custom formula…</option>
</select>
<input type="text" name="chapter-{{ loop.index0 }}-formula" class="chapter-card__formula" data-role="formula-input" placeholder="af_nova*0.4+am_liam*0.6" value="{{ chapter.voice_formula or '' }}" {% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
</div>
</div>
</article>
{% endfor %}
</div>
<div class="prepare-actions">
<button type="submit" class="button">Queue conversion</button>
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div>
</form>
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
</section>
{% endblock %}
{% block scripts %}
{{ super() }}
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
{% endblock %}