src/main.rs 9.4 K raw
1
use chrono::{DateTime, Datelike, Utc};
2
use color_eyre::eyre::WrapErr;
3
use crossterm::event::{KeyCode, KeyEvent};
4
use feedparser_rs::{ParsedFeed, parse_url};
5
use ratatui::{
6
    DefaultTerminal, Frame,
7
    layout::{Constraint, Direction, Layout},
8
    style::{Color, Modifier, Style},
9
    text::{Line, Span, Text},
10
    widgets::{Block, List, ListItem, ListState, Padding},
11
};
12
13
struct Item {
14
    title: String,
15
    author: String,
16
    date: String,
17
    url: Option<String>,
18
    published: Option<DateTime<Utc>>,
19
}
20
21
fn normalize_url(s: &str) -> String {
22
    if s.starts_with("http://") || s.starts_with("https://") {
23
        s.to_string()
24
    } else {
25
        format!("https://{s}")
26
    }
27
}
28
29
fn is_bare_domain(url: &str) -> bool {
30
    let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
31
    rest.split_once('/')
32
        .map_or(true, |(_, p)| p.trim_matches('/').is_empty())
33
}
34
35
fn find_feed_link(html: &str, base_url: &str) -> Option<String> {
36
    let base = base_url.trim_end_matches('/');
37
    let lower = html.to_lowercase();
38
    let mut pos = 0;
39
    while let Some(tag_start) = lower[pos..].find("<link") {
40
        let abs = pos + tag_start;
41
        let tag_end = lower[abs..].find('>')? + abs;
42
        let tag = &html[abs..=tag_end];
43
        let tag_lower = tag.to_lowercase();
44
        let is_feed =
45
            tag_lower.contains("application/rss+xml") || tag_lower.contains("application/atom+xml");
46
        if is_feed {
47
            if let Some(href) = extract_attr(tag, "href") {
48
                let resolved = if href.starts_with("http://") || href.starts_with("https://") {
49
                    href
50
                } else if href.starts_with('/') {
51
                    format!("{base}{href}")
52
                } else {
53
                    format!("{base}/{href}")
54
                };
55
                return Some(resolved);
56
            }
57
        }
58
        pos = tag_end + 1;
59
    }
60
    None
61
}
62
63
fn extract_attr(tag: &str, attr: &str) -> Option<String> {
64
    let search = format!("{attr}=");
65
    let lower = tag.to_lowercase();
66
    let start = lower.find(&search)? + search.len();
67
    let rest = &tag[start..];
68
    let (quote, end_char) = if rest.starts_with('"') {
69
        (&rest[1..], '"')
70
    } else if rest.starts_with('\'') {
71
        (&rest[1..], '\'')
72
    } else {
73
        return None;
74
    };
75
    let end = quote.find(end_char)?;
76
    Some(quote[..end].to_string())
77
}
78
79
fn build_agent() -> ureq::Agent {
80
    ureq::Agent::config_builder()
81
        .timeout_global(Some(std::time::Duration::from_secs(10)))
82
        .build()
83
        .into()
84
}
85
86
fn discover_feed(agent: &ureq::Agent, input: &str) -> color_eyre::Result<String> {
87
    let url = normalize_url(input);
88
    if !is_bare_domain(&url) {
89
        return Ok(url);
90
    }
91
    let html = agent
92
        .get(&url)
93
        .call()
94
        .wrap_err_with(|| format!("fetch {url}"))?
95
        .body_mut()
96
        .read_to_string()
97
        .wrap_err_with(|| format!("read {url}"))?;
98
    if let Some(feed_url) = find_feed_link(&html, &url) {
99
        return Ok(feed_url);
100
    }
101
    let base = url.trim_end_matches('/');
102
    const PATHS: &[&str] = &[
103
        "/feed.xml",
104
        "/rss.xml",
105
        "/atom.xml",
106
        "/feed",
107
        "/rss",
108
        "/index.xml",
109
        "/feeds/posts/default",
110
        "/blog/feed.xml",
111
        "/blog/rss.xml",
112
    ];
113
    for path in PATHS {
114
        let candidate = format!("{base}{path}");
115
        if agent
116
            .get(&candidate)
117
            .call()
118
            .map(|r| r.status() == 200)
119
            .unwrap_or(false)
120
        {
121
            return Ok(candidate);
122
        }
123
    }
124
    Err(color_eyre::eyre::eyre!("No feed found for: {input}"))
125
}
126
127
fn load_feeds(urls: &[String]) -> Vec<ParsedFeed> {
128
    let agent = build_agent();
129
    urls.iter()
130
        .filter_map(|url| {
131
            let resolved = discover_feed(&agent, url)
132
                .map_err(|e| eprintln!("warning: skipping {url}: {e}"))
133
                .ok()?;
134
            parse_url(&resolved, None, None, None)
135
                .map_err(|e| eprintln!("warning: failed to parse feed {url}: {e}"))
136
                .ok()
137
        })
138
        .collect()
139
}
140
141
fn collect_items(feeds: Vec<ParsedFeed>) -> Vec<Item> {
142
    let mut items: Vec<Item> = feeds
143
        .into_iter()
144
        .flat_map(|f| {
145
            let feed_title = f.feed.title.clone();
146
            f.entries.into_iter().map(move |e| {
147
                let author = e
148
                    .authors
149
                    .first()
150
                    .and_then(|a| a.name.as_ref().map(|n| n.to_string()))
151
                    .or_else(|| feed_title.as_ref().map(|t| t.to_string()))
152
                    .unwrap_or_else(|| "anon".into());
153
                let date = e.published.map(fmt_date).unwrap_or_else(|| "-".into());
154
                Item {
155
                    title: e
156
                        .title
157
                        .map(|t| t.to_string())
158
                        .unwrap_or_else(|| "(untitled)".into()),
159
                    author,
160
                    date,
161
                    url: e.links.into_iter().next().map(|l| l.href.to_string()),
162
                    published: e.published,
163
                }
164
            })
165
        })
166
        .collect();
167
    items.sort_by(|a, b| b.published.cmp(&a.published));
168
    items
169
}
170
171
fn main() -> color_eyre::Result<()> {
172
    color_eyre::install()?;
173
    let mut urls: Vec<String> = std::env::args().skip(1).collect();
174
    if urls.is_empty() {
175
        if let Ok(val) = std::env::var("BULLETS_FEEDS") {
176
            urls = val
177
                .split(',')
178
                .map(|s| s.trim().to_string())
179
                .filter(|s| !s.is_empty())
180
                .collect();
181
        }
182
    }
183
    if urls.is_empty() {
184
        eprintln!("No feeds provided.\n");
185
        eprintln!("Usage:");
186
        eprintln!("  bullets <feed-url> [feed-url ...]");
187
        eprintln!("  bullets https://example.com/feed.xml other.com/rss");
188
        eprintln!("\nOr set the BULLETS_FEEDS environment variable:");
189
        eprintln!("  export BULLETS_FEEDS=https://example.com/feed.xml,https://other.com/rss");
190
        std::process::exit(1);
191
    }
192
193
    let feeds = load_feeds(&urls);
194
    if feeds.is_empty() {
195
        eprintln!("No feeds loaded successfully.");
196
        std::process::exit(1);
197
    }
198
199
    let items = collect_items(feeds);
200
    if items.is_empty() {
201
        eprintln!("No entries found in any feed.");
202
        std::process::exit(1);
203
    }
204
205
    ratatui::run(|t| app(t, &items))?;
206
    Ok(())
207
}
208
209
fn app(terminal: &mut DefaultTerminal, items: &[Item]) -> std::io::Result<()> {
210
    let mut state = ListState::default();
211
    state.select(Some(0));
212
213
    loop {
214
        terminal.draw(|f| render(f, items, &mut state))?;
215
216
        if let crossterm::event::Event::Key(KeyEvent { code, .. }) = crossterm::event::read()? {
217
            let len = items.len();
218
            match code {
219
                KeyCode::Char('q') => break,
220
                KeyCode::Char('j') | KeyCode::Down => {
221
                    let next = state
222
                        .selected()
223
                        .map(|i| (i + 1).min(len.saturating_sub(1)))
224
                        .unwrap_or(0);
225
                    state.select(Some(next));
226
                }
227
                KeyCode::Char('k') | KeyCode::Up => {
228
                    let prev = state.selected().map(|i| i.saturating_sub(1)).unwrap_or(0);
229
                    state.select(Some(prev));
230
                }
231
                KeyCode::Enter => {
232
                    if let Some(i) = state.selected() {
233
                        if let Some(url) = items[i].url.as_deref() {
234
                            let _ = open::that(url);
235
                        }
236
                    }
237
                }
238
                _ => {}
239
            }
240
        }
241
    }
242
243
    Ok(())
244
}
245
246
fn fmt_date(dt: DateTime<Utc>) -> String {
247
    let day = dt.day();
248
    let suffix = match day {
249
        1 | 21 | 31 => "st",
250
        2 | 22 => "nd",
251
        3 | 23 => "rd",
252
        _ => "th",
253
    };
254
    format!("{} {}{}, {}", dt.format("%B"), day, suffix, dt.format("%Y"))
255
}
256
257
fn render(frame: &mut Frame, items: &[Item], state: &mut ListState) {
258
    let outer = frame.area();
259
    let [_, center, _] = Layout::default()
260
        .direction(Direction::Horizontal)
261
        .constraints([
262
            Constraint::Fill(1),
263
            Constraint::Max(80),
264
            Constraint::Fill(1),
265
        ])
266
        .areas(outer);
267
268
    let block = Block::new().padding(Padding::symmetric(2, 1));
269
    let inner = block.inner(center);
270
    let title_width = inner.width.saturating_sub(2) as usize;
271
272
    let dim = Style::new().fg(Color::DarkGray);
273
    let author_style = Style::new()
274
        .fg(Color::DarkGray)
275
        .add_modifier(Modifier::ITALIC);
276
277
    let selected = state.selected();
278
    let list_items: Vec<ListItem> = items
279
        .iter()
280
        .enumerate()
281
        .map(|(i, item)| {
282
            let bar = if selected == Some(i) { "▌ " } else { "  " };
283
            let mut lines = vec![Line::from(vec![
284
                Span::raw(bar),
285
                Span::styled(item.date.clone(), dim),
286
            ])];
287
            for wrapped in textwrap::wrap(&item.title, title_width.max(1)) {
288
                lines.push(Line::from(vec![
289
                    Span::raw(bar),
290
                    Span::raw(wrapped.into_owned()),
291
                ]));
292
            }
293
            lines.push(Line::from(vec![
294
                Span::raw(bar),
295
                Span::styled(item.author.clone(), author_style),
296
            ]));
297
            lines.push(Line::from(""));
298
299
            ListItem::new(Text::from(lines))
300
        })
301
        .collect();
302
303
    frame.render_widget(block, center);
304
    frame.render_stateful_widget(List::new(list_items).highlight_symbol(""), inner, state);
305
}