Skip to main content

feed/article_store/
mod.rs

1mod fetch;
2
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use chrono::{DateTime, Utc};
7use reqwest::Client;
8
9use crate::article::Article;
10use crate::cache::CacheStore;
11use crate::config::{Config, FeedEntry};
12
13pub struct ArticleStore {
14    articles: Vec<Article>,
15    feeds: Vec<FeedEntry>,
16    config: Config,
17    cache: CacheStore,
18    client: Client,
19}
20
21#[derive(Debug, Clone, Default)]
22pub struct FilterParams {
23    pub show_read: bool,
24    pub from: Option<DateTime<Utc>>,
25    pub limit: Option<usize>,
26}
27
28impl ArticleStore {
29    pub fn new(feeds: Vec<FeedEntry>, config: Config, data_dir: PathBuf) -> Self {
30        let client = Client::builder()
31            .timeout(Duration::from_secs(10))
32            .connect_timeout(Duration::from_secs(5))
33            .gzip(true)
34            .brotli(true)
35            .build()
36            .unwrap_or_else(|_| Client::new());
37        Self::with_client(feeds, config, data_dir, client)
38    }
39
40    pub fn with_client(
41        feeds: Vec<FeedEntry>,
42        config: Config,
43        data_dir: PathBuf,
44        client: Client,
45    ) -> Self {
46        Self {
47            articles: Vec::new(),
48            feeds,
49            config,
50            cache: CacheStore::new(data_dir),
51            client,
52        }
53    }
54
55    pub fn feeds(&self) -> &[FeedEntry] {
56        &self.feeds
57    }
58
59    pub fn config(&self) -> &Config {
60        &self.config
61    }
62
63    pub fn cache(&self) -> &CacheStore {
64        &self.cache
65    }
66
67    pub fn data_dir(&self) -> &Path {
68        self.cache.data_dir()
69    }
70
71    pub fn client(&self) -> &Client {
72        &self.client
73    }
74
75    /// Direct access to all articles.
76    pub fn articles(&self) -> &[Article] {
77        &self.articles
78    }
79
80    /// Replace internal articles (used in tests and for direct manipulation).
81    pub fn set_articles(&mut self, articles: Vec<Article>) {
82        self.articles = articles;
83    }
84
85    pub fn take_articles(&mut self) -> Vec<Article> {
86        std::mem::take(&mut self.articles)
87    }
88
89    /// Get article by index.
90    pub fn get(&self, index: usize) -> Option<&Article> {
91        self.articles.get(index)
92    }
93
94    /// Number of articles.
95    pub fn len(&self) -> usize {
96        self.articles.len()
97    }
98
99    /// Whether the store has no articles.
100    pub fn is_empty(&self) -> bool {
101        self.articles.is_empty()
102    }
103
104    /// Mark an article as read by index (internal state + cache).
105    pub fn mark_read(&mut self, index: usize) {
106        if let Some(a) = self.articles.get_mut(index) {
107            a.read = true;
108            let cache = self.cache.clone();
109            let feed_url = a.feed_url.clone();
110            let url = a.url.clone();
111            tokio::task::spawn_blocking(move || {
112                let _ = cache.set_read_status(&feed_url, &url, true);
113            });
114        }
115    }
116
117    /// Toggle read status of an article by index. Returns the new read state.
118    pub fn toggle_read(&mut self, index: usize) -> bool {
119        if let Some(a) = self.articles.get_mut(index) {
120            a.read = !a.read;
121            let new_read = a.read;
122            let cache = self.cache.clone();
123            let feed_url = a.feed_url.clone();
124            let url = a.url.clone();
125            tokio::task::spawn_blocking(move || {
126                let _ = cache.set_read_status(&feed_url, &url, new_read);
127            });
128            new_read
129        } else {
130            false
131        }
132    }
133
134    /// Return indices of filtered articles. Does not modify internal state.
135    pub fn query(&self, params: &FilterParams) -> Vec<usize> {
136        let mut result: Vec<usize> = self
137            .articles
138            .iter()
139            .enumerate()
140            .filter(|(_, a)| params.show_read || !a.read)
141            .filter(|(_, a)| match params.from {
142                Some(from) => a.published.is_none_or(|dt| dt >= from),
143                None => true,
144            })
145            .map(|(i, _)| i)
146            .collect();
147
148        if let Some(limit) = params.limit {
149            result.truncate(limit);
150        }
151
152        result
153    }
154
155    /// Return cloned articles matching the filter (for CLI display compatibility).
156    pub fn query_articles(&self, params: &FilterParams) -> Vec<Article> {
157        self.query(params)
158            .into_iter()
159            .filter_map(|i| self.articles.get(i).cloned())
160            .collect()
161    }
162}