//! A terminal reader for ipx (#437): your feeds, a feed's items and an item's text, through the //! same API the page uses, as the person whose API token it is given. use anyhow::{Context, Result, bail}; use clap::Parser; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Modifier, Style, Stylize}; use ratatui::text::Line; use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap}; use ratatui::{DefaultTerminal, Frame}; use reqwest::Url; use serde::Deserialize; use std::collections::HashSet; use std::time::Duration; #[derive(Parser)] #[command(name = "ipx", version, about = "Read iPX in a terminal")] struct Args { /// Where iPX is, as a browser reaches it. #[arg(long, env = "IPX_URL", default_value = "http://localhost:8099")] url: Url, /// An API token, made in the page's Settings. #[arg(long, env = "IPX_API_TOKEN", hide_env_values = true)] token: String, } /// The part of web.rs's FeedRow this needs. #[derive(Deserialize)] struct Feed { id: String, url: String, title: Option, unread: i64, /// The OPML it came from. group: Option, hide_from_all: bool, pinned: bool, } /// The feed list as the page draws it (feeds.ts renderFeeds), as indexes into `feeds` and /// whether each is inside a folder: pinned rows first, a subscribed OPML a folder with its feeds /// under it when open (those with unread first), and a pinned feed lifted out of its folder. fn order(feeds: &[Feed], open: &HashSet) -> Vec<(usize, bool)> { let listed = |id: &str| feeds.iter().any(|f| f.id == id); let mut tops: Vec = (0..feeds.len()).filter(|&i| feeds[i].pinned || !feeds[i].group.as_deref().is_some_and(listed)).collect(); tops.sort_by_key(|&i| !feeds[i].pinned); let mut rows = vec![]; for t in tops { rows.push((t, false)); if open.contains(&feeds[t].id) { let mut inside: Vec = (0..feeds.len()) .filter(|&i| !feeds[i].pinned && feeds[i].group.as_deref() == Some(feeds[t].id.as_str())) .collect(); inside.sort_by_key(|&i| feeds[i].unread == 0); rows.extend(inside.into_iter().map(|i| (i, true))); } } rows } /// The part of db.rs's EntryRow this needs. #[derive(Deserialize)] struct Entry { guid: String, feed_id: String, title: Option, link: Option, published: Option, description: Option, read: bool, flagged: bool, enclosures: Vec, } /// The part of db.rs's EncRow this needs: its id is its address, `/media/{id}`, which is there /// only once it is downloaded (`path`); one skipped or still to come answers 404. #[derive(Deserialize)] struct Enclosure { id: i64, path: Option, } /// What `p` plays: the item's first file iPX has. fn playable(e: &Entry) -> Option<&Enclosure> { e.enclosures.iter().find(|x| x.path.is_some()) } #[derive(Deserialize)] struct EntryPage { total: i64, entries: Vec, } struct Api { http: reqwest::blocking::Client, base: Url, token: String, } impl Api { /// The API's address for these path segments, each escaped: a guid is often a URL itself. fn url(&self, segments: &[&str]) -> Url { let mut url = self.base.clone(); url.path_segments_mut().expect("an http URL has a path").pop_if_empty().extend(segments); url } fn send(&self, req: reqwest::blocking::RequestBuilder) -> Result { let res = req.bearer_auth(&self.token).send()?; if !res.status().is_success() { bail!("{} {}", res.status(), res.text().unwrap_or_default()); } Ok(res) } /// Plays a file in mpv, in a window of its own so the terminal stays the reader's, with the /// token in a header as every other request has it. // shortcut: the token is on mpv's command line, which `ps` shows anyone on this machine; // pass it through a private mpv config file if the reader is used on a shared one. fn play(&self, enc: &Enclosure) -> Result<()> { let url = self.url(&["media", &enc.id.to_string()]); std::process::Command::new("mpv") .arg("--force-window=yes") .arg(format!("--http-header-fields=Authorization: Bearer {}", self.token)) .arg(url.as_str()) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() .context("could not start mpv; is it installed?")?; Ok(()) } fn set_pinned(&self, feed: &str, on: bool) -> Result<()> { let req = self.http.patch(self.url(&["api", "feeds", feed])).json(&serde_json::json!({ "pinned": on })); self.send(req).map(drop) } fn feeds(&self) -> Result> { Ok(self.send(self.http.get(self.url(&["api", "feeds"])))?.json()?) } /// The newest items of one feed, or of all of them when `feed` is None. // shortcut: the newest 200 only, no paging; page through `offset` if someone wants older. fn entries(&self, feed: Option<&str>, unread_only: bool) -> Result { let mut url = match feed { Some(id) => self.url(&["api", "feeds", id, "entries"]), None => self.url(&["api", "entries"]), }; let filter = if unread_only { "unread" } else { "all" }; url.query_pairs_mut().append_pair("limit", "200").append_pair("filter", filter); Ok(self.send(self.http.get(url))?.json()?) } /// Sets an item's read or flagged, and answers with its feed's row, for the new unread count. fn set_flag(&self, e: &Entry, flag: &str, on: bool) -> Result> { let url = self.url(&["api", "entries", &e.feed_id, &e.guid, "flags"]); Ok(self.send(self.http.post(url).json(&serde_json::json!({ flag: on })))?.json()?) } } #[derive(PartialEq, Clone, Copy)] enum Pane { Feeds, Items, Text, } struct App { api: Api, feeds: Vec, /// What the feed list shows, after All Subscriptions, which is index 0 of `feed_list`. rows: Vec<(usize, bool)>, /// The folders open; they start closed, as on the page, since one can hold dozens of feeds. open: HashSet, feed_list: ListState, entries: Vec, total: i64, entry_list: ListState, pane: Pane, scroll: u16, unread_only: bool, status: String, } impl App { fn feed(&self) -> Option<&Feed> { let i = self.feed_list.selected()?; self.rows.get(i.checked_sub(1)?).map(|&(f, _)| &self.feeds[f]) } fn feed_id(&self) -> Option<&str> { self.feed().map(|f| f.id.as_str()) } /// The feed list again after the feeds or the open folders change, on the same feed, or on /// its folder when that has just closed over it. fn arrange(&mut self) { let was = self.feed().map(|f| (f.id.clone(), f.group.clone())); self.rows = order(&self.feeds, &self.open); let at = |id: &str| self.rows.iter().position(|&(f, _)| self.feeds[f].id == id).map(|i| i + 1); let row = was.and_then(|(id, group)| at(&id).or_else(|| group.and_then(|g| at(&g)))); self.feed_list.select(Some(row.unwrap_or(0))); } /// The unread count a row shows: a folder holds no items itself, so it is what is inside. fn unread(&self, f: &Feed) -> i64 { let inside: Vec<_> = self.feeds.iter().filter(|c| c.group.as_deref() == Some(f.id.as_str())).collect(); if inside.is_empty() { f.unread } else { inside.iter().map(|c| c.unread).sum() } } fn entry(&self) -> Option<&Entry> { self.entries.get(self.entry_list.selected()?) } /// Runs an API call, putting what went wrong in the status line instead of ending the program. fn attempt(&mut self, what: Result) -> Option { what.map_err(|e| self.status = format!("{e:#}")).ok() } fn load_feeds(&mut self) { if let Some(feeds) = self.attempt(self.api.feeds()) { self.feeds = feeds; self.arrange(); self.status.clear(); } } fn load_entries(&mut self) { let page = self.api.entries(self.feed_id(), self.unread_only); if let Some(page) = self.attempt(page) { self.entries = page.entries; self.total = page.total; self.entry_list.select(if self.entries.is_empty() { None } else { Some(0) }); self.scroll = 0; self.status.clear(); } } /// Sets the selected item's read or flagged, as the page does. fn flag(&mut self, flag: &str, on: impl Fn(&Entry) -> bool) { let Some(e) = self.entry() else { return }; let on = on(e); let result = self.api.set_flag(e, flag, on); let Some(row) = self.attempt(result) else { return }; if let Some(i) = self.entry_list.selected() { let e = &mut self.entries[i]; if flag == "read" { e.read = on } else { e.flagged = on } } if let Some(row) = row && let Some(f) = self.feeds.iter_mut().find(|f| f.id == row.id) { f.unread = row.unread; } } /// Selecting an item is reading it, as on the page (items.ts markRead), j and k included. fn mark_read(&mut self) { if self.entry().is_some_and(|e| !e.read) { self.flag("read", |_| true); } } /// Opens the selected item, which marks it read, as opening one on the page does. fn open(&mut self) { self.scroll = 0; self.pane = Pane::Text; self.mark_read(); } fn step(&mut self, down: bool) { let list = match self.pane { Pane::Feeds => &mut self.feed_list, Pane::Items => &mut self.entry_list, Pane::Text => { self.scroll = if down { self.scroll.saturating_add(1) } else { self.scroll.saturating_sub(1) }; return; } }; if down { list.select_next() } else { list.select_previous() } if self.pane == Pane::Items { self.scroll = 0; self.mark_read(); } } /// Handles a key, answering false when it is time to quit. fn key(&mut self, code: KeyCode) -> bool { match code { KeyCode::Char('q') => return false, KeyCode::Esc if self.pane == Pane::Feeds => return false, KeyCode::Char('j') | KeyCode::Down => self.step(true), KeyCode::Char('k') | KeyCode::Up => self.step(false), KeyCode::PageDown | KeyCode::Char(' ') if self.pane == Pane::Text => { self.scroll = self.scroll.saturating_add(10) } KeyCode::Char(' ') if self.pane == Pane::Feeds => { if let Some(id) = self.feed_id().map(str::to_owned) { if !self.open.remove(&id) { self.open.insert(id); } self.arrange(); } } KeyCode::Char('P') => { let Some(f) = self.feed() else { return true }; let (id, on) = (f.id.clone(), !f.pinned); if self.attempt(self.api.set_pinned(&id, on)).is_some() { self.load_feeds(); self.status = if on { "pinned to the top" } else { "unpinned" }.into(); } } KeyCode::PageUp if self.pane == Pane::Text => self.scroll = self.scroll.saturating_sub(10), KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => match self.pane { Pane::Feeds => { self.load_entries(); self.pane = Pane::Items; } Pane::Items => self.open(), Pane::Text => {} }, KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left | KeyCode::BackTab => { self.pane = match self.pane { Pane::Text => Pane::Items, _ => Pane::Feeds, } } KeyCode::Char('p') => { let Some(e) = self.entry() else { return true }; match playable(e).map(|enc| self.api.play(enc)) { Some(r) => { if self.attempt(r).is_some() { self.status = "playing in mpv".into(); } } None if e.enclosures.is_empty() => self.status = "this item has no file to play".into(), None => self.status = "iPX has not downloaded this item's file".into(), } } KeyCode::Char('m') => self.flag("read", |e| !e.read), KeyCode::Char('f') => self.flag("flagged", |e| !e.flagged), KeyCode::Char('u') => { self.unread_only = !self.unread_only; self.load_entries(); } KeyCode::Char('r') => { self.load_feeds(); self.load_entries(); } _ => {} } true } fn draw(&mut self, frame: &mut Frame) { let [main, foot] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area()); let [left, right] = Layout::horizontal([Constraint::Percentage(28), Constraint::Fill(1)]).areas(main); let [top, bottom] = Layout::vertical([Constraint::Percentage(40), Constraint::Fill(1)]).areas(right); let block = |title: String, pane: Pane| { let b = Block::bordered().title(title); if self.pane == pane { b.border_style(Style::new().bold()) } else { b.dim() } }; let picked = Style::new().add_modifier(Modifier::REVERSED); // What the page counts (feeds.ts inAll): not a feed hidden from All Subscriptions, or // one from an OPML that is (#288). let hidden = |id: &str| self.feeds.iter().any(|g| g.id == id && g.hide_from_all); let all: i64 = self .feeds .iter() .filter(|f| !f.hide_from_all && !f.group.as_deref().is_some_and(hidden)) .map(|f| f.unread) .sum(); let feeds = std::iter::once(format!("All Subscriptions ({all})")).chain(self.rows.iter().map(|&(i, inside)| { let f = &self.feeds[i]; let folder = self.feeds.iter().any(|c| c.group.as_deref() == Some(f.id.as_str())); let lead = match (inside, folder, self.open.contains(&f.id)) { (true, ..) => " ", (_, true, true) => "▾ ", (_, true, false) => "▸ ", _ => " ", }; let pin = if f.pinned && !inside { "↑ " } else { "" }; let name = f.title.as_deref().unwrap_or(&f.url); match self.unread(f) { 0 => format!("{lead}{pin}{name}"), n => format!("{lead}{pin}{name} ({n})"), } })); let feeds = List::new(feeds).block(block("Feeds".into(), Pane::Feeds)).highlight_style(picked); frame.render_stateful_widget(feeds, left, &mut self.feed_list); let items = self.entries.iter().map(|e| { let mark = format!("{}{}", if e.read { ' ' } else { '•' }, if e.flagged { '★' } else { ' ' }); let line = format!("{mark} {:<10} {}", date(e.published), e.title.as_deref().unwrap_or("(untitled)")); if e.read { ListItem::new(line) } else { ListItem::new(line).bold() } }); let shown = if self.unread_only { "unread" } else { "items" }; let title = format!("{} of {} {shown}", self.entries.len(), self.total); let items = List::new(items).block(block(title, Pane::Items)).highlight_style(picked); frame.render_stateful_widget(items, top, &mut self.entry_list); let text = match self.entry() { Some(e) => { let mut lines = vec![Line::from(e.title.clone().unwrap_or_default()).bold()]; if let Some(link) = &e.link { lines.push(Line::from(link.clone()).dim()); } if playable(e).is_some() { lines.push(Line::from("▶ p to play").dim()); } lines.push(Line::default()); lines .extend(text_of(e.description.as_deref().unwrap_or("")).lines().map(|l| Line::from(l.to_string()))); lines } None => vec![], }; let text = Paragraph::new(text).wrap(Wrap { trim: false }).scroll((self.scroll, 0)); frame.render_widget(text.block(block("Item".into(), Pane::Text)), bottom); let help = "j/k move enter open h back space folder P pin p play m read f flag u unread only r reload q quit"; let foot_text = if self.status.is_empty() { help } else { &self.status }; frame.render_widget(Paragraph::new(foot_text).dim(), foot); } } fn date(t: Option) -> String { t.and_then(|t| chrono::DateTime::from_timestamp(t, 0)).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default() } /// An item's HTML as plain text for a terminal: tags dropped, blocks on lines of their own, /// entities decoded. ipx has already cleaned it, so there is no script or style to skip. fn text_of(html: &str) -> String { let mut out = String::new(); let mut rest = html; loop { let (text, tag) = match rest.find('<') { Some(i) => (&rest[..i], Some(&rest[i + 1..])), None => (rest, None), }; // HTML's own whitespace is not layout; the tags say where lines break. if text.starts_with(char::is_whitespace) && !out.is_empty() && !out.ends_with(['\n', ' ']) { out.push(' '); } out.push_str(&decode(&text.split_whitespace().collect::>().join(" "))); if text.ends_with(char::is_whitespace) && !text.trim().is_empty() { out.push(' '); } let Some(tag) = tag else { break }; let Some(end) = tag.find('>') else { break }; let (inner, closing) = match tag[..end].strip_prefix('/') { Some(t) => (t, true), None => (&tag[..end], false), }; let name = inner.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or("").to_ascii_lowercase(); match name.as_str() { "br" => out.push('\n'), "li" => { if !out.is_empty() && !out.ends_with('\n') { out.push('\n'); } if !closing { out.push_str("• "); } } "p" | "div" | "ul" | "ol" | "blockquote" | "pre" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" if !out.is_empty() && !out.ends_with("\n\n") => { out.push_str(if out.ends_with('\n') { "\n" } else { "\n\n" }); } _ => {} } rest = &tag[end + 1..]; } out.lines().map(str::trim_end).collect::>().join("\n").trim().to_string() } /// HTML's entities, the named ones feeds use and every numbered one. fn decode(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut rest = s; while let Some(i) = rest.find('&') { out.push_str(&rest[..i]); rest = &rest[i..]; let end = rest[..rest.len().min(12)].find(';'); let ch = end.and_then(|end| match &rest[1..end] { "amp" => Some('&'), "lt" => Some('<'), "gt" => Some('>'), "quot" => Some('"'), "apos" => Some('\''), "nbsp" => Some(' '), "hellip" => Some('…'), "mdash" => Some('—'), "ndash" => Some('–'), "rsquo" => Some('’'), "lsquo" => Some('‘'), "rdquo" => Some('”'), "ldquo" => Some('“'), n => { let n = n.strip_prefix('#')?; let code = match n.strip_prefix(['x', 'X']) { Some(hex) => u32::from_str_radix(hex, 16).ok()?, None => n.parse().ok()?, }; char::from_u32(code) } }); match (ch, end) { (Some(c), Some(end)) => { out.push(c); rest = &rest[end + 1..]; } _ => { out.push('&'); rest = &rest[1..]; } } } out.push_str(rest); out } fn run(terminal: &mut DefaultTerminal, app: &mut App) -> Result<()> { loop { terminal.draw(|f| app.draw(f))?; // A poll, not a blocking read, so a resize redraws without waiting for a key. if !event::poll(Duration::from_millis(500))? { continue; } if let Event::Key(k) = event::read()? && k.kind == KeyEventKind::Press && !app.key(k.code) { return Ok(()); } } } fn main() -> Result<()> { let args = Args::parse(); let http = reqwest::blocking::Client::builder().timeout(Duration::from_secs(30)).build()?; let api = Api { http, base: args.url, token: args.token }; // Before the terminal is taken over, so a wrong address or token is said plainly. let feeds = api.feeds().context("could not read your feeds")?; let mut app = App { api, feeds, rows: vec![], open: HashSet::new(), feed_list: ListState::default().with_selected(Some(0)), entries: vec![], total: 0, entry_list: ListState::default(), pane: Pane::Feeds, scroll: 0, unread_only: true, status: String::new(), }; app.arrange(); app.load_entries(); let mut terminal = ratatui::init(); let result = run(&mut terminal, &mut app); ratatui::restore(); result } #[cfg(test)] mod tests { use super::*; #[test] fn text_of_html() { let html = "

