diff --git a/README.md b/README.md index 6ac1a9d..625bcc6 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Usage: russ Commands: read Read your feeds import Import feeds from an OPML document + sync Sync all feeds and exit help Print this message or the help of the given subcommand(s) Options: @@ -97,6 +98,42 @@ Options: RSS/Atom network request timeout in seconds [default: 5] -h, --help Print help + +## sync mode + +Run a full refresh of all feeds and exit without launching the TUI. Useful for cron jobs or scripting. + +```console +$ russ sync -h +Sync all feeds and exit + +Usage: russ sync [OPTIONS] + +Options: + -d, --database-path + Override where `russ` stores and reads feeds. By default, the feeds database on Linux this will be at `XDG_DATA_HOME/russ/feeds.db` or `$HOME/.local/share/russ/feeds.db`. On MacOS it will be at `$HOME/Library/Application Support/russ/feeds.db`. On Windows it will be at `{FOLDERID_LocalAppData}/russ/data/feeds.db` + -n, --network-timeout + RSS/Atom network request timeout in seconds [default: 5] + -h, --help + Print help +``` + +Examples: + +```console +# Refresh using the default database and default timeout +$ russ sync + +# Refresh with a custom database path +$ russ sync -d /path/to/feeds.db + +# Increase network timeout to 15 seconds +$ russ sync -n 15 + +# Cron (runs hourly) +$ crontab -e +0 * * * * /usr/local/bin/russ sync -d /home/you/.local/share/russ/feeds.db >> /home/you/russ-sync.log 2>&1 +``` ``` ## import OPML mode diff --git a/src/app.rs b/src/app.rs index 5f4e0ae..2fac0a4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -37,6 +37,7 @@ pub struct App { impl App { delegate_to_locked_inner![ (error_flash_is_empty, bool), + (error_flash, Vec), (feed_ids, Result>), (force_redraw, Result<()>), (http_client, ureq::Agent), @@ -199,6 +200,9 @@ pub struct AppImpl { } impl AppImpl { + pub fn error_flash(&self) -> Vec { + self.error_flash.iter().map(|e| format!("{e:?}")).collect() + } pub fn new( options: crate::ReadOptions, event_tx: std::sync::mpsc::Sender>, diff --git a/src/main.rs b/src/main.rs index 1aa986b..64f580d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,7 @@ fn main() -> Result<()> { match validated_options { ValidatedOptions::Import(options) => crate::opml::import(options), ValidatedOptions::Read(options) => run_reader(options), + ValidatedOptions::Sync(options) => run_sync(options), } } @@ -80,6 +81,15 @@ enum Command { #[arg(short, long, default_value = "5", value_parser = parse_seconds)] network_timeout: time::Duration, }, + /// Sync all feeds and exit (no TUI) + Sync { + /// Override where `russ` stores and reads feeds. + #[arg(short, long)] + database_path: Option, + /// RSS/Atom network request timeout in seconds + #[arg(short, long, default_value = "5", value_parser = parse_seconds)] + network_timeout: time::Duration, + }, } impl Command { @@ -112,6 +122,16 @@ impl Command { network_timeout: *network_timeout, })) } + Command::Sync { + database_path, + network_timeout, + } => { + let database_path = get_database_path(database_path)?; + Ok(ValidatedOptions::Sync(SyncOptions { + database_path, + network_timeout: *network_timeout, + })) + } } } } @@ -126,6 +146,7 @@ fn parse_seconds(s: &str) -> Result { enum ValidatedOptions { Read(ReadOptions), Import(ImportOptions), + Sync(SyncOptions), } #[derive(Clone, Debug)] @@ -143,6 +164,12 @@ struct ImportOptions { network_timeout: time::Duration, } +#[derive(Debug)] +struct SyncOptions { + database_path: PathBuf, + network_timeout: time::Duration, +} + fn get_database_path(database_path: &Option) -> std::io::Result { let database_path = if let Some(database_path) = database_path { database_path.to_owned() @@ -255,6 +282,50 @@ fn run_reader(options: ReadOptions) -> Result<()> { Ok(()) } +fn run_sync(options: SyncOptions) -> Result<()> { + // Reuse the IO architecture without starting the TUI. + let (event_tx, _event_rx) = mpsc::channel(); + let (io_tx, io_rx) = mpsc::channel(); + + // Build ReadOptions with defaults needed by App::new + let read_opts = ReadOptions { + database_path: options.database_path, + tick_rate: 250, + flash_display_duration_seconds: time::Duration::from_secs(4), + network_timeout: options.network_timeout, + }; + + let app = App::new(read_opts.clone(), event_tx.clone(), io_tx.clone())?; + let cloned_app = app.clone(); + + // Spawn IO thread + let io_thread = thread::spawn(move || -> Result<()> { + io::io_loop(cloned_app, io_tx, io_rx, &read_opts) + }); + + // Trigger refresh all feeds + app.refresh_feeds()?; + + // Allow IO to process request(s) before breaking. + // Wait approximately the network timeout, then signal break. + thread::sleep(options.network_timeout + time::Duration::from_millis(200)); + app.break_io_thread()?; + + // Wait for IO thread to finish + io_thread + .join() + .expect("Unable to join IO thread to main thread")?; + + // Print any errors collected during sync to stderr + if !app.error_flash_is_empty() { + for e in app.error_flash() { + eprintln!("{e}"); + } + } + + Ok(()) +} + enum Action { Quit, MoveLeft,