eilmeldung 1.4.1

a feature-rich TUI RSS Reader based on the news-flash library
mod parse;
mod search_term;
mod sort_order;

pub mod prelude {
    pub use super::parse::{QueryParseError, QueryToken, strip_first_and_last};
    pub use super::search_term::{SearchTerm, to_search_term};
    pub use super::sort_order::{SortDirection, SortKey, SortOrder, SortOrderParseError};
    pub use super::{ArticleQuery, ArticleQueryContext, AugmentedArticleFilter};
}

use crate::prelude::*;
use std::collections::{HashMap, HashSet};

use chrono::{DateTime, Utc};
use getset::Getters;
use news_flash::models::{
    Article, ArticleFilter, ArticleID, Category, Feed, FeedID, Marked, Read, Tag, TagID,
};

#[derive(Clone, Debug)]
pub(super) enum QueryAtom {
    True,
    Read(Read),
    Marked(Marked),
    Feed(SearchTerm),
    Category(SearchTerm),
    Title(SearchTerm),
    Summary(SearchTerm),
    Author(SearchTerm),
    FeedUrl(SearchTerm),
    FeedWebUrl(SearchTerm),
    All(SearchTerm),
    Tag(Vec<String>),
    Tagged,
    Flagged,
    LastSync,
    Newer(DateTime<Utc>),
    Older(DateTime<Utc>),
    SyncedBefore(DateTime<Utc>),
    SyncedAfter(DateTime<Utc>),
}

#[derive(Clone, Debug)]
pub(super) enum QueryClause {
    Id(QueryAtom),
    Not(QueryAtom),
}

impl QueryClause {
    #[inline(always)]
    pub fn test(
        &self,
        article: &Article,
        feed: Option<&Feed>,
        category: Option<&Category>,
        tags: Option<&HashSet<String>>,
        last_sync: &DateTime<Utc>,
        flagged_articles: &HashSet<ArticleID>,
    ) -> bool {
        match self {
            QueryClause::Id(query_atom) => {
                query_atom.test(article, feed, category, tags, last_sync, flagged_articles)
            }
            QueryClause::Not(query_atom) => {
                !query_atom.test(article, feed, category, tags, last_sync, flagged_articles)
            }
        }
    }
}

#[derive(Default, Clone, Debug, Getters)]
#[getset(get = "pub")]
pub struct ArticleQuery {
    query_string: String,
    query: Vec<QueryClause>,
    sort_order: Option<SortOrder>,
}

pub struct ArticleQueryContext<'a> {
    pub feed_map: &'a HashMap<FeedID, Feed>,
    pub category_for_feed: &'a HashMap<FeedID, Category>,
    pub tags_for_article: &'a HashMap<ArticleID, Vec<TagID>>,
    pub tag_map: &'a HashMap<TagID, Tag>,
    pub last_sync: &'a DateTime<Utc>,
    pub flagged: &'a HashSet<ArticleID>,
}

impl ArticleQuery {
    #[inline(always)]
    pub fn filter(&self, articles: &[Article], context: &ArticleQueryContext) -> Vec<Article> {
        articles
            .iter()
            .filter(|article| self.test(article, context))
            .cloned()
            .collect::<Vec<Article>>()
    }

    #[inline(always)]
    pub fn test(&self, article: &Article, context: &ArticleQueryContext) -> bool {
        let feed = context.feed_map.get(&article.feed_id);

        let category = context.category_for_feed.get(&article.feed_id);

        let tags = context
            .tags_for_article
            .get(&article.article_id)
            .map(|tag_ids| {
                tag_ids
                    .iter()
                    .filter_map(|tag_id| {
                        context.tag_map.get(tag_id).map(|tag| tag.label.to_string())
                    })
                    .collect::<HashSet<String>>()
            });

        self.query.iter().all(|query_clause| {
            query_clause.test(
                article,
                feed,
                category,
                tags.as_ref(),
                context.last_sync,
                context.flagged,
            )
        })
    }
}

