use serde::{Deserialize, Serialize}; use std::env; use std::path::PathBuf; #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct ConfigFile { #[serde(default)] pub server: Server, #[serde(default)] pub display: ConfDisplay, #[serde(default)] pub analytics: Analytics, #[serde(default)] pub filtering: Filtering, #[serde(default)] pub sharing: Sharing, #[serde(default)] pub ai: Ai, #[serde(default)] pub scraping: Scraping, #[serde(default)] pub processing: Processing, #[serde(default)] pub migration: ConfMigration, #[serde(default)] pub cli: Cli, } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct ContentQuality { pub min_content_length: u32, pub max_content_length: u32, pub min_text_html_ratio: f32, pub readability_threshold: f32, } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct DuplicateDetection { pub enabled: bool, pub title_similarity_threshold: f32, pub content_similarity_threshold: f32, pub check_historical_days: u32, pub store_fingerprints: bool, } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct AdPrevention { pub enabled: bool, pub block_iframes: bool, pub clean_content: bool, pub ad_patterns: Vec, pub preserved_elements: Vec, pub removed_elements: Vec, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub enum DefaultView { Compact, Full, Summary, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub enum Theme { Light, Dark, Auto, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub enum DefaultSort { #[serde(rename = "added_desc")] AddedDesc, #[serde(rename = "published_desc")] PublishedDesc, #[serde(rename = "title_asc")] TitleAsc, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub enum DefaultFormat { #[serde(rename = "text")] Text, #[serde(rename = "markdown")] Markdown, #[serde(rename = "json")] JSON, #[serde(rename = "html")] HTML, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub enum AiProvider { #[serde(rename = "ollama")] Ollama, #[serde(rename = "openai")] OpenAi, #[serde(rename = "anthropic")] Anthropic, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub enum DefaultOutput { #[serde(rename = "table")] Table, #[serde(rename = "json")] JSON, #[serde(rename = "csv")] CSV, } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct Server { #[serde(default = "Server::default_host")] pub host: String, #[serde(default = "Server::default_port")] pub port: u16, } impl Server { fn default_host() -> String { Server::default().host } fn default_port() -> u16 { Server::default().port } } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct ConfDisplay { #[serde(default)] pub default_view: DefaultView, #[serde(default = "ConfDisplay::default_articles_per_page")] pub articles_per_page: u32, #[serde(default = "ConfDisplay::default_show_reading_time")] pub show_reading_time: bool, #[serde(default = "ConfDisplay::default_show_word_count")] pub show_word_count: bool, #[serde(default = "ConfDisplay::default_highlight_unread")] pub highlight_unread: bool, #[serde(default)] pub theme: Theme, } impl ConfDisplay { fn default_articles_per_page() -> u32 { ConfDisplay::default().articles_per_page } fn default_show_reading_time() -> bool { ConfDisplay::default().show_reading_time } fn default_show_word_count() -> bool { ConfDisplay::default().show_word_count } fn default_highlight_unread() -> bool { ConfDisplay::default().highlight_unread } } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct Analytics { #[serde(default = "Analytics::default_enabled")] pub enabled: bool, #[serde(default = "Analytics::default_track_reading_time")] pub track_reading_time: bool, #[serde(default = "Analytics::default_track_scroll_position")] pub track_scroll_position: bool, #[serde(default = "Analytics::default_retention_days")] pub retention_days: u32, #[serde(default = "Analytics::default_aggregate_older_data")] pub aggregate_older_data: bool, } impl Analytics { fn default_enabled() -> bool { Analytics::default().enabled } fn default_track_reading_time() -> bool { Analytics::default().track_reading_time } fn default_track_scroll_position() -> bool { Analytics::default().track_scroll_position } fn default_retention_days() -> u32 { Analytics::default().retention_days } fn default_aggregate_older_data() -> bool { Analytics::default().aggregate_older_data } } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct Filtering { #[serde(default = "Filtering::default_enable_smart_suggestions")] pub enable_smart_suggestions: bool, #[serde(default = "Filtering::default_max_recent_filters")] pub max_recent_filters: u32, #[serde(default = "Filtering::default_auto_save_filters")] pub auto_save_filters: bool, #[serde(default)] pub default_sort: DefaultSort, #[serde(default = "Filtering::default_enable_geographic_hierarchy")] pub enable_geographic_hierarchy: bool, #[serde(default = "Filtering::default_auto_migrate_country_filters")] pub auto_migrate_country_filters: bool, } impl Filtering { fn default_enable_smart_suggestions() -> bool { Filtering::default().enable_smart_suggestions } fn default_max_recent_filters() -> u32 { Filtering::default().max_recent_filters } fn default_auto_save_filters() -> bool { Filtering::default().auto_save_filters } fn default_enable_geographic_hierarchy() -> bool { Filtering::default().enable_geographic_hierarchy } fn default_auto_migrate_country_filters() -> bool { Filtering::default().auto_migrate_country_filters } } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct Sharing { #[serde(default)] pub default_format: DefaultFormat, #[serde(default = "Sharing::default_include_summary")] pub include_summary: bool, #[serde(default = "Sharing::default_include_tags")] pub include_tags: bool, #[serde(default = "Sharing::default_include_source")] pub include_source: bool, #[serde(default = "Sharing::default_copy_to_clipboard")] pub copy_to_clipboard: bool, #[serde(default)] pub templates: SharingTemplates, } impl Sharing { fn default_include_summary() -> bool { Sharing::default().include_summary } fn default_include_tags() -> bool { Sharing::default().include_tags } fn default_include_source() -> bool { Sharing::default().include_source } fn default_copy_to_clipboard() -> bool { Sharing::default().copy_to_clipboard } } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct SharingTemplates { #[serde(default = "SharingTemplate::default_text")] pub text: SharingTemplate, #[serde(default = "SharingTemplate::default_markdown")] pub markdown: SharingTemplate, } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct SharingTemplate { pub format: String, } impl Default for SharingTemplate { fn default() -> Self { // Fallback empty only if used generically; specific fields use the richer defaults below. Self { format: String::new(), } } } impl SharingTemplate { pub fn default_text() -> Self { SharingTemplates::default().text } pub fn default_markdown() -> Self { SharingTemplates::default().markdown } } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct Ai { #[serde(default = "Ai::default_enabled")] pub enabled: bool, #[serde(default)] pub provider: AiProvider, #[serde(default = "Ai::default_timeout_seconds")] pub timeout_seconds: u32, #[serde(default)] pub summary: AiSummary, #[serde(default)] pub tagging: AiTagging, } impl Ai { fn default_enabled() -> bool { Ai::default().enabled } fn default_timeout_seconds() -> u32 { Ai::default().timeout_seconds } } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct AiSummary { #[serde(default = "AiSummary::default_enabled")] pub enabled: bool, #[serde(default = "AiSummary::default_temperature")] pub temperature: f32, #[serde(default = "AiSummary::default_max_tokens")] pub max_tokens: u32, } impl AiSummary { fn default_enabled() -> bool { AiSummary::default().enabled } fn default_temperature() -> f32 { AiSummary::default().temperature } fn default_max_tokens() -> u32 { AiSummary::default().max_tokens } } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct AiTagging { #[serde(default = "AiTagging::default_enabled")] pub enabled: bool, #[serde(default = "AiTagging::default_temperature")] pub temperature: f32, #[serde(default = "AiTagging::default_max_tokens")] pub max_tokens: u32, #[serde(default = "AiTagging::default_max_tags_per_article")] pub max_tags_per_article: u32, #[serde(default = "AiTagging::default_min_confidence_threshold")] pub min_confidence_threshold: f32, #[serde(default = "AiTagging::default_enable_geographic_tagging")] pub enable_geographic_tagging: bool, #[serde(default = "AiTagging::default_enable_category_tagging")] pub enable_category_tagging: bool, #[serde(default = "AiTagging::default_geographic_hierarchy_levels")] pub geographic_hierarchy_levels: u32, } impl AiTagging { fn default_enabled() -> bool { AiTagging::default().enabled } fn default_temperature() -> f32 { AiTagging::default().temperature } fn default_max_tokens() -> u32 { AiTagging::default().max_tokens } fn default_max_tags_per_article() -> u32 { AiTagging::default().max_tags_per_article } fn default_min_confidence_threshold() -> f32 { AiTagging::default().min_confidence_threshold } fn default_enable_geographic_tagging() -> bool { AiTagging::default().enable_geographic_tagging } fn default_enable_category_tagging() -> bool { AiTagging::default().enable_category_tagging } fn default_geographic_hierarchy_levels() -> u32 { AiTagging::default().geographic_hierarchy_levels } } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct Scraping { #[serde(default = "Scraping::default_timeout_seconds")] pub timeout_seconds: u32, #[serde(default = "Scraping::default_max_retries")] pub max_retries: u32, #[serde(default = "Scraping::default_max_content_length")] pub max_content_length: u32, #[serde(default = "Scraping::default_respect_robots_txt")] pub respect_robots_txt: bool, #[serde(default = "Scraping::default_rate_limit_delay_ms")] pub rate_limit_delay_ms: u32, #[serde(default)] pub content_quality: ContentQuality, #[serde(default)] pub duplicate_detection: DuplicateDetection, #[serde(default)] pub ad_prevention: AdPrevention, } impl Scraping { fn default_timeout_seconds() -> u32 { Scraping::default().timeout_seconds } fn default_max_retries() -> u32 { Scraping::default().max_retries } fn default_max_content_length() -> u32 { Scraping::default().max_content_length } fn default_respect_robots_txt() -> bool { Scraping::default().respect_robots_txt } fn default_rate_limit_delay_ms() -> u32 { Scraping::default().rate_limit_delay_ms } } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct Processing { #[serde(default = "Processing::default_batch_size")] pub batch_size: u32, #[serde(default = "Processing::default_max_concurrent")] pub max_concurrent: u32, #[serde(default = "Processing::default_retry_attempts")] pub retry_attempts: u32, #[serde(default = "Processing::default_priority_manual")] pub priority_manual: bool, #[serde(default = "Processing::default_auto_mark_read_on_view")] pub auto_mark_read_on_view: bool, } impl Processing { fn default_batch_size() -> u32 { Processing::default().batch_size } fn default_max_concurrent() -> u32 { Processing::default().max_concurrent } fn default_retry_attempts() -> u32 { Processing::default().retry_attempts } fn default_priority_manual() -> bool { Processing::default().priority_manual } fn default_auto_mark_read_on_view() -> bool { Processing::default().auto_mark_read_on_view } } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct ConfMigration { #[serde(default = "ConfMigration::default_auto_convert_country_filters")] pub auto_convert_country_filters: bool, #[serde(default = "ConfMigration::default_preserve_legacy_data")] pub preserve_legacy_data: bool, #[serde(default = "ConfMigration::default_migration_batch_size")] pub migration_batch_size: u32, } impl ConfMigration { fn default_auto_convert_country_filters() -> bool { ConfMigration::default().auto_convert_country_filters } fn default_preserve_legacy_data() -> bool { ConfMigration::default().preserve_legacy_data } fn default_migration_batch_size() -> u32 { ConfMigration::default().migration_batch_size } } #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(default)] pub struct Cli { #[serde(default)] pub default_output: DefaultOutput, #[serde(default = "Cli::default_pager_command")] pub pager_command: String, #[serde(default = "Cli::default_show_progress")] pub show_progress: bool, #[serde(default = "Cli::default_auto_confirm_bulk")] pub auto_confirm_bulk: bool, #[serde(default = "Cli::default_show_geographic_hierarchy")] pub show_geographic_hierarchy: bool, } impl Cli { fn default_pager_command() -> String { Cli::default().pager_command } fn default_show_progress() -> bool { Cli::default().show_progress } fn default_auto_confirm_bulk() -> bool { Cli::default().auto_confirm_bulk } fn default_show_geographic_hierarchy() -> bool { Cli::default().show_geographic_hierarchy } } impl Default for ContentQuality { fn default() -> Self { Self { min_content_length: 100, max_content_length: 500_000, min_text_html_ratio: 0.3, readability_threshold: 0.6, } } } impl Default for DuplicateDetection { fn default() -> Self { Self { enabled: true, title_similarity_threshold: 0.90, content_similarity_threshold: 0.85, check_historical_days: 30, store_fingerprints: true, } } } impl Default for AdPrevention { fn default() -> Self { Self { enabled: true, block_iframes: true, clean_content: true, ad_patterns: vec![ "sponsored-content".to_string(), "advertisement".to_string(), "promoted-post".to_string(), "partner-content".to_string(), "ad-wrapper".to_string(), "sponsored".to_string(), ], preserved_elements: vec![ "article".to_string(), "p".to_string(), "h1".to_string(), "h2".to_string(), "h3".to_string(), "img".to_string(), "figure".to_string(), "figcaption".to_string(), ], removed_elements: vec![ "aside".to_string(), "iframe".to_string(), "script".to_string(), "style".to_string(), "ins".to_string(), "form".to_string(), "button".to_string(), ".ad".to_string(), "#ad".to_string(), "[class*='ad-']".to_string(), "[id*='ad-']".to_string(), ], } } } impl DefaultView { pub fn display_name(&self) -> &str { match self { DefaultView::Compact => "Compact", DefaultView::Full => "Full Article", DefaultView::Summary => "Summary", } } pub fn from_string(s: &str) -> Option { match s.to_lowercase().as_str() { "compact" => Some(DefaultView::Compact), "full" => Some(DefaultView::Full), "summary" => Some(DefaultView::Summary), _ => None, } } } impl Default for DefaultView { fn default() -> Self { DefaultView::Compact } } impl Theme { pub fn display_name(&self) -> &str { match self { Theme::Light => "Light Mode", Theme::Dark => "Dark Mode", Theme::Auto => "Auto (System)", } } pub fn is_dark_mode(&self, system_prefers_dark: bool) -> bool { match self { Theme::Light => false, Theme::Dark => true, Theme::Auto => system_prefers_dark, } } } impl Default for Theme { fn default() -> Self { Theme::Auto } } impl DefaultSort { pub fn display_name(&self) -> &str { match self { DefaultSort::AddedDesc => "Recently Added", DefaultSort::PublishedDesc => "Recently Published", DefaultSort::TitleAsc => "Title A-Z", } } pub fn to_sql_order(&self) -> &str { match self { DefaultSort::AddedDesc => "added_at DESC", DefaultSort::PublishedDesc => "published_at DESC", DefaultSort::TitleAsc => "title ASC", } } } impl Default for DefaultSort { fn default() -> Self { DefaultSort::AddedDesc } } impl DefaultFormat { pub fn display_name(&self) -> &str { match self { DefaultFormat::Text => "Plain Text", DefaultFormat::Markdown => "Markdown", DefaultFormat::JSON => "JSON", DefaultFormat::HTML => "HTML", } } pub fn mime_type(&self) -> &str { match self { DefaultFormat::Text => "text/plain", DefaultFormat::Markdown => "text/markdown", DefaultFormat::JSON => "application/json", DefaultFormat::HTML => "text/html", } } } impl Default for DefaultFormat { fn default() -> Self { DefaultFormat::Text } } impl AiProvider { pub fn display_name(&self) -> &str { match self { AiProvider::Ollama => "Ollama", AiProvider::OpenAi => "OpenAI", AiProvider::Anthropic => "Anthropic", } } } impl Default for AiProvider { fn default() -> Self { AiProvider::Ollama } } impl DefaultOutput { pub fn display_name(&self) -> &str { match self { DefaultOutput::Table => "Table", DefaultOutput::JSON => "JSON", DefaultOutput::CSV => "CSV", } } } impl Default for DefaultOutput { fn default() -> Self { DefaultOutput::Table } } impl Default for Cli { fn default() -> Self { Self { default_output: DefaultOutput::Table, pager_command: "less -R".to_string(), show_progress: true, auto_confirm_bulk: false, show_geographic_hierarchy: true, } } } impl Default for ConfMigration { fn default() -> Self { Self { auto_convert_country_filters: true, preserve_legacy_data: true, migration_batch_size: 100, } } } impl Default for Processing { fn default() -> Self { Self { batch_size: 10, max_concurrent: 5, retry_attempts: 3, priority_manual: true, auto_mark_read_on_view: false, } } } impl Default for Scraping { fn default() -> Self { Self { timeout_seconds: 30, max_retries: 3, max_content_length: 100_000, respect_robots_txt: true, rate_limit_delay_ms: 1000, content_quality: ContentQuality::default(), duplicate_detection: DuplicateDetection::default(), ad_prevention: AdPrevention::default(), } } } impl Default for Ai { fn default() -> Self { Self { enabled: true, provider: AiProvider::default(), timeout_seconds: 120, summary: AiSummary::default(), tagging: AiTagging::default(), } } } impl Default for AiSummary { fn default() -> Self { Self { enabled: true, temperature: 0.1, max_tokens: 1024, } } } impl Default for AiTagging { fn default() -> Self { Self { enabled: true, temperature: 0.3, max_tokens: 200, max_tags_per_article: 10, min_confidence_threshold: 0.7, enable_geographic_tagging: true, enable_category_tagging: true, geographic_hierarchy_levels: 3, } } } impl Default for Sharing { fn default() -> Self { Self { default_format: DefaultFormat::default(), include_summary: true, include_tags: true, include_source: true, copy_to_clipboard: true, templates: SharingTemplates::default(), } } } impl Default for SharingTemplates { fn default() -> Self { Self { text: SharingTemplate { format: r#"📰 {title} {summary} 🏷️ Tags: {tags} 🌍 Location: {geographic_tags} 🔗 Source: {url} 📅 Published: {published_at} Shared via Owly News Summariser "# .to_string(), }, markdown: SharingTemplate { format: r#"# {title} {summary} **Tags:** {tags} **Location:** {geographic_tags} **Source:** [{url}]({url}) **Published:** {published_at} --- *Shared via Owly News Summariser* "# .to_string(), }, } } } impl Default for Filtering { fn default() -> Self { Self { enable_smart_suggestions: true, max_recent_filters: 10, auto_save_filters: true, default_sort: DefaultSort::AddedDesc, enable_geographic_hierarchy: true, auto_migrate_country_filters: true, } } } impl Default for Analytics { fn default() -> Self { Self { enabled: true, track_reading_time: true, track_scroll_position: true, retention_days: 365, aggregate_older_data: true, } } } impl Default for ConfDisplay { fn default() -> Self { Self { default_view: DefaultView::default(), articles_per_page: 50, show_reading_time: true, show_word_count: false, highlight_unread: true, theme: Theme::default(), } } } impl Default for Server { fn default() -> Self { Self { host: "127.0.0.1".to_string(), port: 8090, } } } impl Default for ConfigFile { fn default() -> Self { Self { server: Server::default(), display: ConfDisplay::default(), analytics: Analytics::default(), filtering: Filtering::default(), sharing: Sharing::default(), ai: Ai::default(), scraping: Scraping::default(), processing: Processing::default(), migration: ConfMigration::default(), cli: Cli::default(), } } } impl ConfigFile { pub fn load_from_file(app_settings: &AppSettings) -> Result> { let config_path_str = &app_settings.config_path; let config_path = PathBuf::from(config_path_str); if !config_path.exists() { let config_file = ConfigFile::default(); config_file.save_to_file(app_settings)?; return Ok(config_file); } let contents = std::fs::read_to_string(config_path_str)?; let config: ConfigFile = toml::from_str(&contents)?; Ok(config) } pub fn save_to_file( &self, app_settings: &AppSettings, ) -> Result<(), Box> { let contents = toml::to_string_pretty(self)?; std::fs::write(&app_settings.config_path, contents)?; Ok(()) } } #[derive(Deserialize, Debug)] pub struct AppSettings { pub config_path: String, pub db_path: String, pub migration_path: String, pub config: ConfigFile, } impl Default for AppSettings { fn default() -> Self { Self { config_path: Self::get_config_path(), db_path: Self::get_db_path(), migration_path: Self::get_migration_path(), config: ConfigFile::default(), } } } impl AppSettings { const FALLBACK_DIR: &'static str = "/tmp"; const PROD_DB_PATH: &'static str = "/var/lib/owly-news/owlynews.sqlite3"; const PROD_MIGRATION_PATH: &'static str = "/usr/share/owly-news/migrations"; pub fn get_app_settings() -> Self { AppSettings::default() } fn get_base_path(env_var: &str, fallback: &str) -> String { env::var(env_var).unwrap_or_else(|_| fallback.to_string()) } fn get_db_path() -> String { if cfg!(debug_assertions) { let manifest_dir = Self::get_base_path("CARGO_MANIFEST_DIR", Self::FALLBACK_DIR); format!("{}/owlynews.sqlite3", manifest_dir) } else { Self::PROD_DB_PATH.to_string() } } fn get_config_path() -> String { if cfg!(debug_assertions) { let manifest_dir = Self::get_base_path("CARGO_MANIFEST_DIR", Self::FALLBACK_DIR); format!("{}/config.toml", manifest_dir) } else { let home = Self::get_base_path("HOME", Self::FALLBACK_DIR); format!("{}/.config/owly-news/config.toml", home) } } fn get_migration_path() -> String { if cfg!(debug_assertions) { let manifest_dir = Self::get_base_path("CARGO_MANIFEST_DIR", Self::FALLBACK_DIR); format!("{}/migrations", manifest_dir) } else { Self::PROD_MIGRATION_PATH.to_string() } } pub fn database_url(&self) -> String { format!("sqlite:{}", self.db_path) } pub fn ensure_default_directory(&self) -> Result<(), std::io::Error> { // Always create the database directory if let Some(parent) = std::path::Path::new(&self.db_path).parent() { std::fs::create_dir_all(parent)?; } // In production mode, also create the config directory if !cfg!(debug_assertions) { if let Some(parent) = std::path::Path::new(&self.config_path).parent() { std::fs::create_dir_all(parent)?; } } Ok(()) } }