From 19d841e64496e4bcf1c31513c69b7258ab202ad3 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 18 Apr 2026 10:09:00 +0200 Subject: [PATCH] Add new config fields for oneliner polling Introduces `oneliner_poll_interval_seconds` and `oneliner_window_hours` to the client configuration. These fields allow users to customize how frequently the client polls for new oneliners and the time window for the data fetched from the server. Default values are provided for these fields to ensure backward compatibility and a sensible out-of-the-box experience. The `Config` struct now also includes helper methods `poll_interval()` and `window_hours()` for easier access to these settings, including the application of defaults. Additionally, new functions `cases_dir()` and `snapshot_cache_path()` are added to `client-desktop/src/paths.rs` to manage directories for case data and the oneliner snapshot cache, respectively. These paths follow OS-standard conventions for local application data. --- client-desktop/Cargo.toml | 3 ++ client-desktop/src/config.rs | 72 ++++++++++++++++++++++++++++++++++++ client-desktop/src/paths.rs | 17 +++++++++ 3 files changed, 92 insertions(+) diff --git a/client-desktop/Cargo.toml b/client-desktop/Cargo.toml index c1fdfbc..253a731 100644 --- a/client-desktop/Cargo.toml +++ b/client-desktop/Cargo.toml @@ -6,17 +6,20 @@ description = "Desktop dictation client for the doctate medical dictation system [dependencies] doctate-common = { path = "../doctate-common" } +doctate-client-core = { path = "../doctate-client-core" } eframe = "0.28" reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } tokio = { workspace = true } +time = { workspace = true } tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { workspace = true } directories = "5" thiserror = "1" +webbrowser = "1" which = "6" [target.'cfg(unix)'.dependencies] diff --git a/client-desktop/src/config.rs b/client-desktop/src/config.rs index 25546d9..670e853 100644 --- a/client-desktop/src/config.rs +++ b/client-desktop/src/config.rs @@ -1,4 +1,5 @@ use std::path::{Path, PathBuf}; +use std::time::Duration; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; @@ -6,6 +7,10 @@ use thiserror::Error; const CONFIG_FILENAME: &str = "client.toml"; +/// Fallback values used when the TOML file omits the field. +const DEFAULT_POLL_INTERVAL_SECONDS: u64 = 5; +const DEFAULT_ONELINER_WINDOW_HOURS: u32 = 72; + /// Client configuration loaded from a TOML file at OS-standard paths. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Config { @@ -13,6 +18,14 @@ pub struct Config { pub server_url: String, /// Per-user API key used in the `X-API-Key` header on upload. pub api_key: String, + /// Polling cadence for `/api/oneliners`. Omit in TOML to use the + /// built-in default (5 s). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oneliner_poll_interval_seconds: Option, + /// Time window handed to the server as `?hours=N`. Omit in TOML to + /// use the built-in default (72 h = 3 days). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oneliner_window_hours: Option, } #[derive(Debug, Error)] @@ -92,6 +105,21 @@ impl Config { pub fn save(&self) -> Result<(), ConfigError> { self.save_to(&default_config_path()?) } + + /// Polling cadence as a [`Duration`], substituting the default if + /// the field was omitted. + pub fn poll_interval(&self) -> Duration { + Duration::from_secs( + self.oneliner_poll_interval_seconds + .unwrap_or(DEFAULT_POLL_INTERVAL_SECONDS), + ) + } + + /// Window-hours parameter for `/api/oneliners`, with default. + pub fn window_hours(&self) -> u32 { + self.oneliner_window_hours + .unwrap_or(DEFAULT_ONELINER_WINDOW_HOURS) + } } #[cfg(test)] @@ -103,6 +131,8 @@ mod tests { Config { server_url: "https://doctate.example.org".into(), api_key: "sk-test-12345".into(), + oneliner_poll_interval_seconds: None, + oneliner_window_hours: None, } } @@ -141,4 +171,46 @@ mod tests { let err = Config::load_from(&path).unwrap_err(); assert!(matches!(err, ConfigError::Parse { .. })); } + + #[test] + fn defaults_when_fields_omitted() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("minimal.toml"); + std::fs::write( + &path, + "server_url = \"http://x\"\napi_key = \"y\"\n", + ) + .unwrap(); + let cfg = Config::load_from(&path).unwrap().unwrap(); + assert_eq!(cfg.poll_interval(), Duration::from_secs(5)); + assert_eq!(cfg.window_hours(), 72); + } + + #[test] + fn explicit_values_override_defaults() { + let cfg = Config { + server_url: "http://x".into(), + api_key: "y".into(), + oneliner_poll_interval_seconds: Some(10), + oneliner_window_hours: Some(24), + }; + assert_eq!(cfg.poll_interval(), Duration::from_secs(10)); + assert_eq!(cfg.window_hours(), 24); + } + + #[test] + fn roundtrip_preserves_new_fields() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("client.toml"); + let original = Config { + server_url: "http://x".into(), + api_key: "y".into(), + oneliner_poll_interval_seconds: Some(3), + oneliner_window_hours: Some(168), + }; + original.save_to(&path).unwrap(); + let loaded = Config::load_from(&path).unwrap().unwrap(); + assert_eq!(loaded.oneliner_poll_interval_seconds, Some(3)); + assert_eq!(loaded.oneliner_window_hours, Some(168)); + } } diff --git a/client-desktop/src/paths.rs b/client-desktop/src/paths.rs index b70dca4..9d9df47 100644 --- a/client-desktop/src/paths.rs +++ b/client-desktop/src/paths.rs @@ -21,3 +21,20 @@ pub fn pending_dir() -> Result { .map(|dirs| dirs.data_local_dir().join("pending")) .ok_or(PathError::NoDataDir) } + +/// Directory holding one JSON marker per known case (locally created +/// and/or server-synced). Persists across app restarts. +/// Linux: `~/.local/share/doctate/cases/` +pub fn cases_dir() -> Result { + ProjectDirs::from("", "", "doctate") + .map(|dirs| dirs.data_local_dir().join("cases")) + .ok_or(PathError::NoDataDir) +} + +/// File path for the last-successful `/api/oneliners` response. Feeds +/// the UI at cold start before the first live poll completes. +pub fn snapshot_cache_path() -> Result { + ProjectDirs::from("", "", "doctate") + .map(|dirs| dirs.data_local_dir().join("oneliners_cache.json")) + .ok_or(PathError::NoDataDir) +}