use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::config::AppConfig; use crate::error::{PixstripError, Result}; use crate::preset::Preset; fn default_config_dir() -> PathBuf { dirs::config_dir() .or_else(|| dirs::home_dir().map(|h| h.join(".config"))) .unwrap_or_else(std::env::temp_dir) .join("pixstrip") } /// Write to a temporary file then rename, for crash safety. pub fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> { let tmp = path.with_extension("tmp"); std::fs::write(&tmp, contents)?; std::fs::rename(&tmp, path)?; Ok(()) } fn sanitize_filename(name: &str) -> String { name.chars() .map(|c| match c { '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*' => '_', _ => c, }) .collect() } // --- Preset Store --- pub struct PresetStore { presets_dir: PathBuf, } impl PresetStore { pub fn new() -> Self { Self { presets_dir: default_config_dir().join("presets"), } } pub fn with_base_dir(base: &Path) -> Self { Self { presets_dir: base.join("presets"), } } fn ensure_dir(&self) -> Result<()> { std::fs::create_dir_all(&self.presets_dir).map_err(PixstripError::Io) } fn preset_path(&self, name: &str) -> PathBuf { let filename = format!("{}.json", sanitize_filename(name)); self.presets_dir.join(filename) } pub fn save(&self, preset: &Preset) -> Result<()> { self.ensure_dir()?; let path = self.preset_path(&preset.name); let json = serde_json::to_string_pretty(preset) .map_err(|e| PixstripError::Preset(e.to_string()))?; atomic_write(&path, &json).map_err(PixstripError::Io) } pub fn load(&self, name: &str) -> Result { let path = self.preset_path(name); if !path.exists() { return Err(PixstripError::Preset(format!( "Preset '{}' not found", name ))); } let data = std::fs::read_to_string(&path).map_err(PixstripError::Io)?; serde_json::from_str(&data).map_err(|e| PixstripError::Preset(e.to_string())) } pub fn list(&self) -> Result> { if !self.presets_dir.exists() { return Ok(Vec::new()); } let mut presets = Vec::new(); for entry in std::fs::read_dir(&self.presets_dir).map_err(PixstripError::Io)? { let entry = entry.map_err(PixstripError::Io)?; let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("json") { let data = std::fs::read_to_string(&path).map_err(PixstripError::Io)?; if let Ok(preset) = serde_json::from_str::(&data) { presets.push(preset); } } } presets.sort_by(|a, b| a.name.cmp(&b.name)); Ok(presets) } pub fn delete(&self, name: &str) -> Result<()> { let path = self.preset_path(name); if !path.exists() { return Err(PixstripError::Preset(format!( "Preset '{}' not found", name ))); } std::fs::remove_file(&path).map_err(PixstripError::Io) } pub fn export_to_file(&self, preset: &Preset, path: &Path) -> Result<()> { let json = serde_json::to_string_pretty(preset) .map_err(|e| PixstripError::Preset(e.to_string()))?; atomic_write(path, &json).map_err(PixstripError::Io) } pub fn import_from_file(&self, path: &Path) -> Result { let data = std::fs::read_to_string(path).map_err(PixstripError::Io)?; let mut preset: Preset = serde_json::from_str(&data).map_err(|e| PixstripError::Preset(e.to_string()))?; preset.is_custom = true; Ok(preset) } } impl Default for PresetStore { fn default() -> Self { Self::new() } } // --- Config Store --- pub struct ConfigStore { config_path: PathBuf, } impl ConfigStore { pub fn new() -> Self { Self { config_path: default_config_dir().join("config.json"), } } pub fn with_base_dir(base: &Path) -> Self { Self { config_path: base.join("config.json"), } } pub fn save(&self, config: &AppConfig) -> Result<()> { if let Some(parent) = self.config_path.parent() { std::fs::create_dir_all(parent).map_err(PixstripError::Io)?; } let json = serde_json::to_string_pretty(config) .map_err(|e| PixstripError::Config(e.to_string()))?; atomic_write(&self.config_path, &json).map_err(PixstripError::Io) } pub fn load(&self) -> Result { if !self.config_path.exists() { return Ok(AppConfig::default()); } let data = std::fs::read_to_string(&self.config_path).map_err(PixstripError::Io)?; serde_json::from_str(&data).map_err(|e| PixstripError::Config(e.to_string())) } } impl Default for ConfigStore { fn default() -> Self { Self::new() } } // --- Session Store --- #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct SessionState { pub last_input_dir: Option, pub last_output_dir: Option, pub last_preset_name: Option, pub current_step: u32, pub window_width: Option, pub window_height: Option, pub window_maximized: bool, // Last-used wizard settings pub resize_enabled: Option, pub resize_width: Option, pub resize_height: Option, pub convert_enabled: Option, pub convert_format: Option, pub compress_enabled: Option, pub quality_preset: Option, pub metadata_enabled: Option, pub metadata_mode: Option, pub watermark_enabled: Option, pub rename_enabled: Option, pub last_seen_version: Option, pub expanded_sections: std::collections::HashMap, } pub struct SessionStore { session_path: PathBuf, } impl SessionStore { pub fn new() -> Self { Self { session_path: default_config_dir().join("session.json"), } } pub fn with_base_dir(base: &Path) -> Self { Self { session_path: base.join("session.json"), } } pub fn save(&self, state: &SessionState) -> Result<()> { if let Some(parent) = self.session_path.parent() { std::fs::create_dir_all(parent).map_err(PixstripError::Io)?; } let json = serde_json::to_string_pretty(state) .map_err(|e| PixstripError::Config(e.to_string()))?; atomic_write(&self.session_path, &json).map_err(PixstripError::Io) } pub fn load(&self) -> Result { if !self.session_path.exists() { return Ok(SessionState::default()); } let data = std::fs::read_to_string(&self.session_path).map_err(PixstripError::Io)?; serde_json::from_str(&data).map_err(|e| PixstripError::Config(e.to_string())) } } impl Default for SessionStore { fn default() -> Self { Self::new() } } // --- History Store --- #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HistoryEntry { pub timestamp: String, pub input_dir: String, pub output_dir: String, pub preset_name: Option, pub total: usize, pub succeeded: usize, pub failed: usize, pub total_input_bytes: u64, pub total_output_bytes: u64, pub elapsed_ms: u64, pub output_files: Vec, } pub struct HistoryStore { history_path: PathBuf, } impl HistoryStore { pub fn new() -> Self { Self { history_path: default_config_dir().join("history.json"), } } pub fn with_base_dir(base: &Path) -> Self { Self { history_path: base.join("history.json"), } } pub fn add(&self, entry: HistoryEntry, max_entries: usize, max_days: u32) -> Result<()> { let mut entries = self.list()?; entries.push(entry); self.write_all(&entries)?; self.prune(max_entries, max_days) } pub fn prune(&self, max_entries: usize, max_days: u32) -> Result<()> { let mut entries = self.list()?; if entries.is_empty() { return Ok(()); } let now_secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); let cutoff_secs = now_secs.saturating_sub(max_days as u64 * 86400); // Remove entries older than max_days (keep entries with unparseable timestamps) entries.retain(|e| { e.timestamp.parse::().map_or(true, |ts| ts >= cutoff_secs) }); // Trim to max_entries (keep the most recent) if entries.len() > max_entries { let start = entries.len() - max_entries; entries = entries.split_off(start); } self.write_all(&entries) } pub fn list(&self) -> Result> { if !self.history_path.exists() { return Ok(Vec::new()); } let data = std::fs::read_to_string(&self.history_path).map_err(PixstripError::Io)?; if data.trim().is_empty() { return Ok(Vec::new()); } serde_json::from_str(&data).map_err(|e| PixstripError::Config(e.to_string())) } pub fn clear(&self) -> Result<()> { self.write_all(&Vec::::new()) } pub fn write_all(&self, entries: &[HistoryEntry]) -> Result<()> { if let Some(parent) = self.history_path.parent() { std::fs::create_dir_all(parent).map_err(PixstripError::Io)?; } let json = serde_json::to_string_pretty(entries) .map_err(|e| PixstripError::Config(e.to_string()))?; atomic_write(&self.history_path, &json).map_err(PixstripError::Io) } } impl Default for HistoryStore { fn default() -> Self { Self::new() } }