news-flash 3.0.1

Base library for a modern feed reader
Documentation
use tokio::sync::{Mutex, RwLock, Semaphore};

use crate::action_cache::ActionCache;
use crate::config::{ConfigHandler, ConfigProxy};
use crate::database::Database;
use crate::error::NewsFlashError;
use crate::feed_api::FeedApi;
use crate::models::{ApiSecret, PluginID};
use crate::util::favicons::FavIconLoader;
use crate::{DefaultPortal, FeedApiImplementations};
use crate::{NewsFlash, database::DatabaseExt};
use crate::{NewsFlashResult, config::ConfigHandlerExt};
use std::io::{Error as IoError, ErrorKind as IoErrorKind};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;

#[derive(Default)]
pub struct NewsFlashBuilder {
    plugin_id: Option<PluginID>,
    config_dir: Option<PathBuf>,
    data_dir: Option<PathBuf>,
    db: Option<Box<dyn DatabaseExt>>,
    config_handler: Option<Box<dyn ConfigHandlerExt>>,
    user_api_secret: Option<ApiSecret>,
    #[cfg(test)]
    api: Option<Box<dyn FeedApi>>,
}

impl NewsFlashBuilder {
    pub fn plugin(mut self, id: PluginID) -> Self {
        self.plugin_id.replace(id);
        self
    }

    pub fn config_dir<P: AsRef<Path>>(mut self, path: P) -> Self {
        self.config_dir.replace(path.as_ref().to_path_buf());
        self
    }

    pub fn data_dir<P: AsRef<Path>>(mut self, path: P) -> Self {
        self.data_dir.replace(path.as_ref().to_path_buf());
        self
    }

    pub fn data_base(mut self, db: Box<dyn DatabaseExt>) -> Self {
        self.db.replace(db);
        self
    }

    pub fn config_handler(mut self, handler: Box<dyn ConfigHandlerExt>) -> Self {
        self.config_handler.replace(handler);
        self
    }

    pub fn api_secret(mut self, api_secret: Option<ApiSecret>) -> Self {
        self.user_api_secret = api_secret;
        self
    }

    #[cfg(test)]
    pub fn api_mock(mut self, api: Box<dyn FeedApi>) -> Self {
        self.api.replace(api);
        self
    }

    pub fn try_load(mut self) -> NewsFlashResult<NewsFlash> {
        let Some(config_dir) = self.config_dir.as_deref() else {
            return Err(NewsFlashError::IO(IoError::new(
                IoErrorKind::NotADirectory,
                "need to specify a config directory",
            )));
        };

        let handler = Box::new(ConfigHandler::default());
        let config = ConfigProxy::open(handler, config_dir)?;
        let plugin_id = config.get_backend().ok_or(NewsFlashError::LoadBackend)?;
        self.plugin_id.replace(plugin_id);

        self.create()
    }

    pub fn create(self) -> NewsFlashResult<NewsFlash> {
        let Some(config_dir) = self.config_dir else {
            return Err(NewsFlashError::IO(IoError::new(
                IoErrorKind::NotADirectory,
                "need to specify a config directory",
            )));
        };

        tracing::trace!(?config_dir);

        let Some(data_dir) = self.data_dir else {
            return Err(NewsFlashError::IO(IoError::new(
                IoErrorKind::NotADirectory,
                "need to specify a data directory",
            )));
        };

        tracing::trace!(?data_dir);

        let handler: Box<dyn ConfigHandlerExt> = self.config_handler.unwrap_or(Box::new(ConfigHandler::default()));
        let db: Box<dyn DatabaseExt> = if let Some(db) = self.db {
            db
        } else {
            Box::new(Database::new(&data_dir)?)
        };
        let db = Arc::new(db);

        let config = ConfigProxy::open(handler, &config_dir)?;
        let download_semaphore = Arc::new(Semaphore::new(config.get_concurrent_downloads() as usize));
        let config = Arc::new(RwLock::new(config));

        #[cfg(not(test))]
        let api = Self::load_backend(
            self.plugin_id,
            &config_dir,
            db.clone(),
            config.clone(),
            download_semaphore.clone(),
            self.user_api_secret,
        )?;

        #[cfg(test)]
        let api = if let Some(api) = self.api {
            api
        } else {
            Self::load_backend(
                self.plugin_id,
                &config_dir,
                db.clone(),
                config.clone(),
                download_semaphore.clone(),
                self.user_api_secret,
            )?
        };

        let icons = FavIconLoader::new(&db, &download_semaphore);
        let sync_cache = ActionCache::new();

        let base = NewsFlash {
            #[cfg(any(feature = "article-scraper", feature = "image-downloader"))]
            data_dir,
            db,
            api: RwLock::new(api),
            config,
            download_semaphore,
            icons,
            #[cfg(feature = "article-scraper")]
            scraper: RwLock::new(None),
            sync_cache: Mutex::new(sync_cache),
            is_sync_ongoing: Arc::new(AtomicBool::new(false)),
            is_offline: Arc::new(AtomicBool::new(false)),
        };

        Ok(base)
    }

    fn load_backend<P: AsRef<Path>>(
        plugin_id: Option<PluginID>,
        config_dir: P,
        db: Arc<Box<dyn DatabaseExt>>,
        config: Arc<RwLock<ConfigProxy>>,
        download_semaphore: Arc<Semaphore>,
        user_api_secret: Option<ApiSecret>,
    ) -> NewsFlashResult<Box<dyn FeedApi>> {
        if let Some(plugin_id) = &plugin_id {
            tracing::info!(%plugin_id, "Loading backend");

            if let Some(meta_data) = FeedApiImplementations::get(plugin_id) {
                let portal = DefaultPortal::new(db, config, download_semaphore);
                let portal = Box::new(portal);
                let backend = meta_data.get_instance(config_dir.as_ref(), portal, user_api_secret)?;
                Ok(backend)
            } else {
                tracing::error!(%plugin_id, "No meta object found");
                Err(NewsFlashError::LoadBackend)
            }
        } else {
            tracing::error!("need to specify plugin id or provide mocked feed API");
            Err(NewsFlashError::LoadBackend)
        }
    }
}