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