impl QueryAtom {
    #[inline(always)]
    pub fn test(
        &self,
        article: &Article,
        feed: Option<&Feed>,
        category: Option<&Category>,
        tags: Option<&HashSet<String>>,
        last_sync: &DateTime<Utc>,
        flagged_articles: &HashSet<ArticleID>,
    ) -> bool {
        use QueryAtom as A;
        match self {
            A::True => true,
            A::Read(read) => article.unread == *read,
            A::Marked(marked) => article.marked == *marked,

            A::Tagged => !tags.map(|tags| tags.is_empty()).unwrap_or(true),

            A::Flagged => flagged_articles.contains(&article.article_id),

            A::Feed(search_term)
            | A::Category(search_term)
            | A::Title(search_term)
            | A::Summary(search_term)
            | A::Author(search_term)
            | A::FeedUrl(search_term)
            | A::FeedWebUrl(search_term)
            | A::All(search_term) => self.test_string_match(search_term, article, feed, category),

            A::Tag(search_tags) => {
                let Some(tags) = tags else {
                    return false;
                };
                search_tags.iter().any(|tag| tags.contains(tag))
            }

            A::Older(date_time) => article.date < *date_time,
            A::Newer(date_time) => article.date > *date_time,
            A::SyncedAfter(date_time) => article.synced > *date_time,
            A::SyncedBefore(date_time) => article.synced < *date_time,
            A::LastSync => article.synced >= *last_sync,
        }
    }

    #[inline(always)]
    fn test_string_match(
        &self,
        search_term: &SearchTerm,
        article: &Article,
        feed: Option<&Feed>,
        category: Option<&Category>,
    ) -> bool {
        let content_string = match self {
            QueryAtom::Feed(_) => {
                let Some(feed) = feed else {
                    return false;
                };
                Some(feed.label.clone())
            }
            QueryAtom::Category(_) => {
                let Some(category) = category else {
                    return false;
                };
                Some(category.label.clone())
            }
            QueryAtom::FeedUrl(_) => {
                let Some(feed) = feed else {
                    return false;
                };
                feed.feed_url.clone().map(|url| url.to_string())
            }
            QueryAtom::FeedWebUrl(_) => {
                let Some(feed) = feed else {
                    return false;
                };
                feed.website.clone().map(|url| url.to_string())
            }
            QueryAtom::Title(_) => article.title.clone(),
            QueryAtom::Summary(_) => article.summary.clone(),
            QueryAtom::Author(_) => article.author.clone(),
            QueryAtom::All(_) => Some(format!(
                "{} {} {} {} {} {}",
                article.title.as_deref().unwrap_or_default(),
                article.summary.as_deref().unwrap_or_default(),
                article.author.as_deref().unwrap_or_default(),
                feed.as_ref()
                    .map(|feed| feed.label.as_str())
                    .unwrap_or_default(),
                feed.as_ref()
                    .map(|feed| feed
                        .feed_url
                        .as_ref()
                        .map(|url| url.to_string())
                        .unwrap_or_default())
                    .unwrap_or_default()
                    .as_str(),
                feed.as_ref()
                    .map(|feed| feed
                        .website
                        .as_ref()
                        .map(|url| url.to_string())
                        .unwrap_or_default())
                    .unwrap_or_default()
                    .as_str(),
            )),
            _ => unreachable!(),
        };

        let Some(content_string) = content_string else {
            return false;
        };

        search_term.test(&content_string)
    }
}

#[derive(Default, Clone, Debug)]
pub struct AugmentedArticleFilter {
    pub article_filter: ArticleFilter,
    pub article_query: ArticleQuery,
}

impl From<ArticleFilter> for AugmentedArticleFilter {
    fn from(article_filter: ArticleFilter) -> Self {
        Self {
            article_filter,
            ..Self::default()
        }
    }
}

impl From<ArticleQuery> for AugmentedArticleFilter {
    fn from(article_query: ArticleQuery) -> Self {
        Self {
            article_query,
            ..Self::default()
        }
    }
}

impl AugmentedArticleFilter {
    pub fn new(article_filter: ArticleFilter, article_query: ArticleQuery) -> Self {
        Self {
            article_filter,
            article_query,
        }
    }

    pub fn is_augmented(&self) -> bool {
        !self.article_query.query.is_empty()
    }

    pub fn defines_scope(&self) -> bool {
        self.is_augmented()
            || self.article_filter.unread.is_some()
            || self.article_filter.marked.is_some()
    }
}