Skip to main content

feed/
commands.rs

1use anyhow::{bail, Context, Result};
2use chrono::{TimeZone, Utc};
3use reqwest::Client;
4use std::time::Duration;
5
6use crate::article_store::{ArticleStore, FilterParams};
7use crate::cache::CacheStore;
8use crate::cache::HttpMetadata;
9use crate::cli::Cli;
10use crate::config::{Config, FeedEntry};
11use crate::display;
12use crate::feed_source::{self, FetchResult};
13
14pub(crate) fn build_client() -> Client {
15    Client::builder()
16        .timeout(Duration::from_secs(10))
17        .connect_timeout(Duration::from_secs(5))
18        .gzip(true)
19        .brotli(true)
20        .build()
21        .unwrap_or_else(|_| Client::new())
22}
23
24pub async fn cmd_default(cli: &Cli, config: &Config, data_dir: &std::path::Path) -> Result<()> {
25    let feeds: Vec<&FeedEntry> = match (cli.tag.as_deref(), cli.name.as_deref()) {
26        (Some(t), _) => config.feeds_by_tag(t),
27        (_, Some(n)) => config.find_feed(n).into_iter().collect(),
28        _ => config.feeds.iter().collect(),
29    };
30
31    if feeds.is_empty() {
32        if config.feeds.is_empty() {
33            eprintln!("No feeds registered. Use `feed add <url>` to add one.");
34        } else {
35            eprintln!("No matching feeds found.");
36        }
37        return Ok(());
38    }
39
40    let owned_feeds: Vec<FeedEntry> = feeds.into_iter().cloned().collect();
41
42    let from = match &cli.from {
43        Some(from_str) => {
44            let from_date = from_str.parse::<chrono::NaiveDate>().map_err(|_| {
45                anyhow::anyhow!("Invalid date format: {}. Use YYYY-MM-DD", from_str)
46            })?;
47            Some(Utc.from_utc_datetime(&from_date.and_hms_opt(0, 0, 0).context("Invalid time")?))
48        }
49        None => None,
50    };
51
52    let filter_params = FilterParams {
53        show_read: cli.all,
54        from,
55        limit: cli.limit,
56    };
57
58    let mut store = ArticleStore::new(owned_feeds, config.clone(), data_dir.to_path_buf());
59
60    if cli.cli {
61        store.fetch(cli.cached).await;
62        let articles = store.query_articles(&filter_params);
63        if articles.is_empty() {
64            eprintln!("No articles found.");
65            return Ok(());
66        }
67        let items: Vec<display::DisplayItem> = articles
68            .iter()
69            .map(|a| display::DisplayItem {
70                title: &a.title,
71                url: &a.url,
72                published: a.published,
73            })
74            .collect();
75        let title = format!("{} feeds", store.feeds().len());
76        println!("{}", display::render_article_list(&title, &items));
77    } else {
78        store.fetch(true).await; // Cache first for instant TUI
79        crate::tui::run(store, filter_params).await?;
80    }
81
82    Ok(())
83}
84
85pub async fn cmd_fetch_article(url: &str) -> Result<()> {
86    let client = build_client();
87    let width = terminal_size::terminal_size()
88        .map(|(terminal_size::Width(w), _)| w as usize)
89        .unwrap_or(80);
90    let (title, text) =
91        crate::article::fetch_and_extract(&client, url, width.saturating_sub(2)).await?;
92    if !title.is_empty() {
93        println!("\n {}", title);
94    }
95    println!(" {}\n", url);
96    for line in text.lines() {
97        println!(" {}", line);
98    }
99    Ok(())
100}
101
102pub async fn cmd_fetch_feed(
103    url: &str,
104    config: &Config,
105    data_dir: &std::path::Path,
106    limit: Option<usize>,
107) -> Result<()> {
108    let client = build_client();
109    eprintln!("Resolving feed URL...");
110    let feed_url = feed_source::resolve_feed_url(&client, url).await?;
111    if feed_url != url {
112        eprintln!("Discovered feed: {}", feed_url);
113    }
114
115    let metadata = HttpMetadata {
116        etag: None,
117        last_modified: None,
118    };
119    let result = feed_source::fetch(&client, &feed_url, &metadata).await?;
120    let feed = match result {
121        FetchResult::Fetched(feed) => feed,
122        FetchResult::NotModified => {
123            eprintln!("Feed not modified");
124            return Ok(());
125        }
126    };
127
128    if config.cache.retention_days >= 0 {
129        CacheStore::new(data_dir).save_feed(
130            &feed_url,
131            &feed,
132            feed.etag.as_deref(),
133            feed.last_modified.as_deref(),
134        )?;
135    }
136
137    let entries: Vec<_> = feed
138        .entries
139        .iter()
140        .take(limit.unwrap_or(usize::MAX))
141        .collect();
142    let items: Vec<display::DisplayItem> = entries
143        .iter()
144        .map(|e| display::DisplayItem {
145            title: &e.title,
146            url: &e.url,
147            published: e.published,
148        })
149        .collect();
150    println!("{}", display::render_article_list(&feed.title, &items));
151    Ok(())
152}
153
154pub async fn cmd_add(
155    url: &str,
156    name: Option<&str>,
157    tags: &[String],
158    config_path: &std::path::Path,
159) -> Result<()> {
160    let client = build_client();
161    eprintln!("Resolving feed URL...");
162    let feed_url = feed_source::resolve_feed_url(&client, url).await?;
163    if feed_url != url {
164        eprintln!("Discovered feed: {}", feed_url);
165    }
166
167    let feed_name = match name {
168        Some(n) => n.to_string(),
169        None => {
170            eprintln!("Fetching feed title...");
171            let metadata = HttpMetadata {
172                etag: None,
173                last_modified: None,
174            };
175            let result = feed_source::fetch(&client, &feed_url, &metadata).await?;
176            match result {
177                FetchResult::Fetched(feed) => feed.title,
178                FetchResult::NotModified => "(unknown)".to_string(),
179            }
180        }
181    };
182
183    let mut config = Config::load(config_path)?;
184    config.add_feed(FeedEntry {
185        name: feed_name.clone(),
186        url: feed_url.to_string(),
187        tags: tags.to_vec(),
188        extractor: None,
189    });
190    config.save(config_path)?;
191
192    eprintln!("Added: {} ({})", feed_name, feed_url);
193    Ok(())
194}
195
196pub fn cmd_remove(target: &str, config_path: &std::path::Path) -> Result<()> {
197    let mut config = Config::load(config_path)?;
198    if config.remove_feed(target) {
199        config.save(config_path)?;
200        eprintln!("Removed: {}", target);
201    } else {
202        bail!("Feed not found: {}", target);
203    }
204    Ok(())
205}
206
207pub fn cmd_list(config: &Config) -> Result<()> {
208    println!("{}", display::render_feed_list(config));
209    Ok(())
210}
211
212pub fn cmd_tags(config: &Config) -> Result<()> {
213    println!("{}", display::render_tag_list(config));
214    Ok(())
215}