{# poll_url and poll_interval come from status_view.with_poll, which builds the URL with status_view.poll_url; this template composes no URL. The poll carries the token of what this rendering shows, so an unchanged poll is answered 204 and focus inside the area stays put. tabindex="-1" is on every rendering because htmx restores focus by id only to a focusable element. autofocus is only on an action's response: on a poll it would pull focus out of the Title box every second. No line is aria-busy, because a screen reader may hold back a busy subtree inside a live region and "Scanning..." would never be heard; class="busy-line" draws the spinner instead. #}
{% if job %} {% if job.is_busy %} {# Built on the server: templates compose no vocabulary. #}

{{ busy_line }}

{% elif job.state == JobState.AWAITING_FLIP %} {# The controls are emitted only for the owner, or for anyone when the job has no owner token; never hide them with CSS instead. The title gate is the other way round: a job with no owner token is answerable by anyone but shows its title to no one. There is deliberately no cancel control or second timer here: the flip timeout already covers a closed tab. non_owner_line and the waiting lines below end in a full stop, not an ellipsis, on purpose: nothing is happening on this viewer's behalf. #} {% if flip_answer %}

{{ flip_answer | flip_answer_label }}

{% elif is_owner %} {% include "partials/flip.html" %} {% else %}

{{ non_owner_line }}

{% endif %} {% elif job.state in pass_wait_states %} {# The same owner gate as the flip prompt. Before the worker publishes the question the owner sees busy_line, the state's progress wording, not non_owner_line. Neither waiting line is a busy-line: a spinner makes a scan that waits for a person look hung. #} {% if pass_answer %}

{{ pass_answer | pass_answer_label }}

{% elif is_owner and pass_copy %} {% include "partials/pages_prompt.html" %} {% elif not is_owner %}

{{ non_owner_line }}

{% else %}

{{ busy_line }}

{% endif %} {% elif job.state == JobState.DONE %} {# Guard on counts being None, never on a count's truthiness: error, cancel, a refused submit and rows older than the count columns leave them NULL, while a measured 0 renders as 0. A warned upload reads amber and is not an alert: it is a degraded success. #} {% if job.warning %}

⚠ {{ job.state | outcome_line(job.warning, job.title) }}

{{ job.warning }}

{% else %}

✓ {{ job.state | outcome_line(job.warning, job.title) }}

{% endif %} {% set counts = job | page_counts %} {% if counts %}

{{ counts }}

{% endif %} {% set removed = job | removed_pages %} {% if removed %}

{{ removed }}

{% endif %} {% if terminal_reload %}{% include "partials/terminal_reload.html" %}{% endif %} {% elif job.state == JobState.FALLBACK %}

→ {{ job.state | outcome_line(job.warning, job.title) }}

{% if job.warning %}

{{ job.warning }}

{% endif %} {% set counts = job | page_counts %} {% if counts %}

{{ counts }}

{% endif %} {% set removed = job | removed_pages %} {% if removed %}

{{ removed }}

{% endif %} {% if terminal_reload %}{% include "partials/terminal_reload.html" %}{% endif %} {% elif job.state == JobState.CANCELLED %} {# A cancel is a deliberate stop, not a failure, so it is not announced as an alert. #}

⊘ Cancelled: {{ job.title }}

{% if terminal_reload %}{% include "partials/terminal_reload.html" %}{% endif %} {% elif job.state == JobState.ERROR %} {# The alert wraps the next step as well as the sentence, so a screen reader hears it;
stays outside, so "Technical details" is not announced. job.error comes from the job view, which has already replaced every host path. A NULL error_category is a row from before categories and keeps the plain shape. An amber category may already be in paperless-ngx, so it is not an alert: red says "scan it again". #} {% if job.error_category %} {% if job.error_category | is_amber_category %}

⚠ {{ job.error_category | error_message }}

{{ job.error_category | error_next_step }}

{% else %}

✗ {{ job.error_category | error_message }}

{{ job.error_category | error_next_step }}

{% endif %}
Technical details

{{ job.error }}

Category: {{ job.error_category }}

Job: {{ job.id }}

{% else %} {% endif %} {% if terminal_reload %}{% include "partials/terminal_reload.html" %}{% endif %} {% endif %} {% if job.thumbnail %} First page preview {% endif %} {% else %} {# idle_line stays plain text even when it carries the blocked reason: the red reason already sits under the button. On the full page an ended job arrives as last_job and is reported muted, as the past, so a fresh page never presents an old result as news. #}

{{ idle_line }}

{% if last_job %}

{{ last_scan_line }}

{% if last_scan_detail %}

{{ last_scan_detail }}

{% endif %} {% if last_job.state == JobState.ERROR and last_job.error_category %}
Technical details

{{ last_job.error }}

Category: {{ last_job.error_category }}

Job: {{ last_job.id }}

{% endif %} {% endif %} {% endif %}