diff --git a/crates/onyx-core/src/google_tasks.rs b/crates/onyx-core/src/google_tasks.rs index 8bf3fb1..e13ecd4 100644 --- a/crates/onyx-core/src/google_tasks.rs +++ b/crates/onyx-core/src/google_tasks.rs @@ -452,15 +452,7 @@ fn render_task_markdown(task: &Task) -> String { /// Sanitize a string for use as a filesystem path component. fn sanitize_name(name: &str) -> String { - let s: String = name.chars() - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', - '\0'..='\x1f' => '_', - _ => c, - }) - .collect::() - .trim_matches(|c: char| c == '.' || c == ' ') - .to_string(); + let s = crate::sanitize_filename(name); if s.is_empty() { "Untitled".to_string() } else { s } } diff --git a/crates/onyx-core/src/lib.rs b/crates/onyx-core/src/lib.rs index b8c5120..8e85ca0 100644 --- a/crates/onyx-core/src/lib.rs +++ b/crates/onyx-core/src/lib.rs @@ -11,3 +11,30 @@ pub use models::{Task, TaskStatus, TaskList}; pub use repository::TaskRepository; pub use config::{AppConfig, WorkspaceConfig}; pub use error::{Error, Result}; + +/// Sanitize a string for use as a filesystem path component. +/// Replaces filesystem-unsafe characters with underscores, trims leading/trailing +/// dots and spaces, and prefixes Windows reserved device names. +pub(crate) fn sanitize_filename(name: &str) -> String { + let sanitized: String = name.chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + '\0'..='\x1f' => '_', + _ => c, + }) + .collect::() + .trim_matches(|c: char| c == '.' || c == ' ') + .to_string(); + // Reject Windows reserved device names (CON, PRN, AUX, NUL, COM0-9, LPT0-9) + let stem = sanitized.split('.').next().unwrap_or("").to_uppercase(); + let is_reserved = matches!(stem.as_str(), + "CON" | "PRN" | "AUX" | "NUL" + | "COM0" | "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9" + | "LPT0" | "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9" + ); + if is_reserved { + format!("_{}", sanitized) + } else { + sanitized + } +} diff --git a/crates/onyx-core/src/storage.rs b/crates/onyx-core/src/storage.rs index 42d5801..c966af0 100644 --- a/crates/onyx-core/src/storage.rs +++ b/crates/onyx-core/src/storage.rs @@ -237,27 +237,7 @@ impl FileSystemStorage { } fn sanitize_filename(name: &str) -> String { - let sanitized: String = name.chars() - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', - '\0'..='\x1f' => '_', - _ => c, - }) - .collect::() - .trim_matches(|c: char| c == '.' || c == ' ') - .to_string(); - // Reject Windows reserved device names (CON, PRN, AUX, NUL, COM0-9, LPT0-9) - let stem = sanitized.split('.').next().unwrap_or("").to_uppercase(); - let is_reserved = matches!(stem.as_str(), - "CON" | "PRN" | "AUX" | "NUL" - | "COM0" | "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9" - | "LPT0" | "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9" - ); - if is_reserved { - format!("_{}", sanitized) - } else { - sanitized - } + crate::sanitize_filename(name) } fn task_file_path(&self, list_dir: &Path, task: &Task) -> PathBuf {