refactor: rename to common/, clients/desktop, clients/wearos
Move three directories into their target topology: - doctate-common/ -> common/ - client-desktop/ -> clients/desktop/ - watch/wearos/ -> clients/wearos/ Update doctate-common path-dependency in 3 Cargo.toml files (server, clients/desktop, experiments) and the workspace members list in the root Cargo.toml. Crate names unchanged (doctate-server, doctate-common, doctate-desktop). Workspace still in single-Cargo.lock form; isolation into standalone workspaces follows in stage 3. All 508 tests pass, experiments standalone-workspace also resolves the new path.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "doctate-common"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Shared types and helpers between doctate server and clients"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,54 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Server response to `POST /api/upload`. Shape is stable across all clients.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AckResponse {
|
||||
pub case_id: String,
|
||||
pub recorded_at: String,
|
||||
pub status: AckStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AckStatus {
|
||||
/// Recording accepted and persisted.
|
||||
Received,
|
||||
/// Case no longer exists server-side; client should delete its local copy.
|
||||
Gone,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn status_serializes_lowercase() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AckStatus::Received).unwrap(),
|
||||
"\"received\""
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&AckStatus::Gone).unwrap(), "\"gone\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_deserializes_lowercase() {
|
||||
let got: AckStatus = serde_json::from_str("\"received\"").unwrap();
|
||||
assert_eq!(got, AckStatus::Received);
|
||||
let got: AckStatus = serde_json::from_str("\"gone\"").unwrap();
|
||||
assert_eq!(got, AckStatus::Gone);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_roundtrip() {
|
||||
let original = AckResponse {
|
||||
case_id: "550e8400-e29b-41d4-a716-446655440000".into(),
|
||||
recorded_at: "2026-04-15T10:32:00Z".into(),
|
||||
status: AckStatus::Received,
|
||||
};
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let decoded: AckResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.case_id, original.case_id);
|
||||
assert_eq!(decoded.recorded_at, original.recorded_at);
|
||||
assert_eq!(decoded.status, original.status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Shared vocabulary for the admin bulk-action endpoint (`POST
|
||||
//! /web/cases/bulk`).
|
||||
//!
|
||||
//! The wire shape is `application/x-www-form-urlencoded` with an
|
||||
//! `action=<token>` field and one or more `case_id=<uuid>` repetitions.
|
||||
//! Keeping the set of accepted tokens in a single enum lets the server
|
||||
//! handler match exhaustively and lets tests build request bodies
|
||||
//! without string literals that can silently drift from the handler's
|
||||
//! accepted values.
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Which operation a bulk POST applies to every selected case.
|
||||
///
|
||||
/// Serialized as lowercase `"close" | "analyze" | "reset"`. The server
|
||||
/// handler [`BulkAction::from_str`]-parses the raw form value so an
|
||||
/// unknown token produces a domain error ("Unbekannte Aktion") instead
|
||||
/// of a serde/extractor error — preserving the HTTP contract across
|
||||
/// refactors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BulkAction {
|
||||
/// Mark each case closed (write `.closed` marker).
|
||||
Close,
|
||||
/// Queue each case for LLM analysis.
|
||||
Analyze,
|
||||
/// Drop derived artefacts (oneliner, document, analysis input) and
|
||||
/// revert `.m4a.failed` to `.m4a` so transcription self-heal retries.
|
||||
Reset,
|
||||
}
|
||||
|
||||
impl BulkAction {
|
||||
/// Lowercase on-the-wire token. Intended for test request bodies
|
||||
/// and redirect targets; the server's own deserializer goes through
|
||||
/// [`FromStr`] to preserve the exact error shape.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
BulkAction::Close => "close",
|
||||
BulkAction::Analyze => "analyze",
|
||||
BulkAction::Reset => "reset",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for BulkAction {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returned by [`BulkAction::from_str`] on an unknown token. Carries
|
||||
/// the offending input so the caller can log it without re-capturing.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnknownBulkAction(pub String);
|
||||
|
||||
impl fmt::Display for UnknownBulkAction {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "unknown bulk action: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for UnknownBulkAction {}
|
||||
|
||||
impl FromStr for BulkAction {
|
||||
type Err = UnknownBulkAction;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"close" => Ok(BulkAction::Close),
|
||||
"analyze" => Ok(BulkAction::Analyze),
|
||||
"reset" => Ok(BulkAction::Reset),
|
||||
other => Err(UnknownBulkAction(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_lowercase_tokens() {
|
||||
for v in [BulkAction::Close, BulkAction::Analyze, BulkAction::Reset] {
|
||||
assert_eq!(v, v.as_str().parse().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_tokens_are_stable() {
|
||||
assert_eq!(BulkAction::Close.as_str(), "close");
|
||||
assert_eq!(BulkAction::Analyze.as_str(), "analyze");
|
||||
assert_eq!(BulkAction::Reset.as_str(), "reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_token_reports_input() {
|
||||
let err = "foo".parse::<BulkAction>().unwrap_err();
|
||||
assert_eq!(err.0, "foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_matches_wire() {
|
||||
let json = serde_json::to_string(&BulkAction::Analyze).unwrap();
|
||||
assert_eq!(json, "\"analyze\"");
|
||||
let back: BulkAction = serde_json::from_str("\"reset\"").unwrap();
|
||||
assert_eq!(back, BulkAction::Reset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/// HTTP header that carries the per-user API key on `/api/...` requests.
|
||||
pub const API_KEY_HEADER: &str = "X-API-Key";
|
||||
|
||||
/// Server endpoint path for recording upload (relative to the server base URL).
|
||||
pub const UPLOAD_PATH: &str = "/api/upload";
|
||||
|
||||
// -- Multipart field names for POST /api/upload --
|
||||
|
||||
pub const FIELD_CASE_ID: &str = "case_id";
|
||||
pub const FIELD_RECORDED_AT: &str = "recorded_at";
|
||||
pub const FIELD_AUDIO: &str = "audio";
|
||||
|
||||
/// Content-Type of the audio part in the upload multipart body (m4a/AAC).
|
||||
pub const CONTENT_TYPE_AUDIO_MP4: &str = "audio/mp4";
|
||||
@@ -0,0 +1,18 @@
|
||||
pub mod ack;
|
||||
pub mod bulk;
|
||||
pub mod constants;
|
||||
pub mod oneliners;
|
||||
pub mod recordings;
|
||||
pub mod timestamp;
|
||||
pub mod url;
|
||||
|
||||
pub use ack::{AckResponse, AckStatus};
|
||||
pub use bulk::{BulkAction, UnknownBulkAction};
|
||||
pub use constants::{
|
||||
API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT,
|
||||
UPLOAD_PATH,
|
||||
};
|
||||
pub use oneliners::{ONELINER_FILENAME, ONELINERS_PATH, OnelinerEntry, OnelinersResponse};
|
||||
pub use recordings::{Loudness, RECORDING_META_SUFFIX, RecordingMeta, Transcript, TranscriptState};
|
||||
pub use timestamp::{filename_stem_to_recorded_at, now_rfc3339, recorded_at_to_filename_stem};
|
||||
pub use url::join_url;
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Shared DTOs for the `/api/oneliners` endpoint.
|
||||
//!
|
||||
//! Server serializes [`OnelinersResponse`]; clients deserialize the same
|
||||
//! type. Keeping this in a client-agnostic crate avoids drift between
|
||||
//! server output and client parsing.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// HTTP path of the oneliners endpoint.
|
||||
pub const ONELINERS_PATH: &str = "/api/oneliners";
|
||||
|
||||
/// Filename used by the server to persist [`OnelinerState`] per case
|
||||
/// (inside the case directory). Shared so both writer and readers
|
||||
/// agree on the exact path.
|
||||
pub const ONELINER_FILENAME: &str = "oneliner.json";
|
||||
|
||||
/// Outcome of the LLM oneliner generation for a single case.
|
||||
///
|
||||
/// Three disjoint outcomes used to collapse onto the same filesystem
|
||||
/// representation (missing file). Making them first-class lets the UI
|
||||
/// distinguish "LLM said nothing medical" (valid empty result) from
|
||||
/// "LLM call errored" (transient failure worth retrying) from "file
|
||||
/// simply isn't there yet" (no state persisted).
|
||||
///
|
||||
/// Serialized as internally-tagged JSON: `{"kind":"ready","text":...}`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum OnelinerState {
|
||||
/// LLM produced a usable one-sentence summary.
|
||||
Ready {
|
||||
text: String,
|
||||
/// RFC3339 UTC timestamp of the LLM call that produced this
|
||||
/// text. Diagnostic only — the file mtime drives ETag/sort.
|
||||
generated_at: String,
|
||||
},
|
||||
/// LLM deliberately returned an empty answer — the transcript
|
||||
/// contained no medical content. This is a valid outcome, not a
|
||||
/// failure, and recovery must not retry it.
|
||||
Empty { generated_at: String },
|
||||
/// LLM call failed (timeout, HTTP, parse, …). Details stay in the
|
||||
/// server logs on purpose; the UI surfaces only "error", and
|
||||
/// startup recovery retries this variant.
|
||||
Error { generated_at: String },
|
||||
/// Manual override set by the doctor via tap-to-edit. Sticky:
|
||||
/// blocks all auto-regeneration until a new manual edit replaces
|
||||
/// it. `set_at` is server-side RFC3339 UTC (avoids client clock skew).
|
||||
Manual { text: String, set_at: String },
|
||||
}
|
||||
|
||||
/// Full response body of `GET /api/oneliners`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OnelinersResponse {
|
||||
/// RFC3339 UTC timestamp at which the server built this response.
|
||||
pub as_of: String,
|
||||
/// Server-authoritative window the response covers, in hours.
|
||||
/// Derived from the user's `window_hours` entry in `users.toml`.
|
||||
/// **Clients must not use this value to apply a display filter** —
|
||||
/// the oneliners list is already exactly what the server wants shown.
|
||||
/// The field exists solely so reconciliation logic can anchor its
|
||||
/// "outside the window" rule (see
|
||||
/// `doctate-client-core::CaseStore::reconcile_with_server_snapshot`).
|
||||
pub window_hours: u32,
|
||||
/// Oneliners of cases whose earliest recording sits inside the window,
|
||||
/// sorted newest-first by `created_at`.
|
||||
pub oneliners: Vec<OnelinerEntry>,
|
||||
}
|
||||
|
||||
/// One case summary.
|
||||
///
|
||||
/// - `created_at` — earliest `.m4a` mtime in the case (first dictation
|
||||
/// in this case).
|
||||
/// - `last_recording_at` — latest `.m4a` mtime (most recent dictation,
|
||||
/// including addenda). This is the **primary sort key** for clients:
|
||||
/// "when did the doctor last work on this case?". Absent only if the
|
||||
/// case has no recordings at all (shouldn't happen in normal flow).
|
||||
/// - `updated_at` — `oneliner.json` mtime, i.e. when the oneliner state
|
||||
/// was last written. Diagnostic only — not for sort or display.
|
||||
/// - `oneliner` — the persisted state; `None` while no state file has
|
||||
/// been written yet for this case (fresh case, nothing transcribed).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OnelinerEntry {
|
||||
pub case_id: String,
|
||||
pub oneliner: Option<OnelinerState>,
|
||||
pub created_at: String,
|
||||
pub last_recording_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// HTTP path template for setting a manual oneliner override.
|
||||
/// `{case_id}` is replaced by the actual case id by routers and clients;
|
||||
/// shared so both sides agree on the exact URL shape.
|
||||
pub const ONELINER_OVERRIDE_PATH_TEMPLATE: &str = "/api/cases/{case_id}/oneliner";
|
||||
|
||||
/// Request body for `PUT /api/cases/{case_id}/oneliner`.
|
||||
///
|
||||
/// Only `text` travels over the wire — `set_at` is set server-side from
|
||||
/// the server clock, so clients with skewed/unset clocks (watch on
|
||||
/// battery save, etc.) cannot poison the timestamp.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OnelinerOverrideRequest {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn manual_variant_roundtrips() {
|
||||
let original = OnelinerState::Manual {
|
||||
text: "55 J., Knieschmerz links".to_owned(),
|
||||
set_at: "2026-04-26T18:33:00Z".to_owned(),
|
||||
};
|
||||
let json = serde_json::to_string(&original).expect("serialize");
|
||||
let parsed: OnelinerState = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(original, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_serializes_with_kind_tag() {
|
||||
let state = OnelinerState::Manual {
|
||||
text: "abc".to_owned(),
|
||||
set_at: "2026-04-26T18:33:00Z".to_owned(),
|
||||
};
|
||||
let json = serde_json::to_value(&state).expect("serialize");
|
||||
assert_eq!(json["kind"], "manual");
|
||||
assert_eq!(json["text"], "abc");
|
||||
assert_eq!(json["set_at"], "2026-04-26T18:33:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_request_deserializes_minimal_body() {
|
||||
let body = r#"{"text":"hello"}"#;
|
||||
let req: OnelinerOverrideRequest = serde_json::from_str(body).expect("deserialize");
|
||||
assert_eq!(req.text, "hello");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Recording-side domain types shared between server and clients.
|
||||
//!
|
||||
//! Each recording has a single sidecar JSON next to its audio file:
|
||||
//! `<stem>.json` (suffix [`RECORDING_META_SUFFIX`]). It carries the
|
||||
//! container duration and the transcript outcome in one structured
|
||||
//! document.
|
||||
//!
|
||||
//! On-disk vs. in-memory representation:
|
||||
//!
|
||||
//! - [`Transcript`] is the **on-disk** shape. It only models the two
|
||||
//! *terminal* outcomes the transcriber can persist (`Silent` or
|
||||
//! `Content`). A recording that has not been transcribed yet has no
|
||||
//! sidecar at all — there is no "pending" variant on disk.
|
||||
//! - [`TranscriptState`] is the **in-memory** shape returned by the
|
||||
//! server's reader. It adds a [`TranscriptState::Pending`] variant
|
||||
//! for "no sidecar exists", so callers can `match` on the three
|
||||
//! logically distinct states without having to inspect the
|
||||
//! filesystem themselves.
|
||||
//!
|
||||
//! Call-sites that collapse this onto a 2-state view (`Option<String>`,
|
||||
//! `.exists()`, `is_empty()`) drift apart and have caused real bugs:
|
||||
//! e.g. the UI treating `Silent` like `Content` while the oneliner
|
||||
//! worker treated it like `Pending`, leaving cases stuck in
|
||||
//! "Generating" forever.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Filename suffix of the per-recording metadata JSON sidecar.
|
||||
///
|
||||
/// Used as `<audio_stem>.<RECORDING_META_SUFFIX>` next to the `.m4a`,
|
||||
/// e.g. `2026-04-19T10-00-00Z.json`. Centralised here so the server
|
||||
/// reader, worker writer, and recovery scan agree on one location.
|
||||
pub const RECORDING_META_SUFFIX: &str = "json";
|
||||
|
||||
/// On-disk transcript outcome.
|
||||
///
|
||||
/// Internally tagged: serializes as `{"state": "silent"}` or
|
||||
/// `{"state": "content", "text": "..."}` — the discriminator lives
|
||||
/// inside the JSON object next to the data, so the wire format stays
|
||||
/// flat. Only the two terminal variants the transcriber can produce
|
||||
/// appear here; "not transcribed yet" is represented by the absence
|
||||
/// of the sidecar file (see [`TranscriptState::Pending`]).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "state", rename_all = "lowercase")]
|
||||
pub enum Transcript {
|
||||
/// Transcriber ran and classified the audio as silence — terminal.
|
||||
/// The next transcribe pass will not re-run unless the user
|
||||
/// re-uploads.
|
||||
Silent,
|
||||
/// Transcriber produced non-empty text.
|
||||
Content { text: String },
|
||||
}
|
||||
|
||||
/// Replay-gain data for one recording, derived from a single
|
||||
/// `volumedetect` pass at the end of the transcribe pipeline.
|
||||
///
|
||||
/// `gain_db` is applied browser-side via a Web Audio `GainNode` so
|
||||
/// recordings from clients with very different mic levels (Watch
|
||||
/// ~-38 dB mean, desktop ~-27 dB) play back at a consistent loudness;
|
||||
/// the audio file itself is never modified. `mean_db` and `max_db` are
|
||||
/// persisted alongside so the gain target can be retuned later without
|
||||
/// re-decoding the audio.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Loudness {
|
||||
pub mean_db: f64,
|
||||
pub max_db: f64,
|
||||
pub gain_db: f64,
|
||||
}
|
||||
|
||||
/// Per-recording metadata persisted as `<stem>.json`.
|
||||
///
|
||||
/// Single atomic write at the end of the transcribe pipeline. The
|
||||
/// presence of this file is the canonical signal "the transcriber
|
||||
/// finished for this recording". Absent file = still pending.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RecordingMeta {
|
||||
/// Transcriber outcome. See [`Transcript`].
|
||||
pub transcript: Transcript,
|
||||
/// Container duration in whole seconds, from `ffprobe` on the
|
||||
/// remuxed audio. `None` is allowed so test fixtures and recovery
|
||||
/// flows that mint a metadata file without invoking ffprobe stay
|
||||
/// valid; production worker writes always populate this.
|
||||
pub duration_seconds: Option<u32>,
|
||||
/// Replay-gain data. `None` for sidecars written before this field
|
||||
/// existed and for production paths where `volumedetect` failed.
|
||||
/// `#[serde(default)]` keeps older JSON without this field parsable.
|
||||
#[serde(default)]
|
||||
pub loudness: Option<Loudness>,
|
||||
}
|
||||
|
||||
/// Three-way state of a recording's transcript, as seen by the server
|
||||
/// after consulting the on-disk sidecar.
|
||||
///
|
||||
/// Introduced to force every call-site to decide explicitly how it
|
||||
/// treats `Silent` recordings via `match` exhaustiveness. Unlike
|
||||
/// [`Transcript`], this carries a [`Self::Pending`] variant — the
|
||||
/// reader synthesises that when the sidecar JSON does not exist.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TranscriptState {
|
||||
/// No sidecar JSON yet — transcriber hasn't produced a result.
|
||||
/// Callers should typically wait or enqueue.
|
||||
Pending,
|
||||
/// JSON exists with `state: "silent"`. Terminal: the transcriber
|
||||
/// already ran and classified this recording as silence.
|
||||
Silent,
|
||||
/// JSON exists with `state: "content"`.
|
||||
Content { text: String },
|
||||
}
|
||||
|
||||
impl From<Transcript> for TranscriptState {
|
||||
fn from(t: Transcript) -> Self {
|
||||
match t {
|
||||
Transcript::Silent => Self::Silent,
|
||||
Transcript::Content { text } => Self::Content { text },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TranscriptState {
|
||||
/// Build a `TranscriptState` from a parsed [`RecordingMeta`] (or its
|
||||
/// absence). `None` (file missing or malformed) becomes
|
||||
/// [`Self::Pending`].
|
||||
pub fn from_meta(meta: Option<&RecordingMeta>) -> Self {
|
||||
match meta {
|
||||
None => Self::Pending,
|
||||
Some(m) => m.transcript.clone().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the transcriber has finished for this recording,
|
||||
/// regardless of whether it produced content or silence. Use this
|
||||
/// for UI "is the transcription done?" checks — `Silent` counts.
|
||||
pub fn has_file(&self) -> bool {
|
||||
!matches!(self, Self::Pending)
|
||||
}
|
||||
|
||||
/// True when there is no sidecar yet and the transcriber still
|
||||
/// owes us a result. Use this for "should I enqueue transcription?"
|
||||
/// or "is a oneliner worth deferring?" gates.
|
||||
pub fn is_pending(&self) -> bool {
|
||||
matches!(self, Self::Pending)
|
||||
}
|
||||
|
||||
/// Non-empty transcript text, if any. Returns `None` for both
|
||||
/// `Pending` and `Silent` — callers that need to distinguish must
|
||||
/// `match` directly.
|
||||
pub fn as_content(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Content { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_meta_is_pending() {
|
||||
assert_eq!(TranscriptState::from_meta(None), TranscriptState::Pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silent_meta_maps_to_silent_state() {
|
||||
let meta = RecordingMeta {
|
||||
transcript: Transcript::Silent,
|
||||
duration_seconds: Some(7),
|
||||
loudness: None,
|
||||
};
|
||||
assert_eq!(
|
||||
TranscriptState::from_meta(Some(&meta)),
|
||||
TranscriptState::Silent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_meta_maps_to_content_state() {
|
||||
let meta = RecordingMeta {
|
||||
transcript: Transcript::Content {
|
||||
text: "Hallo".into(),
|
||||
},
|
||||
duration_seconds: None,
|
||||
loudness: None,
|
||||
};
|
||||
assert_eq!(
|
||||
TranscriptState::from_meta(Some(&meta)),
|
||||
TranscriptState::Content {
|
||||
text: "Hallo".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_file_distinguishes_pending() {
|
||||
assert!(!TranscriptState::Pending.has_file());
|
||||
assert!(TranscriptState::Silent.has_file());
|
||||
assert!(TranscriptState::Content { text: "x".into() }.has_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn as_content_only_for_content() {
|
||||
assert_eq!(TranscriptState::Pending.as_content(), None);
|
||||
assert_eq!(TranscriptState::Silent.as_content(), None);
|
||||
assert_eq!(
|
||||
TranscriptState::Content { text: "x".into() }.as_content(),
|
||||
Some("x")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_silent_serializes_with_tag() {
|
||||
let json = serde_json::to_string(&Transcript::Silent).unwrap();
|
||||
assert_eq!(json, r#"{"state":"silent"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_content_serializes_with_tag_and_text() {
|
||||
let json = serde_json::to_string(&Transcript::Content {
|
||||
text: "Hallo".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(json, r#"{"state":"content","text":"Hallo"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_meta_roundtrip_with_duration() {
|
||||
let meta = RecordingMeta {
|
||||
transcript: Transcript::Content {
|
||||
text: "Befund.".into(),
|
||||
},
|
||||
duration_seconds: Some(42),
|
||||
loudness: None,
|
||||
};
|
||||
let json = serde_json::to_string(&meta).unwrap();
|
||||
let back: RecordingMeta = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, meta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_meta_roundtrip_without_duration() {
|
||||
let meta = RecordingMeta {
|
||||
transcript: Transcript::Silent,
|
||||
duration_seconds: None,
|
||||
loudness: None,
|
||||
};
|
||||
let json = serde_json::to_string(&meta).unwrap();
|
||||
assert!(json.contains(r#""duration_seconds":null"#));
|
||||
let back: RecordingMeta = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, meta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_meta_roundtrip_with_loudness() {
|
||||
let meta = RecordingMeta {
|
||||
transcript: Transcript::Content {
|
||||
text: "Befund.".into(),
|
||||
},
|
||||
duration_seconds: Some(42),
|
||||
loudness: Some(Loudness {
|
||||
mean_db: -27.3,
|
||||
max_db: -3.1,
|
||||
gain_db: 2.1,
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_string(&meta).unwrap();
|
||||
let back: RecordingMeta = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, meta);
|
||||
}
|
||||
|
||||
/// Backwards-compat: a sidecar written before `loudness` existed
|
||||
/// must still parse, with the field defaulting to `None`. This is
|
||||
/// what `#[serde(default)]` on the field guarantees and is the only
|
||||
/// reason we don't need a migration step for pre-loudness recordings.
|
||||
#[test]
|
||||
fn old_meta_without_loudness_parses_as_none() {
|
||||
let json = r#"{"transcript":{"state":"silent"},"duration_seconds":7}"#;
|
||||
let meta: RecordingMeta = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(
|
||||
meta,
|
||||
RecordingMeta {
|
||||
transcript: Transcript::Silent,
|
||||
duration_seconds: Some(7),
|
||||
loudness: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::{OffsetDateTime, UtcOffset};
|
||||
|
||||
/// Current UTC time as an RFC3339 string with second precision, e.g.
|
||||
/// `2026-04-15T10:32:00Z`. Subsecond fractions are deliberately stripped
|
||||
/// so every caller produces the same filename shape — the server's
|
||||
/// filename parser expects exactly `YYYY-MM-DDTHH-MM-SSZ` after the
|
||||
/// `:` → `-` substitution, and dictation events don't need finer
|
||||
/// resolution than one second.
|
||||
pub fn now_rfc3339() -> String {
|
||||
OffsetDateTime::now_utc()
|
||||
.replace_nanosecond(0)
|
||||
.expect("0 is always a valid nanosecond value")
|
||||
.format(&Rfc3339)
|
||||
.expect("RFC3339 formatting of OffsetDateTime is infallible")
|
||||
}
|
||||
|
||||
/// Map an RFC3339 timestamp to a filesystem-safe stem by replacing every
|
||||
/// `:` with `-`. Intentionally naive — RFC3339 has no colons outside the
|
||||
/// time/offset portion, so the result is bijective against
|
||||
/// `filename_stem_to_recorded_at`.
|
||||
///
|
||||
/// Example: `"2026-04-15T10:32:00Z"` → `"2026-04-15T10-32-00Z"`.
|
||||
pub fn recorded_at_to_filename_stem(recorded_at: &str) -> String {
|
||||
recorded_at.replace(':', "-")
|
||||
}
|
||||
|
||||
/// Reverse of `recorded_at_to_filename_stem`. Only undoes the mapping in
|
||||
/// the time portion (after `T`) so date-part hyphens stay intact.
|
||||
///
|
||||
/// Example: `"2026-04-15T10-32-00Z"` → `"2026-04-15T10:32:00Z"`.
|
||||
pub fn filename_stem_to_recorded_at(stem: &str) -> String {
|
||||
match stem.split_once('T') {
|
||||
Some((date, time)) => format!("{}T{}", date, time.replace('-', ":")),
|
||||
None => stem.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an RFC3339 timestamp. Returns `None` on any error so callers
|
||||
/// can fall back to a lenient display path without having to classify
|
||||
/// failures.
|
||||
pub fn parse_rfc3339(s: &str) -> Option<OffsetDateTime> {
|
||||
OffsetDateTime::parse(s, &Rfc3339).ok()
|
||||
}
|
||||
|
||||
/// Extract `HH:MM` in the local timezone from an RFC3339 UTC timestamp.
|
||||
/// On parse failure, returns the raw string truncated to its first 16
|
||||
/// characters — the user still sees something readable even if the
|
||||
/// value is shaped unexpectedly.
|
||||
pub fn extract_hhmm(ts: &str) -> String {
|
||||
match parse_rfc3339(ts) {
|
||||
Some(t) => {
|
||||
let local = t.to_offset(UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC));
|
||||
format!("{:02}:{:02}", local.hour(), local.minute())
|
||||
}
|
||||
None => ts.chars().take(16).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filename_stem_roundtrip() {
|
||||
assert_eq!(
|
||||
filename_stem_to_recorded_at("2026-04-15T10-32-00Z"),
|
||||
"2026-04-15T10:32:00Z"
|
||||
);
|
||||
assert_eq!(
|
||||
filename_stem_to_recorded_at("2026-04-15T10-32-00.123Z"),
|
||||
"2026-04-15T10:32:00.123Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_at_stem_roundtrip_utc() {
|
||||
let original = "2026-04-15T10:32:00Z";
|
||||
let stem = recorded_at_to_filename_stem(original);
|
||||
assert_eq!(stem, "2026-04-15T10-32-00Z");
|
||||
assert_eq!(filename_stem_to_recorded_at(&stem), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_at_stem_roundtrip_with_milliseconds() {
|
||||
let original = "2026-04-15T10:32:00.123Z";
|
||||
let stem = recorded_at_to_filename_stem(original);
|
||||
assert_eq!(filename_stem_to_recorded_at(&stem), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_rfc3339_has_expected_shape() {
|
||||
let now = now_rfc3339();
|
||||
assert!(now.starts_with("20"), "unexpected year prefix: {now}");
|
||||
assert!(now.contains('T'), "missing T separator: {now}");
|
||||
}
|
||||
|
||||
/// Regression guard: subsecond precision must be stripped. A single
|
||||
/// nanosecond leak would feed the server filename parser
|
||||
/// `YYYY-MM-DDTHH-MM-SS.nnnnnnnnnZ.m4a`, which it treats as
|
||||
/// malformed — the resulting `<time datetime>` is empty, breaking
|
||||
/// browser-side TZ rendering.
|
||||
#[test]
|
||||
fn now_rfc3339_has_no_subsecond_component() {
|
||||
let now = now_rfc3339();
|
||||
assert!(
|
||||
!now.contains('.'),
|
||||
"now_rfc3339 must be second-granular, got {now}"
|
||||
);
|
||||
assert!(now.ends_with('Z'), "expected UTC 'Z' suffix, got {now}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rfc3339_accepts_utc_z() {
|
||||
let t = parse_rfc3339("2026-04-18T10:32:00Z").expect("parse UTC");
|
||||
assert_eq!(t.hour(), 10);
|
||||
assert_eq!(t.minute(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rfc3339_returns_none_on_garbage() {
|
||||
assert!(parse_rfc3339("not a timestamp").is_none());
|
||||
}
|
||||
|
||||
/// `extract_hhmm` depends on the host timezone for formatting, so we
|
||||
/// can't assert an exact value — but we can assert the shape and
|
||||
/// that the fallback kicks in on unparseable input.
|
||||
#[test]
|
||||
fn extract_hhmm_shape_is_two_colon_two() {
|
||||
let s = extract_hhmm("2026-04-18T10:32:00Z");
|
||||
assert_eq!(s.len(), 5, "expected HH:MM, got {s}");
|
||||
assert!(
|
||||
s.chars().nth(2) == Some(':'),
|
||||
"expected colon at index 2: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_hhmm_falls_back_to_truncated_raw_on_parse_failure() {
|
||||
assert_eq!(extract_hhmm("not-a-timestamp"), "not-a-timestamp");
|
||||
assert_eq!(
|
||||
extract_hhmm("way-longer-than-sixteen-chars"),
|
||||
"way-longer-than-"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Small URL-assembly helpers shared by every HTTP caller in the
|
||||
//! workspace.
|
||||
//!
|
||||
//! All clients (desktop, server-internal, server-to-Ollama/Whisper)
|
||||
//! end up building `{base_url}{path}` where `base_url` comes from
|
||||
//! config and may or may not carry a trailing slash. The raw
|
||||
//! `format!("{}{}", base.trim_end_matches('/'), path)` pattern was
|
||||
//! duplicated in seven call sites; centralizing it here keeps any
|
||||
//! future rule (enforce a leading slash on the path, tolerate
|
||||
//! `http://host/` with a path segment, normalize percent-encoding)
|
||||
//! in one place.
|
||||
|
||||
/// Join a base URL with an absolute path, normalizing any trailing
|
||||
/// slash on the base. The path is expected to start with `/`; no
|
||||
/// validation is performed — callers already pass constants like
|
||||
/// [`crate::UPLOAD_PATH`].
|
||||
///
|
||||
/// Examples:
|
||||
/// - `join_url("http://h", "/a")` → `"http://h/a"`
|
||||
/// - `join_url("http://h/", "/a")` → `"http://h/a"`
|
||||
/// - `join_url("http://h//", "/a")` → `"http://h/a"`
|
||||
pub fn join_url(base: &str, path: &str) -> String {
|
||||
let mut out = String::with_capacity(base.len() + path.len());
|
||||
out.push_str(base.trim_end_matches('/'));
|
||||
out.push_str(path);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_trailing_slash_on_base() {
|
||||
assert_eq!(join_url("http://h", "/a"), "http://h/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_trailing_slash_on_base() {
|
||||
assert_eq!(join_url("http://h/", "/a"), "http://h/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_trailing_slashes_on_base() {
|
||||
assert_eq!(join_url("http://h///", "/a"), "http://h/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_path() {
|
||||
assert_eq!(
|
||||
join_url("https://example.com/", "/api/upload"),
|
||||
"https://example.com/api/upload"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user