use std::collections::{HashMap, HashSet};
use crate::models::{ArticleID, ArticleOrder, CategoryID, FeedID, Marked, OrderBy, Read, TagID};
use chrono::{DateTime, Utc};
use super::{CategoryMapping, FeedMapping};
#[derive(Clone, Debug, Default)]
pub struct ArticleFilter {
pub limit: Option<i64>,
pub offset: Option<i64>,
pub order: Option<ArticleOrder>,
pub order_by: Option<OrderBy>,
pub unread: Option<Read>,
pub marked: Option<Marked>,
pub feeds: Option<Vec<FeedID>>,
pub feed_blacklist: Option<Vec<FeedID>>,
pub categories: Option<Vec<CategoryID>>,
pub category_blacklist: Option<Vec<CategoryID>>,
pub tags: Option<Vec<TagID>>,
pub ids: Option<Vec<ArticleID>>,
pub newer_than: Option<DateTime<Utc>>,
pub older_than: Option<DateTime<Utc>>,
pub synced_before: Option<DateTime<Utc>>,
pub synced_after: Option<DateTime<Utc>>,
pub search_term: Option<String>,
}
impl ArticleFilter {
pub fn ids(article_ids: Vec<ArticleID>) -> Self {
Self {
ids: Some(article_ids),
..Self::default()
}
}
pub fn read_ids(article_ids: Vec<ArticleID>, read: Read) -> Self {
Self {
unread: Some(read),
ids: Some(article_ids),
..Self::default()
}
}
pub fn marked_ids(article_ids: Vec<ArticleID>, marked: Marked) -> Self {
Self {
marked: Some(marked),
ids: Some(article_ids),
..Self::default()
}
}
pub fn feed_unread(feed_id: &FeedID) -> Self {
Self {
unread: Some(Read::Unread),
feeds: Some([feed_id.clone()].into()),
..Self::default()
}
}
pub fn category_unread(category_id: &CategoryID) -> Self {
Self {
unread: Some(Read::Unread),
categories: Some([category_id.clone()].into()),
..Self::default()
}
}
pub fn tag_unread(tag_id: &TagID) -> Self {
Self {
unread: Some(Read::Unread),
tags: Some([tag_id.clone()].into()),
..Self::default()
}
}
pub fn all_unread() -> Self {
Self {
unread: Some(Read::Unread),
..Self::default()
}
}
pub fn all_marked() -> Self {
Self {
marked: Some(Marked::Marked),
..Self::default()
}
}
pub fn feeds_to_load(&self, category_mappings: &[CategoryMapping], feed_mappings: &[FeedMapping]) -> HashSet<FeedID> {
let mut result = HashSet::new();
if let Some(feeds) = &self.feeds {
for feed_id in feeds {
result.insert(feed_id.clone());
}
}
if let Some(categories) = &self.categories {
for category_id in categories {
let feed_ids = Self::find_all_feeds(category_id, category_mappings, feed_mappings);
for feed_id in feed_ids {
result.insert(feed_id);
}
}
}
result
}
pub fn feeds_to_blacklist(&self, category_mappings: &[CategoryMapping], feed_mappings: &[FeedMapping]) -> HashSet<FeedID> {
let mut result = HashSet::new();
if let Some(feed_blacklist) = &self.feed_blacklist {
for feed_id in feed_blacklist {
result.insert(feed_id.clone());
}
}
if let Some(category_blacklist) = &self.category_blacklist {
for category_id in category_blacklist {
let feed_ids = Self::find_all_feeds(category_id, category_mappings, feed_mappings);
for feed_id in feed_ids {
result.insert(feed_id);
}
}
}
result
}
fn find_all_feeds(category_id: &CategoryID, category_mappings: &[CategoryMapping], feed_mappings: &[FeedMapping]) -> HashSet<FeedID> {
let mut feeds_by_parent: HashMap<&CategoryID, Vec<&FeedMapping>> = HashMap::new();
for fm in feed_mappings {
feeds_by_parent.entry(&fm.category_id).or_default().push(fm);
}
let mut cats_by_parent: HashMap<&CategoryID, Vec<&CategoryMapping>> = HashMap::new();
for cm in category_mappings {
cats_by_parent.entry(&cm.parent_id).or_default().push(cm);
}
fn find_all_feeds_impl(
category_id: &CategoryID,
feeds_by_parent: &HashMap<&CategoryID, Vec<&FeedMapping>>,
cats_by_parent: &HashMap<&CategoryID, Vec<&CategoryMapping>>,
visited: &mut HashSet<CategoryID>,
result: &mut HashSet<FeedID>,
) {
if !visited.insert(category_id.clone()) {
tracing::warn!(%category_id, "Cycle detected in category hierarchy");
return;
}
if let Some(feeds) = feeds_by_parent.get(category_id) {
for fm in feeds {
result.insert(fm.feed_id.clone());
}
}
if let Some(children) = cats_by_parent.get(category_id) {
for cm in children {
find_all_feeds_impl(&cm.category_id, feeds_by_parent, cats_by_parent, visited, result);
}
}
}
let mut result = HashSet::new();
let mut visited = HashSet::new();
find_all_feeds_impl(category_id, &feeds_by_parent, &cats_by_parent, &mut visited, &mut result);
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{CategoryID, FeedID};
fn fm(feed: &str, category: &str) -> FeedMapping {
FeedMapping {
feed_id: FeedID::new(feed),
category_id: CategoryID::new(category),
sort_index: None,
}
}
fn cm(parent: &str, child: &str) -> CategoryMapping {
CategoryMapping {
parent_id: CategoryID::new(parent),
category_id: CategoryID::new(child),
sort_index: None,
}
}
#[test]
fn find_all_feeds_flat() {
let feed_mappings = vec![fm("feed1", "root"), fm("feed2", "root"), fm("feed3", "other")];
let category_mappings = vec![];
let result = ArticleFilter::find_all_feeds(&CategoryID::new("root"), &category_mappings, &feed_mappings);
assert_eq!(result.len(), 2);
assert!(result.contains(&FeedID::new("feed1")));
assert!(result.contains(&FeedID::new("feed2")));
assert!(!result.contains(&FeedID::new("feed3")));
}
#[test]
fn find_all_feeds_nested() {
let feed_mappings = vec![fm("feed1", "root"), fm("feed2", "child"), fm("feed3", "child")];
let category_mappings = vec![cm("root", "child")];
let result = ArticleFilter::find_all_feeds(&CategoryID::new("root"), &category_mappings, &feed_mappings);
assert_eq!(result.len(), 3);
assert!(result.contains(&FeedID::new("feed1")));
assert!(result.contains(&FeedID::new("feed2")));
assert!(result.contains(&FeedID::new("feed3")));
}
#[test]
fn find_all_feeds_deep_nesting() {
let feed_mappings = vec![fm("feed1", "level2")];
let category_mappings = vec![cm("root", "level1"), cm("level1", "level2")];
let result = ArticleFilter::find_all_feeds(&CategoryID::new("root"), &category_mappings, &feed_mappings);
assert_eq!(result.len(), 1);
assert!(result.contains(&FeedID::new("feed1")));
}
#[test]
fn find_all_feeds_cycle_does_not_hang() {
let feed_mappings = vec![fm("feed1", "root"), fm("feed2", "child")];
let category_mappings = vec![cm("root", "child"), cm("child", "root")];
let result = ArticleFilter::find_all_feeds(&CategoryID::new("root"), &category_mappings, &feed_mappings);
assert!(result.contains(&FeedID::new("feed1")));
assert!(result.contains(&FeedID::new("feed2")));
}
#[test]
fn find_all_feeds_empty() {
let result = ArticleFilter::find_all_feeds(&CategoryID::new("root"), &[], &[]);
assert!(result.is_empty());
}
}