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) +}