Commit Graph

13 Commits

Author SHA1 Message Date
Brummel 66b3b7e4c8 Refactor recording metadata to JSON sidecar
Replaces the `.transcript.txt` sidecar with a structured `.json` file
for recording metadata. This change consolidates transcript text,
duration, and other potential metadata into a single, extensible JSON
object.

This also refactors the `TranscriptState` enum to better represent the
on-disk state (absence of file means pending) and the in-memory
representation. The `Transcript` enum now specifically models the
terminal outcomes of the transcriber (`Silent` or `Content`).

The commit includes updates to documentation, data structures, path
handling, and various tests to align with the new metadata format.
2026-04-27 12:48:25 +02:00
Brummel eaed8f0dcd fix: Inline recording-delete confirm + stable redirects
- Replace native confirm() in the recording list with an inline
  two-step confirm row; hide the player via visibility:hidden
  while open so the row height stays stable.
- Hard-redirect recording delete back to /web/cases/{id}/recordings
  instead of relying on the Referer header (which silently fell
  back to /web/cases when stripped).
- Switch analyze/reset to a hidden return_to form field so the
  source page (case detail vs. case list) is preserved reliably,
  with same-origin /web/ prefix validation.
- Tighten delete_recording_test on the concrete Location header;
  adjust analyze/reset redirect expectations to the new
  case-detail fallback.
2026-04-26 17:52:29 +02:00
Brummel 2e3b5efe86 feat: render CSRF token into state-changing forms
Adds the csrf_token hidden field to every POST form in the web UI
(logout, bulk, purge-closed, close, reopen, analyze, reset,
delete-recording). Login stays without a token — SameSite=Strict
already blocks the relevant attack shapes and forced-login CSRF
has no impact here.

Shape:
- New askama macro partials/csrf_field.html renders the hidden
  input; 3 templates import + {% call csrf::field(csrf_token) %}
  inside each <form method="POST">.
- AuthenticatedWebUser carries csrf_token (cloned out of the
  session once during extraction) so render handlers don't need a
  second store lookup.
- 3 template structs (MyCasesTemplate, CasePageTemplate,
  CaseRecordingsTemplate) gain the field; render sites pass
  user.csrf_token through.

Safety-net tests:
- Each rendered page must contain the exact session csrf_token in
  a hidden input — catches anyone adding a new form without the
  macro.
- Happy-path round-trip: fetch page, parse token from HTML, POST
  with token → 303. Catches drift between rendered and accepted
  token formats.
2026-04-22 10:04:21 +02:00
Brummel 1f32d4dd23 fix: separate transient from permanent transcribe failures
Before, every Whisper error (5xx, timeout, Minerva down, corrupt audio,
4xx) renamed `<ts>.m4a` to `<ts>.m4a.failed` uniformly, turning transient
outages into permanent sackgassen that only a manual admin reset could
undo. Cases whose every recording was `.m4a.failed` stuck without a
persisted oneliner state; the UI masked them via a fallback in
`compute_oneliner_display`.

The core insight: a recoverable error is not an error. Transient Whisper
failures now leave the audio as plain `.m4a` so the existing page-load
heal (`enqueue_pending_for_user`) re-enqueues it on the next refresh —
which is exactly what happens when Minerva comes back. No new sidecar,
no retry count, no scheduler.

Changes:
- `WhisperError::is_transient` classifies `Http`, 5xx, 408, 429 as
  transient; `Io` and other 4xx as permanent.
- Transcribe worker: transient → info log + continue (audio stays .m4a);
  permanent → `mark_failed` as before.
- `has_any_transcript` counts `.m4a.failed` as terminal, so
  `update_oneliner` runs for failed-only cases and settles
  `OnelinerState::Empty` (analogous to the silent-only fix in 4531f85).
