From 78073d27d7c77abfcbc5cdabadef203f226fd9c7 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 6 Aug 2025 16:54:10 +0200 Subject: [PATCH] [update] refactored configuration handling with comprehensive `ConfigFile` structure, added default settings, expanded support for new modules, and enhanced directory creation logic --- backend-rust/config.toml | 3 - backend-rust/src/config.rs | 670 ++++++++++++++++++++++++++++++++----- backend-rust/src/db.rs | 2 +- backend-rust/src/main.rs | 33 +- 4 files changed, 607 insertions(+), 101 deletions(-) delete mode 100644 backend-rust/config.toml diff --git a/backend-rust/config.toml b/backend-rust/config.toml deleted file mode 100644 index 38eba18..0000000 --- a/backend-rust/config.toml +++ /dev/null @@ -1,3 +0,0 @@ -[server] -host = '127.0.0.1' -port = 8090 diff --git a/backend-rust/src/config.rs b/backend-rust/src/config.rs index af57368..9f1ff6b 100644 --- a/backend-rust/src/config.rs +++ b/backend-rust/src/config.rs @@ -1,127 +1,625 @@ -use serde::Deserialize; -use std::{env, fmt}; +use serde::{Deserialize, Serialize}; +use std::env; use std::path::PathBuf; -use toml::Value; -use tracing::{error, info}; + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(default)] +pub struct ConfigFile { + pub server: Server, + pub display: ConfDisplay, + pub analytics: Analytics, + pub filtering: Filtering, + pub sharing: Sharing, + pub ai: Ai, + pub scraping: Scraping, + pub processing: Processing, + pub migration: ConfMigration, + pub cli: Cli, +} + +#[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)] +pub struct Server { + pub host: String, + pub port: u16, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct ConfDisplay { + pub default_view: DefaultView, + pub articles_per_page: u32, + pub show_reading_time: bool, + pub show_word_count: bool, + pub highlight_unread: bool, + pub theme: Theme, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Analytics { + pub enabled: bool, + pub track_reading_time: bool, + pub track_scroll_position: bool, + pub retention_days: u32, + pub aggregate_older_data: bool, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Filtering { + pub enable_smart_suggestions: bool, + pub max_recent_filters: u32, + pub auto_save_filters: bool, + pub default_sort: DefaultSort, + pub enable_geographic_hierarchy: bool, + pub auto_migrate_country_filters: bool, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Sharing { + pub default_format: DefaultFormat, + pub include_summary: bool, + pub include_tags: bool, + pub include_source: bool, + pub copy_to_clipboard: bool, + pub templates: SharingTemplates, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct SharingTemplates { + pub text: SharingTemplate, + pub markdown: SharingTemplate, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct SharingTemplate { + pub format: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Ai { + pub enabled: bool, + pub provider: AiProvider, + pub timeout_seconds: u32, + pub summary: AiSummary, + pub tagging: AiTagging, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct AiSummary { + pub enabled: bool, + pub temperature: f32, + pub max_tokens: u32, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct AiTagging { + pub enabled: bool, + pub temperature: f32, + pub max_tokens: u32, + pub max_tags_per_article: u32, + pub min_confidence_threshold: f32, + pub enable_geographic_tagging: bool, + pub enable_category_tagging: bool, + pub geographic_hierarchy_levels: u32, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Scraping { + pub timeout_seconds: u32, + pub max_retries: u32, + pub max_content_length: u32, + pub respect_robots_txt: bool, + pub rate_limit_delay_ms: u32, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Processing { + pub batch_size: u32, + pub max_concurrent: u32, + pub retry_attempts: u32, + pub priority_manual: bool, + pub auto_mark_read_on_view: bool, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct ConfMigration { + pub auto_convert_country_filters: bool, + pub preserve_legacy_data: bool, + pub migration_batch_size: u32, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Cli { + pub default_output: DefaultOutput, + pub pager_command: String, + pub show_progress: bool, + pub auto_confirm_bulk: bool, + pub show_geographic_hierarchy: bool, +} + +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, + } + } +} + +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: Config, + pub config: ConfigFile, } -#[derive(Deserialize, Debug)] -pub struct Config { - pub server: Server, -} - -#[derive(Deserialize, Debug)] -pub struct Server { - pub host: String, - pub port: u16, -} - -#[derive(Deserialize, Debug)] -struct ConfigFile { - server: Server, -} - -#[derive(Debug)] -pub enum ConfigError { - InvalidPort, -} - -impl fmt::Display for ConfigError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ConfigError::InvalidPort => write!(f, "Invalid port: port cannot be 0"), - } - } -} - -impl std::error::Error for ConfigError {} - -impl AppSettings { - pub fn validate(&self) -> Result<(), ConfigError> { - if self.config.server.port == 0 { - return Err(ConfigError::InvalidPort); - } - Ok(()) - } - - pub fn get_app_settings() -> Self { - let config_file = Self::load_config_file().unwrap_or_else(|| { - info!("Using default config values"); - ConfigFile { - server: Server { - host: "127.0.0.1".to_string(), - port: 1337, - }, - } - }); - +impl Default for AppSettings { + fn default() -> Self { Self { config_path: Self::get_config_path(), db_path: Self::get_db_path(), - migration_path: String::from("./migrations"), - config: Config { - server: config_file.server, - }, + migration_path: Self::get_migration_path(), + config: ConfigFile::default(), } } +} - fn load_config_file() -> Option { - let config_path = Self::get_config_path(); - let contents = std::fs::read_to_string(&config_path) - .map_err(|e| error!("Failed to read config file: {}", e)) - .ok()?; +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"; - toml::from_str(&contents) - .map_err(|e| error!("Failed to parse TOML: {}", e)) - .ok() + 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 mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - path.push("owlynews.sqlite3"); - path.to_str() - .ok_or_else(|| anyhow::anyhow!("Failed to convert path to string")) - .ok() - .expect("Failed to convert path to string") - .to_string() + let manifest_dir = Self::get_base_path("CARGO_MANIFEST_DIR", Self::FALLBACK_DIR); + format!("{}/owlynews.sqlite3", manifest_dir) } else { - // Production: Use standard Linux applications data directory - "/var/lib/owly-news/owlynews.sqlite3".to_string() + Self::PROD_DB_PATH.to_string() } } fn get_config_path() -> String { if cfg!(debug_assertions) { - let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - path.push("config.toml"); - path.to_str() - .ok_or_else(|| anyhow::anyhow!("Failed to convert path to string")) - .ok() - .expect("Failed to convert path to string") - .to_string() + let manifest_dir = Self::get_base_path("CARGO_MANIFEST_DIR", Self::FALLBACK_DIR); + format!("{}/config.toml", manifest_dir) } else { - // Production: Use standard Linux applications data directory - let home = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + 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_db_directory(&self) -> Result<(), std::io::Error> { + 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(()) } } diff --git a/backend-rust/src/db.rs b/backend-rust/src/db.rs index 4e7597f..93151c3 100644 --- a/backend-rust/src/db.rs +++ b/backend-rust/src/db.rs @@ -10,7 +10,7 @@ use tracing::info; pub const MIGRATOR: Migrator = sqlx::migrate!("./migrations"); pub async fn initialize_db(app_settings: &AppSettings) -> Result> { - app_settings.ensure_db_directory()?; + app_settings.ensure_default_directory()?; let options = SqliteConnectOptions::from_str(&app_settings.database_url())? .create_if_missing(true) diff --git a/backend-rust/src/main.rs b/backend-rust/src/main.rs index b38f47f..421fd08 100644 --- a/backend-rust/src/main.rs +++ b/backend-rust/src/main.rs @@ -4,37 +4,48 @@ mod db; mod models; mod services; -use crate::config::{AppSettings}; +use crate::config::{AppSettings, ConfigFile}; use anyhow::Result; use axum::Router; use axum::routing::get; use tokio::signal; -use tracing::{info}; +use tracing::info; use tracing_subscriber; -use tracing_subscriber::EnvFilter; #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_target(false) - .compact() - .with_env_filter(EnvFilter::from_default_env()) - .json() // For production .init(); - let app_settings = AppSettings::get_app_settings(); + info!("Starting server"); + + AppSettings::default(); + let mut app_settings = AppSettings::get_app_settings(); + + AppSettings::ensure_default_directory(&app_settings) + .expect("Failed to create default directory"); + + app_settings.config = ConfigFile::load_from_file(&AppSettings::get_app_settings()) + .expect("Failed to load config file"); let pool = db::initialize_db(&app_settings).await?; - let app = create_app(pool); - let listener = - tokio::net::TcpListener::bind(format!("{}:{}", app_settings.config.server.host, app_settings.config.server.port)).await?; - info!("Server starting on {}:{}", app_settings.config.server.host, app_settings.config.server.port); + let listener = tokio::net::TcpListener::bind(format!( + "{}:{}", + &app_settings.config.server.host, &app_settings.config.server.port + )) + .await?; + info!( + "Server starting on http://{}:{}", + &app_settings.config.server.host, &app_settings.config.server.port + ); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; + info!("Server stopped"); Ok(()) }