p hands the item's downloaded file (/media/{id}) to mpv in its own window, with the token as a
header. Moving onto an item with j or k marks it read, as selecting one on the page does. The
feed list follows the page's order: pinned first, a subscribed OPML a folder that Space opens,
its unread the sum of its feeds', and P pins or unpins. scripts/build-tui.sh cross-builds the
binary for Linux and macOS under the same cap as the image builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
642 lines
24 KiB
Rust
642 lines
24 KiB
Rust
//! 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<String>,
|
||
unread: i64,
|
||
/// The OPML it came from.
|
||
group: Option<String>,
|
||
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<String>) -> Vec<(usize, bool)> {
|
||
let listed = |id: &str| feeds.iter().any(|f| f.id == id);
|
||
let mut tops: Vec<usize> =
|
||
(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<usize> = (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<String>,
|
||
link: Option<String>,
|
||
published: Option<i64>,
|
||
description: Option<String>,
|
||
read: bool,
|
||
flagged: bool,
|
||
enclosures: Vec<Enclosure>,
|
||
}
|
||
|
||
/// 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<String>,
|
||
}
|
||
|
||
/// 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<Entry>,
|
||
}
|
||
|
||
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<reqwest::blocking::Response> {
|
||
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<Vec<Feed>> {
|
||
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<EntryPage> {
|
||
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<Option<Feed>> {
|
||
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<Feed>,
|
||
/// 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<String>,
|
||
feed_list: ListState,
|
||
entries: Vec<Entry>,
|
||
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<T>(&mut self, what: Result<T>) -> Option<T> {
|
||
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<i64>) -> 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::<Vec<_>>().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::<Vec<_>>().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 = "<p>One & two,\n <b>bold</b>.</p><p>Next line<br>broken — ’s &bogus; &</p>\
|
||
<ul><li>a</li><li>b</li></ul>";
|
||
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 <i>b</i> c"), "a b c");
|
||
assert_eq!(text_of("<b>x</b> <i>y</i>"), "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));
|
||
}
|
||
}
|