diff --git a/src/commands.rs b/src/commands.rs index bb929c6..c456921 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -50,7 +50,7 @@ pub fn add_channel(ctx: &TrsEnv, args: &AddChannelArgs) -> Result Result, TrsError> { diff --git a/src/main.rs b/src/main.rs index 127f5dc..1fd3ddf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,7 @@ pub mod ui; #[tokio::main] async fn main() -> Result<()> { let args = argh::from_env::(); - let db_name = "test3"; + let db_name = "test4"; match args.sub_command { TrsSubCommand::AddChannel(args) => { let mut ctx = TrsEnv::new(db_name)?; diff --git a/src/persistence.rs b/src/persistence.rs index 7e897bc..f8f6602 100644 --- a/src/persistence.rs +++ b/src/persistence.rs @@ -12,6 +12,7 @@ const SCHEMA_CHANNELS: &'static str = "CREATE TABLE IF NOT EXISTS Channels ( \ id INTEGER PRIMARY KEY, \ name TEXT NOT NULL, \ link TEXT NOT NULL UNIQUE, \ + feed_link TEXT NOT NULL UNIQUE, \ description TEXT, \ last_update INTEGER\ )"; @@ -28,14 +29,14 @@ const SCHEMA_ARTICLES: &'static str = "CREATE TABLE IF NOT EXISTS Articles ( \ FOREIGN KEY(channel_id) REFERENCES Channels(id) ON DELETE CASCADE \ )"; -const ADD_CHANNEL: &'static str = "INSERT INTO Channels (name, link, description, last_update) \ - VALUES (?1, ?2, ?3, ?4)\ - ON CONFLICT(link) DO UPDATE SET name=?1, description=?3, last_update=?4"; +const ADD_CHANNEL: &'static str = "INSERT INTO Channels (name, link, feed_link, description, last_update) \ + VALUES (?1, ?2, ?3, ?4, ?5)\ + ON CONFLICT(link) DO UPDATE SET name=?1, description=?4, last_update=?5"; const REMOVE_CHANNEL: &'static str = "DELETE FROM Channels WHERE id = ?1"; const LIST_CHANNELS: &'static str = - "SELECT id, name, link, description, last_update FROM Channels LIMIT ?1"; + "SELECT id, name, link, feed_link, description, last_update FROM Channels order by last_update DESC LIMIT ?1"; const GET_CHANNEL: &'static str = - "SELECT id, name, link, description, last_update FROM Channels WHERE link = ?1"; + "SELECT id, name, link, feed_link, description, last_update FROM Channels WHERE link = ?1"; const ADD_ARTICLE: &'static str = "INSERT INTO Articles (channel_id, title, description, link, pub_date, last_update, unread) \ @@ -49,7 +50,7 @@ const GET_ARTICLE: &'static str = "SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles WHERE link = ?1"; const LIST_ARTICLES: &'static str = - "SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles"; + "SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles order by last_update DESC"; const MARK_ARTICLE_READ: &'static str = "UPDATE Articles SET unread = FALSE WHERE id = ?1"; @@ -63,6 +64,7 @@ pub struct RssChannelD { pub id: i64, pub title: String, pub link: String, + pub feed_link: String, pub description: String, pub last_update: OffsetDateTime, pub articles: Vec, @@ -126,13 +128,14 @@ impl Db { Ok(channel) } - pub fn add_channel(&self, channel: &RssChannel) -> Result { + pub fn add_channel(&self, feed_link: impl AsRef, channel: &RssChannel) -> Result { self.connection .execute( ADD_CHANNEL, ( &channel.title, &channel.link, + feed_link.as_ref(), &channel.description, OffsetDateTime::now_utc().unix_timestamp(), ), @@ -256,7 +259,8 @@ impl Db { row.get(1)?, row.get(2)?, row.get(3)?, - Db::read_datetime(4, &row)?, + row.get(4)?, + Db::read_datetime(5, &row)?, Vec::new(), )) } @@ -295,6 +299,7 @@ impl RssChannelD { id: i64, title: String, link: String, + feed_link: String, description: String, last_update: OffsetDateTime, articles: Vec, @@ -303,6 +308,7 @@ impl RssChannelD { id, title, link, + feed_link, description, last_update, articles, diff --git a/src/ui.rs b/src/ui.rs index 2cda2b5..c6ecf92 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,9 +1,9 @@ pub mod actions; pub mod articles; +pub mod backend; pub mod channels; pub mod controls; pub mod debug; -pub mod executor; pub mod title; use std::{ @@ -22,7 +22,6 @@ use channels::ChannelsWidget; use controls::ControlsWidget; use crossterm::event::{self, KeyEventKind}; use debug::DebugWidget; -use executor::UiCommandExecutor; use futures::{FutureExt, StreamExt}; use ratatui::{ prelude::*, @@ -66,6 +65,7 @@ pub enum UiAction { ShowAddChannelUi, RemoveChannel, ToggleReadStatus, + SyncChannel, Exit, } @@ -86,11 +86,24 @@ pub enum UiCommandDispatchActions { ListChannels(args::ListChannelArgs), } + +/// APP +/// - Listen Event +/// - Publish UiCommandDispatchActions +/// +/// BACKEND +/// - Listen UiCommandDispatchActions +/// - Publish BackendEvent +/// +/// EVENT LOOP +/// - Listen BackendEvent +/// - Listen crossterm::event::Event +/// - Publish Event pub async fn ui(args: &UiArgs, db_name: &str) -> Result<()> { - let (app_dispatch, app_recv) = channel(); - let (executor_dispatch, executor_recv) = tokio::sync::mpsc::unbounded_channel(); - let event_recv = start_event_loop(executor_recv); - let mut terminal = ratatui::init(); + let (ui_action_publisher, ui_action_receiver) = channel(); + let (backend_event_publisher, backend_event_receiver) = tokio::sync::mpsc::unbounded_channel(); + let event_receiver = start_event_loop(backend_event_receiver); + let mut app_state = AppState { channels: Vec::new(), exit: false, @@ -102,15 +115,11 @@ pub async fn ui(args: &UiArgs, db_name: &str) -> Result<()> { last_action: None, show_add_channel_ui: false, add_channel: String::new(), - dispatcher: app_dispatch, - receiver: event_recv, + dispatcher: ui_action_publisher, + receiver: event_receiver, }; - let db_name = db_name.to_string(); - std::thread::spawn(move || { - let mut executor = UiCommandExecutor::new(app_recv, executor_dispatch); - executor.run(db_name); - }); + start_backend(db_name, ui_action_receiver, backend_event_publisher); app_state .dispatcher @@ -119,6 +128,7 @@ pub async fn ui(args: &UiArgs, db_name: &str) -> Result<()> { )) .map_err(|e| TrsError::Error(format!("Unable to send initial app: {}", e)))?; + let mut terminal = ratatui::init(); loop { draw(&app_state, &mut terminal)?; handle_events(&mut app_state).await?; @@ -132,6 +142,13 @@ pub async fn ui(args: &UiArgs, db_name: &str) -> Result<()> { Ok(()) } +fn start_backend(db_name: &str, app_recv: std::sync::mpsc::Receiver, executor_dispatch: tokio::sync::mpsc::UnboundedSender) { + let db_name = db_name.to_string(); + std::thread::spawn(move || { + backend::start(db_name, app_recv, executor_dispatch); + }); +} + fn start_event_loop( mut executor_recv: UnboundedReceiver, ) -> UnboundedReceiver { diff --git a/src/ui/actions.rs b/src/ui/actions.rs index 4e3b19d..d46824c 100644 --- a/src/ui/actions.rs +++ b/src/ui/actions.rs @@ -82,6 +82,18 @@ pub fn handle_action( } } } + UiAction::SyncChannel => { + let channel = get_highlighted_channel(app_state); + if let Some(channel) = channel { + let sync_channel_args = args::AddChannelArgs { + link: channel.feed_link.clone(), + }; + app_state + .dispatcher + .send(UiCommandDispatchActions::AddChannel(sync_channel_args)) + .unwrap(); + } + } }; Ok(()) } diff --git a/src/ui/backend.rs b/src/ui/backend.rs new file mode 100644 index 0000000..79a1113 --- /dev/null +++ b/src/ui/backend.rs @@ -0,0 +1,66 @@ +use std::sync::mpsc::Receiver; + +use tokio::sync::mpsc::UnboundedSender; + +use crate::{commands::TrsEnv, ui::BackendEvent}; + +use super::UiCommandDispatchActions; + +// This one will have to run on the same thread as this manages the sqlite connection +pub fn start( + db_name: String, + cmd_recv: Receiver, + backend_dispatch: UnboundedSender, +) -> () { + let ctx = TrsEnv::new(db_name.as_str()).unwrap(); + loop { + let action = cmd_recv.recv(); + let Ok(action) = action else { + break; + }; + + match action { + UiCommandDispatchActions::AddChannel(args) => { + if let Ok(_) = crate::commands::add_channel(&ctx, &args) { + send_new_state_default(&ctx, &backend_dispatch); + }; + } + UiCommandDispatchActions::RemoveChannel(args) => { + if let Ok(_) = crate::commands::remove_channel(&ctx, &args) { + send_new_state_default(&ctx, &backend_dispatch); + } + } + UiCommandDispatchActions::MarkArticleRead(args) => { + if let Ok(_) = crate::commands::mark_read(&ctx, &args) { + send_new_state_default(&ctx, &backend_dispatch); + } + } + UiCommandDispatchActions::ListChannels(args) => { + send_new_state(&ctx, args, &backend_dispatch); + } + } + } +} + +fn send_new_state_default( + ctx: &crate::commands::TrsEnv, + dispatcher: &UnboundedSender, +) { + send_new_state( + ctx, + crate::args::ListChannelArgs { limit: None }, + dispatcher, + ); +} + +fn send_new_state( + ctx: &crate::commands::TrsEnv, + args: crate::args::ListChannelArgs, + dispatcher: &UnboundedSender, +) { + if let Ok(channels) = crate::commands::list_channels(ctx, &args) { + dispatcher + .send(BackendEvent::ReloadState(channels)) + .unwrap_or_default(); + } +} diff --git a/src/ui/controls.rs b/src/ui/controls.rs index 340cbd7..4787773 100644 --- a/src/ui/controls.rs +++ b/src/ui/controls.rs @@ -57,11 +57,12 @@ pub fn parse_ui_action(raw_event: Event) -> UiAction { KeyCode::Char('h') => UiAction::FocusPaneLeft, KeyCode::Char('k') => UiAction::FocusEntryUp, KeyCode::Char('j') => UiAction::FocusEntryDown, - KeyCode::Char('d') => UiAction::ToggleDebug, + KeyCode::Char('n') => UiAction::ToggleDebug, KeyCode::Char('q') => UiAction::Exit, KeyCode::Char('a') => UiAction::ShowAddChannelUi, - KeyCode::Char('r') => UiAction::RemoveChannel, - KeyCode::Char('u') => UiAction::ToggleReadStatus, + KeyCode::Char('d') => UiAction::RemoveChannel, + KeyCode::Char('r') => UiAction::ToggleReadStatus, + KeyCode::Char('s') => UiAction::SyncChannel, KeyCode::Enter => UiAction::OpenArticle, _ => UiAction::None, }; @@ -97,9 +98,11 @@ impl Widget for ControlsWidget { description!(" to switch between channels and articles, "), control!("a"), description!(" add a new RSS channel, "), + control!("s"), + description!(" sync channel, "), + control!("d"), + description!(" delete an RSS channel, "), control!("r"), - description!(" remove an RSS channel, "), - control!("u"), description!(" toggle read state of article, "), control!("q"), description!(" to exit"), diff --git a/src/ui/executor.rs b/src/ui/executor.rs deleted file mode 100644 index d0eaf99..0000000 --- a/src/ui/executor.rs +++ /dev/null @@ -1,72 +0,0 @@ -use std::sync::mpsc::Receiver; - -use tokio::sync::mpsc::UnboundedSender; - -use crate::{commands::TrsEnv, ui::BackendEvent}; - -use super::UiCommandDispatchActions; - -pub struct UiCommandExecutor { - pub app_recv: Receiver, - pub executor_dispatch: UnboundedSender, -} - -impl UiCommandExecutor { - pub fn new( - app_recv: Receiver, - executor_dispatch: UnboundedSender, - ) -> Self { - UiCommandExecutor { - app_recv, - executor_dispatch, - } - } - - // This one will have to run on the same thread as this manages the sqlite connection - pub fn run(&mut self, db_name: String) -> () { - let ctx = TrsEnv::new(db_name.as_str()).unwrap(); - loop { - let action = self.app_recv.recv(); - let Ok(action) = action else { - break; - }; - - match action { - UiCommandDispatchActions::AddChannel(args) => { - if let Ok(_) = crate::commands::add_channel(&ctx, &args) { - self.send_new_state_default(&ctx); - }; - } - UiCommandDispatchActions::RemoveChannel(args) => { - if let Ok(_) = crate::commands::remove_channel(&ctx, &args) { - self.send_new_state_default(&ctx); - } - } - UiCommandDispatchActions::MarkArticleRead(args) => { - if let Ok(_) = crate::commands::mark_read(&ctx, &args) { - self.send_new_state_default(&ctx); - } - } - UiCommandDispatchActions::ListChannels(args) => { - self.send_new_state(&ctx, args); - } - } - } - } - - fn send_new_state_default(&mut self, ctx: &crate::commands::TrsEnv) { - self.send_new_state(ctx, crate::args::ListChannelArgs { limit: None }); - } - - fn send_new_state( - &mut self, - ctx: &crate::commands::TrsEnv, - args: crate::args::ListChannelArgs, - ) { - if let Ok(channels) = crate::commands::list_channels(ctx, &args) { - self.executor_dispatch - .send(BackendEvent::ReloadState(channels)) - .unwrap_or_default(); - } - } -}