- New verdrängender `fehler`-Badge (#c00) in case list and detail when
  at least one `.m4a.failed` exists; recording-level message shortened
  to plain "Transkription fehlgeschlagen".

Tests:
- New integration: transient 503 leaves `.m4a` intact, heal recovers.
- New integration: `.m4a.failed`-only case settles to
  `OnelinerState::Empty` without calling Ollama.
- New unit: `is_transient` table test across relevant status codes.
- New unit: `has_any_transcript` returns true for `.m4a.failed`-only
  case and false for pending-only case.
- Existing worker test retargeted from 500 to 400 and renamed; added
  companion `worker_leaves_m4a_intact_on_transient_whisper_error`.
2026-04-21 14:39:31 +02:00
Brummel 4531f85b13 fix: settle silent-only cases to Empty oneliner state
Introduce `TranscriptState { Pending, Silent, Content }` in
doctate-common as the canonical three-way state of a recording's
`.transcript.txt` sidecar. Previously each call-site projected the
raw `Option<String>` / `.exists()` onto its own 2-state view and the
projections disagreed: the UI treated a 0-byte silent transcript like
`Content` while the oneliner worker treated it like `Pending`,
leaving silent-only cases stuck on "generiere Titel …" forever with
no persisted oneliner.json.

`update_oneliner` now settles a silent-only case to
`OnelinerState::Empty` when no `Content` transcript exists and no
recording is still `Pending`, so the UI resolves to "unbenannt" and
recovery treats it as terminal.

Single reader: `paths::read_transcript_state`. All call-sites
(scan_recordings, compute_oneliner_display, has_pending_recordings,
all_transcripts_joined, read_recordings, enqueue_pending_for_user,
scan_m4as, cases_needing_oneliner_in, case_recordings.html) go
through the same typed abstraction and must handle `Silent` via
exhaustive match.

Regression tests:
- silent_case_empty_test: heal path settles silent-only case to
  Empty without calling Ollama
- case_page_silent_only_shows_empty_not_generating: UI renders
  "unbenannt", not "generiere Titel …"
2026-04-21 13:37:51 +02:00
Brummel 5effa7c96e feat: Add endpoint to delete recordings
This commit introduces a new API endpoint for deleting recordings. The
endpoint
handles the removal of the audio file and its associated sidecar files
(transcript,
duration). It also invalidates derived artifacts like oneliner.json,
document.md,
and analysis_input.json. Additionally, it includes checks for path
traversal,
correct file extensions, and case ownership to ensure security and data
integrity.
A new `RecordingDeleted` event is also added to the `CaseEventKind`
enum.
2026-04-20 15:48:34 +02:00
Brummel 424330aad4 Fix player seek bar alignment
Adjust the `justify-content` of the player to align items to the right,
and limit the `max-width` of the seek bar to prevent overflow and
improve layout on smaller screens.
2026-04-20 15:12:19 +02:00
Brummel 70425939b2 feat: Add auto-trigger for LLM analysis
Introduces a new module `auto_trigger` responsible for opportunistically
initiating LLM analysis jobs. This mechanism scans case directories for
new or updated recordings and transcripts, automatically creating and
queuing analysis jobs when appropriate.

Key components:
- `evaluate_case`: Pure function to determine if a case is ready for
  analysis based on recording status, transcriptions, document
  modification times, and existing job/failure markers.
- `try_enqueue`: Orchestrates the analysis job creation and queuing
  process if `evaluate_case` returns `AutoDecision::Enqueue`.
- `try_enqueue_all_for_user`: Iterates through all user cases to trigger
  `try_enqueue` for eligible ones.
- `write_failure_marker`/`remove_failure_marker`: Handles persistent
  recording of analysis failures and their cleanup.

This feature aims to reduce manual intervention by automatically
analyzing new or changed case data.
2026-04-20 09:18:30 +02:00
Brummel 1d702e2d85 Add event bus for live UI updates
Introduces a system for broadcasting real-time events to the web UI.
This enables features like automatic page reloads when case data changes
in the background, improving the user experience by keeping the UI
synchronized with the backend.

Key components:
- `events` module: Contains the `CaseEventKind` enum, `CaseEvent`
  struct, and `EventSender` type for managing the broadcast channel.
- SSE endpoint (`/web/events`): Streams events to connected browsers.
- Client-side JavaScript: Listens for events and triggers debounced page
  reloads.
- Integration points: Workers and route handlers now emit events when
  relevant state changes occur.
2026-04-19 23:33:53 +02:00
Brummel 17aa5d7200 Implement audio range requests
This commit enables serving audio files via HTTP Range requests. This is
crucial for allowing HTML5 audio players to seek to specific positions
within an audio file without re-downloading the entire file.

The changes include:
- Modifying `handle_audio` in `server/src/routes/web.rs` to parse
  `Range` headers.
- Implementing `serve_range` to handle partial content responses.
- Adding a `parse_range` helper function.
- Updating tests to verify range request functionality.
- Adding `ACCEPT_RANGES: bytes` header to indicate support for range
  requests.
- Storing recording duration in a sidecar file for faster UI rendering.
- Enhancing the HTML template to support a custom audio player with
  seeking.
2026-04-19 22:26:04 +02:00
Brummel 9cc4dc6384 feat: Add admin view toggle to web UI
Introduces a toggle in the web UI that allows administrators to switch
between a standard view and an "admin view". This admin view exposes
additional administrative elements that are hidden in the standard view.

The toggle state is persisted in local storage, allowing the user's
preference to be remembered across sessions. Non-administrator users
will not see the toggle.
feat: Add admin view toggle to web UI

Introduce a client-side toggle for an "admin view" in the web UI. This
allows administrators
to selectively hide or show elements intended only for administrative
purposes. The toggle
state is persisted in local storage for a persistent user experience.

The implementation involves:
- Adding a checkbox element to the header of the case pages and my cases
  list.
- Using CSS to conditionally hide elements with the `admin-only` class
  when the admin view is off.
- Implementing JavaScript to manage the toggle's state, update local
  storage, and apply the
  `admin-view-off` class to the `<html>` element.
- Updating relevant templates (`case_page.html`, `case_recordings.html`,
  `my_cases.html`)
  to include the toggle and the `admin-only` class where appropriate.
- Adding unit tests to verify the visibility of the toggle for admins
  and non-admins.
2026-04-19 18:46:11 +02:00
Brummel 6369ff1680 feat: Add admin flag to user and templates
Passes an `is_admin` flag to the `CaseRecordingsTemplate` and
`case_page.html`.
This flag determines whether the full case ID is displayed in the meta
section
for administrative users. It also influences the header display on the
case page
to prioritize the oneliner when available.
2026-04-19 17:33:29 +02:00
Brummel 3bb2d23adb Refactor case pages into two distinct routes
This commit separates the case detail page into two distinct routes:
`/web/cases/{case_id}` for the main case information and document, and
`/web/cases/{case_id}/recordings` for a dedicated view of audio files
and their transcripts.

This change improves the organization and clarity of the case viewing
experience by segmenting the related but distinct information into their
own UI sections.
2026-04-19 17:02:16 +02:00