A terminal reader, ipx; the server is ipxd (#437)

ipx-tui (crates/tui) is iPX's reader in ratatui: your feeds, a feed's
items, an item's text as plain text, read and flagged as on the page,
through the API with an API token. It installs as `ipx`, so the daemon's
binary is now `ipxd`: the Dockerfile, release script, tests and every
doc and message that names its commands follow.

Naming the binary ipxd alone renamed its crate, and with it every log
target (ipx::scan became ipxd::scan): the default filters are ipx=info,
so nearly every line would have been dropped and the dashboard's queries
on the targets gone blank. The daemon's code is now a library still
called ipx, under a five-line ipxd main.rs, and the targets are as they
were.

ratatui is taken without its default features: 32 new packages in place
of 78.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
This commit is contained in:
2026-11-09 11:52:10 +00:00
parent f844cf349a
commit 711b0395f1
40 changed files with 1128 additions and 238 deletions

View File

@@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- The server's command is `ipxd`, not `ipx`: `ipxd daemon`, `ipxd status`, `ipxd user add` and the rest, in the container as `docker exec iPX ipxd ...`. `ipx` is now the reader in a terminal (#437).
- The log no longer has a line for every request that went well, over half its size: `/metrics` counts each request and its time by route and status as `ipx_http_request_duration_seconds`, a request that failed keeps its warning, and the admin page's log view still shows them all (#364).
- The log no longer has a line for every scan that finishes, a fifth of its lines; the metrics count scans, and the page's log view still shows them (#363).

View File

@@ -4,6 +4,12 @@ version = "1.5.2-dev"
edition.workspace = true
publish = false
# ipxd: `ipx` is the reader in a terminal (crates/tui), which more people type (#437). The
# code is the library, still `ipx`, so its log targets keep their names (src/lib.rs).
[[bin]]
name = "ipxd"
path = "src/main.rs"
[lints]
workspace = true

View File

@@ -44,7 +44,7 @@ pub(crate) async fn add(
}
/// Adds the feed at `url` to the catalogue unless it is there already, a feed an OPML lists
/// included, so one show is never added twice: its id, and whether it was there. For `ipx add`
/// included, so one show is never added twice: its id, and whether it was there. For `ipxd add`
/// and the page's Add feed alike, which were the same steps written twice (#161); each finds the
/// feed first, to say its own way why an address is not one.
pub async fn add_to_catalogue(
@@ -151,7 +151,7 @@ pub(crate) async fn import(ctx: &Ctx, file: &Path) -> Result<()> {
.await?
.into_iter()
.find(|u| u.is_admin)
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?;
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipxd user add <name> --admin"))?;
let doc = feed::read_opml(&text).map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
let (added, had) = lists::subscribe_opml(ctx, lists::opml_feeds(&doc)?, admin.id).await?;
println!("subscribed {} to {added} feed(s); {had} already there", admin.name);
@@ -168,7 +168,7 @@ pub(crate) async fn assemble_config(db: &db::Db, mut cfg: config::Config, path:
if config::Config::file_holds_stored(path) {
tracing::warn!(
"config.toml still lists feeds or server settings; they are ignored, since the \
database holds them now. Change them in the web UI, or with ipx add and rm."
database holds them now. Change them in the web UI, or with ipxd add and rm."
);
}
stored.apply(&mut cfg);
@@ -213,13 +213,13 @@ pub(crate) async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
pub(crate) async fn list(ctx: &Ctx) -> Result<()> {
let cfg = ctx.cfg();
if cfg.feeds.is_empty() {
println!("No feeds configured. Add one with `ipx add <url>`, or in the web UI.");
println!("No feeds configured. Add one with `ipxd add <url>`, or in the web UI.");
return Ok(());
}
for (id, feed) in &cfg.feeds {
let s = ctx.db.feed_summary(id).await?;
// The id on a line of its own, labelled: first on the title's line, antirez.com's id
// "feed" read as a heading and 'ipx fetch antirez' was tried instead (issue #82).
// "feed" read as a heading and 'ipxd fetch antirez' was tried instead (issue #82).
println!("{}", s.title.as_deref().filter(|t| !t.is_empty()).unwrap_or(id));
println!(" id {id}");
println!(" url {}", feed.url);

View File

@@ -10,7 +10,7 @@ use std::path::PathBuf;
use std::sync::Arc;
#[derive(Parser)]
#[command(name = "ipx", version, about = "A headless podcatcher")]
#[command(name = "ipxd", version, about = "A headless podcatcher")]
pub(crate) struct Cli {
/// Config file (default: $XDG_CONFIG_HOME/ipx/config.toml)
#[arg(long, global = true)]
@@ -81,7 +81,7 @@ pub(crate) enum Command {
#[derive(Subcommand)]
pub(crate) enum UserCmd {
/// Create an account. The password is read from stdin: `echo -n hunter2 | ipx user add ray`
/// Create an account. The password is read from stdin: `echo -n hunter2 | ipxd user add ray`
Add {
name: String,
/// May manage other accounts, and is who the shared web token signs in as
@@ -110,7 +110,7 @@ pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd, mut stdin: impl std::
stdin.read_to_string(&mut buf)?;
let pw = buf.trim_end_matches(['\n', '\r']).to_string();
if pw.is_empty() {
anyhow::bail!("no password on stdin: try `echo -n secret | ipx user ...`");
anyhow::bail!("no password on stdin: try `echo -n secret | ipxd user ...`");
}
Ok(pw)
};
@@ -138,7 +138,7 @@ pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd, mut stdin: impl std::
UserCmd::List => {
let users = ctx.db.users().await?;
if users.is_empty() {
println!("no accounts yet: ipx user add <name>");
println!("no accounts yet: ipxd user add <name>");
}
for u in users {
let added = u

View File

@@ -219,7 +219,7 @@ pub struct Feed {
/// Preferred over `password`: name of an env var holding the password.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_env: Option<String>,
/// Put in the Directory by an admin (`ipx add --list`): listed with no subscribers, and kept
/// Put in the Directory by an admin (`ipxd add --list`): listed with no subscribers, and kept
/// in the catalogue when the last one leaves, where anyone else's feed is dropped.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub listed: bool,

View File

@@ -48,7 +48,7 @@ pub struct Ctx {
/// Held from taking a copy of the config to storing it (`edit_cfg`). Each change stores the
/// whole catalogue from its copy, and two at once stored over each other: a feed added while
/// another add spent a minute fetching was gone once that one stored (#153).
// ponytail: this process only. `ipx add`, `rm` and `import` write from a process of their
// ponytail: this process only. `ipxd add`, `rm` and `import` write from a process of their
// own, and a change in flight here can still store over theirs; take the copy from the
// database rather than memory if that ever happens.
pub cfg_write: tokio::sync::Mutex<()>,

View File

@@ -24,7 +24,7 @@ pub(crate) async fn reload(ctx: &Ctx) -> Result<()> {
Ok(())
}
/// The counts `ipx status` prints, and /api/status serves. A running daemon's socket answers with
/// The counts `ipxd status` prints, and /api/status serves. A running daemon's socket answers with
/// this directly rather than through the job queue.
pub(crate) async fn status(ctx: &Ctx) -> Event {
match tracing::Instrument::instrument(ctx.db.counts(), db::step("counts")).await {
@@ -66,7 +66,7 @@ pub(crate) async fn daemon(
let (password, told) = auth::first_password(dir, std::env::var("IPX_ADMIN_PASSWORD").ok())?;
ctx.db.create_user("admin", Some(&crate::auth::hash_password(&password)?), /* admin */ true).await?;
tracing::warn!(
"no accounts yet: created 'admin' {told}. Change it with `echo -n <password> | ipx user passwd admin`"
"no accounts yet: created 'admin' {told}. Change it with `echo -n <password> | ipxd user passwd admin`"
);
}
@@ -342,7 +342,7 @@ pub(crate) async fn shutdown() {
/// How long the daemon may sleep before a feed is due: until the earliest one, at least 30 s and
/// at most 10 minutes. It ticked every minute and ran a scan pass each time, 80% of them finding
/// nothing due (#114). The floor keeps a feed that never gets a check time from spinning it; the
/// ceiling picks up within ten minutes what no command announces, such as `ipx add` or a
/// ceiling picks up within ten minutes what no command announces, such as `ipxd add` or a
/// shorter schedule. A command, a refresh or a feed added on the page, wakes it at once anyway.
pub(crate) async fn until_next_scan(ctx: &Ctx) -> std::time::Duration {
let next = async {
@@ -398,7 +398,7 @@ mod tests {
let held = with("kept-in-memory");
let ctx = test_ctx(held.clone()).await;
ctx.db.store_config(&held).await.unwrap();
// `ipx add` and `ipx rm`, from a process of their own (#120).
// `ipxd add` and `ipxd rm`, from a process of their own (#120).
ctx.db.store_config(&with("added-by-the-cli")).await.unwrap();
reload(&ctx).await.unwrap();
let urls: Vec<String> = ctx.cfg().feeds.values().map(|f| f.url.clone()).collect();

View File

@@ -43,7 +43,7 @@ impl Drop for Db {
///
/// Not SeaORM's schema sync, which is experimental and, despite its docs, drops a unique index
/// the entities do not describe: it dropped users_name_lower on every open, so every `ipx`
/// command took a write lock, and the healthcheck's `ipx status` timed out behind a busy daemon.
/// command took a write lock, and the healthcheck's `ipxd status` timed out behind a busy daemon.
/// On Postgres it would have failed outright, dropping that index as a constraint.
///
/// ponytail: tables only, no columns. A column added to an existing table needs its own ALTER
@@ -98,7 +98,7 @@ async fn create_missing(orm: &DatabaseConnection) -> Result<()> {
}
// A column added to a table that already exists, which CREATE TABLE IF NOT EXISTS leaves
// as it was. Looked for first, and added only when it is missing: this runs on every open,
// the healthcheck's `ipx status` included, and an ALTER takes a lock that waits behind a
// the healthcheck's `ipxd status` included, and an ALTER takes a lock that waits behind a
// busy daemon, which is what made the healthcheck time out once before.
if orm.execute_unprepared("SELECT description FROM feeds LIMIT 0").await.is_err() {
// A feed is read whole only when it has changed since the last read, so each one's
@@ -272,7 +272,7 @@ async fn connect(location: &str, keep_open: bool) -> Result<DatabaseConnection>
// The daemon keeps every connection open (#241): a Postgres connection takes 80-330 ms to
// open, its password exchange most of it, and a page load asked for more than were idle, so
// the feed list and item list waited on new ones, 300-900 ms where their queries took 5-30.
// Not a command such as `ipx status`, nor a test, which would hold twenty for one question.
// Not a command such as `ipxd status`, nor a test, which would hold twenty for one question.
if keep_open && is_postgres(location) {
opts.min_connections(20);
}
@@ -408,7 +408,7 @@ pub struct Managed {
pub group_id: String,
}
/// What `ipx list` shows next to each configured feed.
/// What `ipxd list` shows next to each configured feed.
#[derive(Debug, Default)]
pub struct FeedSummary {
pub title: Option<String>,
@@ -438,7 +438,7 @@ impl Db {
}
let orm = connect(location, keep_open).await?;
if !is_postgres(location) {
// WAL, so the healthcheck's `ipx status` reads while the daemon writes. It is a
// WAL, so the healthcheck's `ipxd status` reads while the daemon writes. It is a
// setting of the file, kept once made, and making it takes a lock that cannot wait out
// a busy daemon, so it is made only when the file is not already in WAL.
let mode =

View File

@@ -212,7 +212,7 @@ pub enum Command {
enclosure: i64,
},
Status,
/// Read the catalogue and server settings again from the database, which `ipx add`, `rm`
/// Read the catalogue and server settings again from the database, which `ipxd add`, `rm`
/// and `import` change in a process of their own. A daemon keeps them in memory, and with
/// its old copy wrote a removed feed back, and dropped an added one, at its next change of
/// its own (#120). Answered, as status is, with the counts after it.
@@ -329,7 +329,7 @@ fn log_event(e: &Event, wire: bool) {
)
};
}
// `ipx status`'s answer: a reply on the socket rather than work done, so it
// `ipxd status`'s answer: a reply on the socket rather than work done, so it
// stays with the rest of the conversation, and the Scans tab stays about scans.
if matches!(e, Event::Status { .. }) {
return line!(info, "ipx::io", format!("<- {json}"));
@@ -356,7 +356,7 @@ pub async fn daemon_is_live(path: &Path) -> bool {
/// time, and a healthcheck left waiting behind a scan or a long download timed out and called a
/// busy daemon dead; a reload left waiting would let the web UI write the old catalogue back in
/// the meantime. The answer goes to the client that asked and no one else: broadcast, it ended
/// any `ipx fetch` that was watching a scan, since `status` is a terminal event.
/// any `ipxd fetch` that was watching a scan, since `status` is a terminal event.
/// A future, since reading the counts is a database query.
pub type StatusFn = Arc<dyn Fn(Command) -> Pin<Box<dyn Future<Output = Event> + Send>> + Send + Sync>;

130
crates/ipx/src/lib.rs Normal file
View File

@@ -0,0 +1,130 @@
//! ipx, the daemon and its command line. The code is in the modules below; `main` starts it.
//! A library under the binary `ipxd` (src/main.rs) so that its modules, and so its log targets
//! (`ipx::scan`, `ipx::http`), the filters (`ipx=info`) and the dashboard's queries on them, kept
//! their names when the command was renamed (#437).
mod access;
mod art;
mod auth;
mod catalogue;
mod cli;
mod config;
mod ctx;
mod daemon;
mod db;
mod deadair;
mod directory;
mod download;
mod entity;
// The crate root's import, so `crate::feed` reaches it from every module as before.
use ipx_feed as feed;
mod ipc;
mod lists;
mod logbuf;
mod logging;
mod metrics;
mod push;
mod retention;
mod scan;
mod signals;
#[cfg(test)]
mod test_util;
mod torrent;
mod web;
use crate::cli::{Cli, Command, UserCmd};
use crate::ctx::Ctx;
use crate::ipc::Command as Cmd;
use anyhow::Result;
use clap::Parser;
use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::Instrument;
#[tokio::main]
pub async fn main() -> Result<()> {
let cli = Cli::parse();
// The daemon only: a trace for every `ipxd status` a script runs would bury the ones worth
// looking at.
let otel = match cli.command {
Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?,
_ => None,
};
logging::init(otel.as_ref());
// For the spans the page reports of itself (#376), which ipx builds with the page's own ids.
if let Some(p) = &otel {
opentelemetry::global::set_tracer_provider(p.clone());
}
let config_path = cli.config.clone().unwrap_or_else(config::config_path);
let cfg = config::Config::load(&config_path)?;
// The daemon holds its connections open; a one-off command opens what it uses.
let keep_open = matches!(cli.command, Command::Daemon { .. });
// The daemon's start-up is a trace (#374), from opening the database to serving; daemon()
// carries it on.
let start = if keep_open { tracing::info_span!("start") } else { tracing::Span::none() };
let db = async { db::Db::open(&db::location(), keep_open).instrument(db::step("open_database")).await }
.instrument(start.clone())
.await?;
// A daemon owns the state; don't have two processes downloading the same thing.
let wire_cmd = match &cli.command {
Command::Fetch { feed, force } => Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] }),
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
Command::Status => Some(Cmd::Status),
Command::List
| Command::Daemon { .. }
| Command::User { .. }
| Command::Add { .. }
| Command::Rm { .. }
| Command::Import { .. }
| Command::Export { .. } => None,
};
if let Some(cmd) = &wire_cmd
&& !cli.local
&& ipc::daemon_is_live(&cfg.general.socket).await
{
return ipc::proxy(&cfg.general.socket, cmd).await;
}
let socket = cfg.general.socket.clone();
let cfg =
async { catalogue::assemble_config(&db, cfg, &config_path).instrument(db::step("assemble_config")).await }
.instrument(start.clone())
.await?;
let is_daemon = matches!(cli.command, Command::Daemon { .. });
// These write the catalogue from a process of their own, which a running daemon, holding its
// own copy, would write over at its next change (#120).
let changes_catalogue = matches!(
cli.command,
Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } }
);
let (events, _) = broadcast::channel(1024);
let ctx = Arc::new(Ctx::new(cfg, db, config_path.clone(), is_daemon, &events)?);
let result = match cli.command {
Command::List => catalogue::list(&ctx).await,
Command::Daemon { web } => daemon::daemon(ctx, config_path, web, events, start).await,
Command::Add { url, folder, keywords, category, list } => {
catalogue::add(&ctx, &url, folder, keywords, category, list).await
}
Command::Rm { feed } => catalogue::rm(&ctx, &feed).await,
Command::User { cmd } => cli::user_cmd(&ctx, cmd, std::io::stdin()).await,
Command::Import { file } => catalogue::import(&ctx, &file).await,
Command::Export { file } => catalogue::export(&ctx, &file).await,
_ => cli::run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
};
if result.is_ok()
&& changes_catalogue
&& ipc::daemon_is_live(&socket).await
&& let Err(e) = ipc::reload(&socket).await
{
eprintln!("the running daemon did not take the change ({e:#}); restart it, or it may undo it");
}
// The batch exporter holds the last few seconds of spans; without this they are lost.
if let Some(p) = otel {
let _ = p.shutdown();
}
result
}

View File

@@ -1,127 +1,5 @@
//! ipx, the daemon and its command line. The code is in the modules below; this only starts it.
//! `ipxd`, the daemon and its command line; everything is in the library (lib.rs).
mod access;
mod art;
mod auth;
mod catalogue;
mod cli;
mod config;
mod ctx;
mod daemon;
mod db;
mod deadair;
mod directory;
mod download;
mod entity;
// The crate root's import, so `crate::feed` reaches it from every module as before.
use ipx_feed as feed;
mod ipc;
mod lists;
mod logbuf;
mod logging;
mod metrics;
mod push;
mod retention;
mod scan;
mod signals;
#[cfg(test)]
mod test_util;
mod torrent;
mod web;
use crate::cli::{Cli, Command, UserCmd};
use crate::ctx::Ctx;
use crate::ipc::Command as Cmd;
use anyhow::Result;
use clap::Parser;
use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::Instrument;
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// The daemon only: a trace for every `ipx status` a script runs would bury the ones worth
// looking at.
let otel = match cli.command {
Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?,
_ => None,
};
logging::init(otel.as_ref());
// For the spans the page reports of itself (#376), which ipx builds with the page's own ids.
if let Some(p) = &otel {
opentelemetry::global::set_tracer_provider(p.clone());
}
let config_path = cli.config.clone().unwrap_or_else(config::config_path);
let cfg = config::Config::load(&config_path)?;
// The daemon holds its connections open; a one-off command opens what it uses.
let keep_open = matches!(cli.command, Command::Daemon { .. });
// The daemon's start-up is a trace (#374), from opening the database to serving; daemon()
// carries it on.
let start = if keep_open { tracing::info_span!("start") } else { tracing::Span::none() };
let db = async { db::Db::open(&db::location(), keep_open).instrument(db::step("open_database")).await }
.instrument(start.clone())
.await?;
// A daemon owns the state; don't have two processes downloading the same thing.
let wire_cmd = match &cli.command {
Command::Fetch { feed, force } => Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] }),
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
Command::Status => Some(Cmd::Status),
Command::List
| Command::Daemon { .. }
| Command::User { .. }
| Command::Add { .. }
| Command::Rm { .. }
| Command::Import { .. }
| Command::Export { .. } => None,
};
if let Some(cmd) = &wire_cmd
&& !cli.local
&& ipc::daemon_is_live(&cfg.general.socket).await
{
return ipc::proxy(&cfg.general.socket, cmd).await;
}
let socket = cfg.general.socket.clone();
let cfg =
async { catalogue::assemble_config(&db, cfg, &config_path).instrument(db::step("assemble_config")).await }
.instrument(start.clone())
.await?;
let is_daemon = matches!(cli.command, Command::Daemon { .. });
// These write the catalogue from a process of their own, which a running daemon, holding its
// own copy, would write over at its next change (#120).
let changes_catalogue = matches!(
cli.command,
Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } }
);
let (events, _) = broadcast::channel(1024);
let ctx = Arc::new(Ctx::new(cfg, db, config_path.clone(), is_daemon, &events)?);
let result = match cli.command {
Command::List => catalogue::list(&ctx).await,
Command::Daemon { web } => daemon::daemon(ctx, config_path, web, events, start).await,
Command::Add { url, folder, keywords, category, list } => {
catalogue::add(&ctx, &url, folder, keywords, category, list).await
}
Command::Rm { feed } => catalogue::rm(&ctx, &feed).await,
Command::User { cmd } => cli::user_cmd(&ctx, cmd, std::io::stdin()).await,
Command::Import { file } => catalogue::import(&ctx, &file).await,
Command::Export { file } => catalogue::export(&ctx, &file).await,
_ => cli::run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
};
if result.is_ok()
&& changes_catalogue
&& ipc::daemon_is_live(&socket).await
&& let Err(e) = ipc::reload(&socket).await
{
eprintln!("the running daemon did not take the change ({e:#}); restart it, or it may undo it");
}
// The batch exporter holds the last few seconds of spans; without this they are lost.
if let Some(p) = otel {
let _ = p.shutdown();
}
result
fn main() -> anyhow::Result<()> {
ipx::main()
}

View File

@@ -8,7 +8,7 @@ use std::path::PathBuf;
use std::sync::Arc;
pub(crate) fn feed() -> config::Feed {
// Whatever `ipx add` would write, which is the shape every code path sees.
// Whatever `ipxd add` would write, which is the shape every code path sees.
let mut cfg = config::Config::default();
add_one_cfg(&mut cfg, "http://x/f.xml", None, vec![])

View File

@@ -671,7 +671,7 @@ fn require_admin(user: &crate::db::User) -> Result<(), ApiError> {
}
/// Demoting or removing this account would leave nobody able to manage anyone, and the only
/// way back would be `ipx user` on the box.
/// way back would be `ipxd user` on the box.
fn last_admin(users: &[crate::db::User], id: i64) -> bool {
let admins: Vec<i64> = users.iter().filter(|u| u.is_admin).map(|u| u.id).collect();
admins == [id]
@@ -798,7 +798,7 @@ async fn add_user(
if state.ctx.db.user_by_name(&name).await?.is_some() {
return Err(ApiError::bad_request(format!("{name} already exists")));
}
// No password is someone the proxy signs in, as with `ipx user add --no-password`.
// No password is someone the proxy signs in, as with `ipxd user add --no-password`.
let hash = if body.password.is_empty() {
None
} else {
@@ -2737,7 +2737,7 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
crate::metrics::STATS.art_hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return art_response(kind, body);
}
// Only what a feed here or the directory names: anything else would make ipx fetch any
// Only what a feed here or the directory names: anything else would make ipxd fetch any
// address it is given. The Directory shows podcasts nobody here subscribes to (#273).
if !crate::directory::listed_art(&q.u)
&& !crate::art::named_in_text(&q.u)
@@ -3008,7 +3008,7 @@ async fn import_opml(
Ok(Json(serde_json::json!({ "added": added, "already": already })))
}
/// The numbers `ipx status` prints, and the version, for a dashboard such as Homepage's
/// The numbers `ipxd status` prints, and the version, for a dashboard such as Homepage's
/// customapi widget. Behind sign-in like the rest of /api; Homepage sends the shared token.
async fn status(State(state): State<WebState>) -> Response {
match crate::daemon::status(&state.ctx).await {

View File

@@ -93,7 +93,7 @@ url = "http://127.0.0.1:{feed_port}/feed.xml"
}
fn ipx(&self, args: &[&str]) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_ipx"));
let mut c = Command::new(env!("CARGO_BIN_EXE_ipxd"));
c.args(args)
.env("IPX_CONFIG", self.root.join("config.toml"))
.env("IPX_DATA_DIR", self.root.join("data"))

14
crates/tui/CHANGELOG.md Normal file
View File

@@ -0,0 +1,14 @@
# Changelog: ipx-tui
The reader in a terminal (README.md). ipx's changelog is [crates/ipx/CHANGELOG.md](../ipx/CHANGELOG.md).
Its version is in `crates/tui/Cargo.toml`.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### 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).

21
crates/tui/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "ipx-tui"
version = "0.1.0-dev"
edition.workspace = true
publish = false
[lints]
workspace = true
[[bin]]
name = "ipx"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap = { workspace = true, features = ["env"] }
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] }
reqwest = { workspace = true, features = ["blocking"] }
serde.workspace = true
serde_json.workspace = true

27
crates/tui/README.md Normal file
View File

@@ -0,0 +1,27 @@
# ipx-tui
The `ipx` command. The server, which had that name, is `ipxd`.
iPX's reader in a terminal: your feeds on the left, a feed's items above, the item's text below.
It talks to iPX's API as the page does, as you, with an API token made in the page's Settings.
```sh
cargo build --release -p ipx-tui
IPX_URL=http://192.168.1.130:8099 IPX_API_TOKEN=... target/release/ipx
```
`--url` and `--token` do the same as the two variables.
| key | does |
|---|---|
| `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 |
| `h`, `Esc` | back a pane; `Esc` on the feeds quits |
| `Space`, `PgUp` `PgDn` | page through the item's text |
| `m` | mark the item read or unread |
| `f` | flag or unflag it |
| `u` | unread items only, or every item (unread only to start) |
| `r` | reload |
| `q` | quit |
It shows a feed's newest 200 items.

451
crates/tui/src/main.rs Normal file
View File

@@ -0,0 +1,451 @@
//! 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::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,
}
/// 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,
}
#[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)
}
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,
/// Your feeds, after All Subscriptions, which is index 0 of `feed_list`.
feeds: Vec<Feed>,
feed_list: ListState,
entries: Vec<Entry>,
total: i64,
entry_list: ListState,
pane: Pane,
scroll: u16,
unread_only: bool,
status: String,
}
impl App {
fn feed_id(&self) -> Option<&str> {
let i = self.feed_list.selected()?;
self.feeds.get(i.checked_sub(1)?).map(|f| f.id.as_str())
}
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.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;
}
}
/// 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;
if self.entry().is_some_and(|e| !e.read) {
self.flag("read", |_| true);
}
}
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;
}
}
/// 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::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('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.feeds.iter().map(|f| {
let name = f.title.as_deref().unwrap_or(&f.url);
if f.unread > 0 { format!("{name} ({})", f.unread) } else { name.to_string() }
}));
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());
}
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 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,
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.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 &amp; two,\n <b>bold</b>.</p><p>Next&nbsp;line<br>broken &#8212; &#x2019;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");
}
}