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
This commit is contained in:
@@ -3,7 +3,8 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(ffprobe -v error *)",
|
"Bash(ffprobe -v error *)",
|
||||||
"Bash(env)",
|
"Bash(env)",
|
||||||
"mcp__outline__read_document"
|
"mcp__outline__read_document",
|
||||||
|
"Bash(sh scripts/build-tui.sh)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
|
|||||||
@@ -12,3 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- `ipx`, iPX's reader in a terminal: your feeds, a feed's items and an item's text, read and flagged as on the page, signed in with an API token (#437).
|
- `ipx`, iPX's reader in a terminal: your feeds, a feed's items and an item's text, read and flagged as on the page, signed in with an API token (#437).
|
||||||
|
- `p` plays the item's file in mpv (#439).
|
||||||
|
- Moving onto an item with `j` or `k` marks it read, as on the page (#441).
|
||||||
|
- Feeds are listed as on the page: a subscribed OPML is a folder `Space` opens, and pinned feeds are at the top; `P` pins and unpins (#440).
|
||||||
|
|||||||
@@ -15,13 +15,18 @@ IPX_URL=http://192.168.1.130:8099 IPX_API_TOKEN=... target/release/ipx
|
|||||||
| key | does |
|
| key | does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `j` `k`, arrows | move, or scroll the item's text |
|
| `j` `k`, arrows | move, or scroll the item's text |
|
||||||
| `Enter`, `l`, `Tab` | open the feed or the item; opening an item marks it read |
|
| `Enter`, `l`, `Tab` | open the feed or the item |
|
||||||
| `h`, `Esc` | back a pane; `Esc` on the feeds quits |
|
| `h`, `Esc` | back a pane; `Esc` on the feeds quits |
|
||||||
| `Space`, `PgUp` `PgDn` | page through the item's text |
|
| `Space`, `PgUp` `PgDn` | page through the item's text |
|
||||||
|
| `Space` | on the feeds, open or close a folder: a subscribed OPML, its feeds inside |
|
||||||
|
| `P` | pin the feed or folder to the top of the list, or unpin it |
|
||||||
|
| `p` | play the item's file in [mpv](https://mpv.io), in a window of its own |
|
||||||
| `m` | mark the item read or unread |
|
| `m` | mark the item read or unread |
|
||||||
| `f` | flag or unflag it |
|
| `f` | flag or unflag it |
|
||||||
| `u` | unread items only, or every item (unread only to start) |
|
| `u` | unread items only, or every item (unread only to start) |
|
||||||
| `r` | reload |
|
| `r` | reload |
|
||||||
| `q` | quit |
|
| `q` | quit |
|
||||||
|
|
||||||
It shows a feed's newest 200 items.
|
An item is marked read when you move onto it or open it, as on the page. It shows a feed's newest
|
||||||
|
200 items. Playing needs `mpv` on the path (`brew install mpv` on a Mac),
|
||||||
|
and where you stop is not saved to iPX as it is on the page.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap};
|
|||||||
use ratatui::{DefaultTerminal, Frame};
|
use ratatui::{DefaultTerminal, Frame};
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -34,6 +35,29 @@ struct Feed {
|
|||||||
/// The OPML it came from.
|
/// The OPML it came from.
|
||||||
group: Option<String>,
|
group: Option<String>,
|
||||||
hide_from_all: bool,
|
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.
|
/// The part of db.rs's EntryRow this needs.
|
||||||
@@ -47,6 +71,20 @@ struct Entry {
|
|||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
read: bool,
|
read: bool,
|
||||||
flagged: 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)]
|
#[derive(Deserialize)]
|
||||||
@@ -77,6 +115,29 @@ impl Api {
|
|||||||
Ok(res)
|
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>> {
|
fn feeds(&self) -> Result<Vec<Feed>> {
|
||||||
Ok(self.send(self.http.get(self.url(&["api", "feeds"])))?.json()?)
|
Ok(self.send(self.http.get(self.url(&["api", "feeds"])))?.json()?)
|
||||||
}
|
}
|
||||||
@@ -109,8 +170,11 @@ enum Pane {
|
|||||||
|
|
||||||
struct App {
|
struct App {
|
||||||
api: Api,
|
api: Api,
|
||||||
/// Your feeds, after All Subscriptions, which is index 0 of `feed_list`.
|
|
||||||
feeds: Vec<Feed>,
|
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,
|
feed_list: ListState,
|
||||||
entries: Vec<Entry>,
|
entries: Vec<Entry>,
|
||||||
total: i64,
|
total: i64,
|
||||||
@@ -122,9 +186,29 @@ struct App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
fn feed_id(&self) -> Option<&str> {
|
fn feed(&self) -> Option<&Feed> {
|
||||||
let i = self.feed_list.selected()?;
|
let i = self.feed_list.selected()?;
|
||||||
self.feeds.get(i.checked_sub(1)?).map(|f| f.id.as_str())
|
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> {
|
fn entry(&self) -> Option<&Entry> {
|
||||||
@@ -139,6 +223,7 @@ impl App {
|
|||||||
fn load_feeds(&mut self) {
|
fn load_feeds(&mut self) {
|
||||||
if let Some(feeds) = self.attempt(self.api.feeds()) {
|
if let Some(feeds) = self.attempt(self.api.feeds()) {
|
||||||
self.feeds = feeds;
|
self.feeds = feeds;
|
||||||
|
self.arrange();
|
||||||
self.status.clear();
|
self.status.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,13 +256,18 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Opens the selected item, which marks it read, as opening one on the page does.
|
||||||
fn open(&mut self) {
|
fn open(&mut self) {
|
||||||
self.scroll = 0;
|
self.scroll = 0;
|
||||||
self.pane = Pane::Text;
|
self.pane = Pane::Text;
|
||||||
if self.entry().is_some_and(|e| !e.read) {
|
self.mark_read();
|
||||||
self.flag("read", |_| true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn step(&mut self, down: bool) {
|
fn step(&mut self, down: bool) {
|
||||||
@@ -196,6 +286,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
if self.pane == Pane::Items {
|
if self.pane == Pane::Items {
|
||||||
self.scroll = 0;
|
self.scroll = 0;
|
||||||
|
self.mark_read();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +300,22 @@ impl App {
|
|||||||
KeyCode::PageDown | KeyCode::Char(' ') if self.pane == Pane::Text => {
|
KeyCode::PageDown | KeyCode::Char(' ') if self.pane == Pane::Text => {
|
||||||
self.scroll = self.scroll.saturating_add(10)
|
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::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 {
|
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => match self.pane {
|
||||||
Pane::Feeds => {
|
Pane::Feeds => {
|
||||||
@@ -224,6 +331,18 @@ impl App {
|
|||||||
_ => Pane::Feeds,
|
_ => 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('m') => self.flag("read", |e| !e.read),
|
||||||
KeyCode::Char('f') => self.flag("flagged", |e| !e.flagged),
|
KeyCode::Char('f') => self.flag("flagged", |e| !e.flagged),
|
||||||
KeyCode::Char('u') => {
|
KeyCode::Char('u') => {
|
||||||
@@ -258,10 +377,23 @@ impl App {
|
|||||||
.filter(|f| !f.hide_from_all && !f.group.as_deref().is_some_and(hidden))
|
.filter(|f| !f.hide_from_all && !f.group.as_deref().is_some_and(hidden))
|
||||||
.map(|f| f.unread)
|
.map(|f| f.unread)
|
||||||
.sum();
|
.sum();
|
||||||
let feeds = std::iter::once(format!("All Subscriptions ({all})")).chain(self.feeds.iter().map(|f| {
|
let feeds =
|
||||||
let name = f.title.as_deref().unwrap_or(&f.url);
|
std::iter::once(format!("All Subscriptions ({all})")).chain(self.rows.iter().map(|&(i, inside)| {
|
||||||
if f.unread > 0 { format!("{name} ({})", f.unread) } else { name.to_string() }
|
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);
|
let feeds = List::new(feeds).block(block("Feeds".into(), Pane::Feeds)).highlight_style(picked);
|
||||||
frame.render_stateful_widget(feeds, left, &mut self.feed_list);
|
frame.render_stateful_widget(feeds, left, &mut self.feed_list);
|
||||||
|
|
||||||
@@ -281,6 +413,9 @@ impl App {
|
|||||||
if let Some(link) = &e.link {
|
if let Some(link) = &e.link {
|
||||||
lines.push(Line::from(link.clone()).dim());
|
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.push(Line::default());
|
||||||
lines
|
lines
|
||||||
.extend(text_of(e.description.as_deref().unwrap_or("")).lines().map(|l| Line::from(l.to_string())));
|
.extend(text_of(e.description.as_deref().unwrap_or("")).lines().map(|l| Line::from(l.to_string())));
|
||||||
@@ -291,7 +426,7 @@ impl App {
|
|||||||
let text = Paragraph::new(text).wrap(Wrap { trim: false }).scroll((self.scroll, 0));
|
let text = Paragraph::new(text).wrap(Wrap { trim: false }).scroll((self.scroll, 0));
|
||||||
frame.render_widget(text.block(block("Item".into(), Pane::Text)), bottom);
|
frame.render_widget(text.block(block("Item".into(), Pane::Text)), bottom);
|
||||||
|
|
||||||
let help = "j/k move enter open h back m read f flag u unread only r reload q quit";
|
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 };
|
let foot_text = if self.status.is_empty() { help } else { &self.status };
|
||||||
frame.render_widget(Paragraph::new(foot_text).dim(), foot);
|
frame.render_widget(Paragraph::new(foot_text).dim(), foot);
|
||||||
}
|
}
|
||||||
@@ -419,6 +554,8 @@ fn main() -> Result<()> {
|
|||||||
let mut app = App {
|
let mut app = App {
|
||||||
api,
|
api,
|
||||||
feeds,
|
feeds,
|
||||||
|
rows: vec![],
|
||||||
|
open: HashSet::new(),
|
||||||
feed_list: ListState::default().with_selected(Some(0)),
|
feed_list: ListState::default().with_selected(Some(0)),
|
||||||
entries: vec![],
|
entries: vec![],
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -428,6 +565,7 @@ fn main() -> Result<()> {
|
|||||||
unread_only: true,
|
unread_only: true,
|
||||||
status: String::new(),
|
status: String::new(),
|
||||||
};
|
};
|
||||||
|
app.arrange();
|
||||||
app.load_entries();
|
app.load_entries();
|
||||||
let mut terminal = ratatui::init();
|
let mut terminal = ratatui::init();
|
||||||
let result = run(&mut terminal, &mut app);
|
let result = run(&mut terminal, &mut app);
|
||||||
@@ -448,4 +586,56 @@ mod tests {
|
|||||||
assert_eq!(text_of("a <i>b</i> c"), "a b c");
|
assert_eq!(text_of("a <i>b</i> c"), "a b c");
|
||||||
assert_eq!(text_of("<b>x</b> <i>y</i>"), "x y");
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
scripts/build-tui.sh
Normal file
52
scripts/build-tui.sh
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# The terminal reader, `ipx`, for Linux amd64 and Apple silicon Macs, into target/tui/.
|
||||||
|
#
|
||||||
|
# sh scripts/build-tui.sh
|
||||||
|
#
|
||||||
|
# Cross-compiled with cargo-zigbuild, whose image carries zig as the linker and macOS's SDK, so no
|
||||||
|
# Mac is needed. The Linux build is musl and static: it runs on any distribution. A release build
|
||||||
|
# of every dependency is the kind that took Tower down on 2026-11-04, so it runs in a container
|
||||||
|
# under the same cap as scripts/build-image.sh's builder (6 GB, six threads); if the cap kills it,
|
||||||
|
# say so and leave the cap be.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
MEM=6g
|
||||||
|
CPUS=0-5
|
||||||
|
NEED_KB=$((9 * 1024 * 1024))
|
||||||
|
MAX_LOAD=12
|
||||||
|
IMAGE=ghcr.io/rust-cross/cargo-zigbuild:0.24.1
|
||||||
|
TARGETS="x86_64-unknown-linux-musl aarch64-apple-darwin"
|
||||||
|
# Tower's paths, since the Docker daemon is Tower's (see scripts/coverage.sh).
|
||||||
|
SRC=/mnt/user/vscode/ipx
|
||||||
|
TARGET=/mnt/fast/vscode/ipx/target-real
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
avail=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)
|
||||||
|
load=$(cut -d' ' -f1 /proc/loadavg)
|
||||||
|
echo "MemAvailable $((avail / 1024)) MB, load $load"
|
||||||
|
if [ "$avail" -lt "$NEED_KB" ]; then
|
||||||
|
echo "refusing: under $((NEED_KB / 1024 / 1024)) GB available. Wait, or find what is using it." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if awk -v l="$load" -v m="$MAX_LOAD" 'BEGIN { exit !(l > m) }'; then
|
||||||
|
echo "refusing: load over $MAX_LOAD. Wait for it to come down." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The source read only; the registry and the compiled crates are kept between runs.
|
||||||
|
docker run --rm --memory "$MEM" --memory-swap "$MEM" --cpuset-cpus "$CPUS" \
|
||||||
|
-v "$SRC:/work:ro" -v "$TARGET:/target" -v "$TARGET/tui-registry:/usr/local/cargo/registry" \
|
||||||
|
-e CARGO_TARGET_DIR=/target/tui-build -w /work "$IMAGE" sh -euc "
|
||||||
|
rustup target add $TARGETS >/dev/null
|
||||||
|
for t in $TARGETS; do cargo zigbuild --locked --release -p ipx-tui --target \$t; done
|
||||||
|
mkdir -p /target/tui
|
||||||
|
# -f replaces a binary someone is running (Text file busy) rather than writing into it.
|
||||||
|
cp -f /target/tui-build/x86_64-unknown-linux-musl/release/ipx /target/tui/ipx-linux-amd64
|
||||||
|
cp -f /target/tui-build/aarch64-apple-darwin/release/ipx /target/tui/ipx-macos-arm64
|
||||||
|
" || {
|
||||||
|
code=$?
|
||||||
|
[ "$code" -eq 137 ] && echo "killed at the ${MEM} memory cap: tell the user, do not raise it" >&2
|
||||||
|
exit "$code"
|
||||||
|
}
|
||||||
|
ls -l target/tui
|
||||||
Reference in New Issue
Block a user