One & two,\n bold.

Next line
broken — ’s &bogus; &

\
  • a
  • b
"; assert_eq!(text_of(html), "One & two, bold.\n\nNext line\nbroken — ’s &bogus; &\n\n• a\n• b"); assert_eq!(text_of("plain"), "plain"); assert_eq!(text_of("a b c"), "a b c"); assert_eq!(text_of("x y"), "x y"); } #[test] fn feeds_in_the_pages_order() { let feed = |id: &str, group: Option<&str>, pinned: bool, unread: i64| Feed { id: id.into(), url: id.into(), title: None, unread, group: group.map(Into::into), hide_from_all: false, pinned, }; let feeds = [ feed("a", None, false, 0), feed("opml", None, false, 0), feed("in-read", Some("opml"), false, 0), feed("in-unread", Some("opml"), false, 3), feed("in-pinned", Some("opml"), true, 0), feed("b", None, true, 0), feed("lost", Some("gone"), false, 0), ]; let ids = |open: &[&str]| -> Vec<(&str, bool)> { let open = open.iter().map(|s| s.to_string()).collect(); order(&feeds, &open).into_iter().map(|(i, inside)| (feeds[i].id.as_str(), inside)).collect() }; // Closed, a folder hides its feeds; a pinned one is out of it, at the top; a feed whose // folder is not listed stands on its own. assert_eq!(ids(&[]), [("in-pinned", false), ("b", false), ("a", false), ("opml", false), ("lost", false)]); assert_eq!( ids(&["opml"]), [ ("in-pinned", false), ("b", false), ("a", false), ("opml", false), ("in-unread", true), ("in-read", true), ("lost", false), ] ); } #[test] fn plays_the_first_downloaded_file() { let e: Entry = serde_json::from_value(serde_json::json!({ "guid": "g", "feed_id": "f", "title": null, "link": null, "published": null, "description": null, "read": false, "flagged": false, "enclosures": [{"id": 1, "path": null}, {"id": 2, "path": "a.mp3"}, {"id": 3, "path": "b.mp3"}], })) .unwrap(); assert_eq!(playable(&e).map(|x| x.id), Some(2)); } }