add/remove channel, toggle read status

This commit is contained in:
cool-mist 2025-08-03 20:18:12 +05:30
parent d43996d049
commit 2a82e3bc66
10 changed files with 371 additions and 70 deletions

View File

@ -6,10 +6,18 @@ use crate::{
};
pub struct TrsEnv {
name: String,
db: Db,
http_client: reqwest::blocking::Client,
}
impl Clone for TrsEnv {
fn clone(&self) -> Self {
let name = self.name.clone();
TrsEnv::new(&name).expect("Failed to clone TrsEnv")
}
}
impl TrsEnv {
pub fn new(instance_name: &str) -> Result<Self, TrsError> {
let db = Db::create(instance_name)?;
@ -17,11 +25,15 @@ impl TrsEnv {
.user_agent("cool-mist/trs")
.build()
.map_err(|e| TrsError::ReqwestError(e, "Failed to create HTTP client".to_string()))?;
Ok(TrsEnv { db, http_client })
Ok(TrsEnv {
name: instance_name.to_string(),
db,
http_client,
})
}
}
pub fn add_channel(ctx: &mut TrsEnv, args: &AddChannelArgs) -> Result<RssChannelD, TrsError> {
pub fn add_channel(ctx: &TrsEnv, args: &AddChannelArgs) -> Result<RssChannelD, TrsError> {
let rss = ctx.http_client.get(&args.link).send().map_err(|e| {
TrsError::ReqwestError(
e,
@ -41,18 +53,15 @@ pub fn add_channel(ctx: &mut TrsEnv, args: &AddChannelArgs) -> Result<RssChannel
ctx.db.add_channel(&channel)
}
pub fn list_channels(
ctx: &mut TrsEnv,
args: &ListChannelArgs,
) -> Result<Vec<RssChannelD>, TrsError> {
pub fn list_channels(ctx: &TrsEnv, args: &ListChannelArgs) -> Result<Vec<RssChannelD>, TrsError> {
ctx.db.list_channels(args.limit.unwrap_or(u32::MAX))
}
pub fn remove_channel(ctx: &mut TrsEnv, args: &RemoveChannelArgs) -> Result<(), TrsError> {
pub fn remove_channel(ctx: &TrsEnv, args: &RemoveChannelArgs) -> Result<(), TrsError> {
ctx.db.remove_channel(args.id).map(|_| ())
}
pub fn mark_read(ctx: &mut TrsEnv, args: &args::MarkReadArgs) -> Result<(), TrsError> {
pub fn mark_read(ctx: &TrsEnv, args: &args::MarkReadArgs) -> Result<(), TrsError> {
match args.unread {
true => ctx.db.mark_article_unread(args.id as i64)?,
false => ctx.db.mark_article_read(args.id as i64)?,

View File

@ -10,7 +10,7 @@ pub mod ui;
fn main() -> Result<()> {
let args = argh::from_env::<TrsArgs>();
let mut ctx = TrsEnv::new("test")?;
let mut ctx = TrsEnv::new("test3")?;
match args.sub_command {
TrsSubCommand::AddChannel(args) => {
commands::add_channel(&mut ctx, &args)?;
@ -50,6 +50,6 @@ fn main() -> Result<()> {
Ok(())
}
TrsSubCommand::MarkRead(args) => commands::mark_read(&mut ctx, &args),
TrsSubCommand::Ui(args) => ui::ui(&mut ctx, &args),
TrsSubCommand::Ui(args) => ui::ui(ctx, &args),
}
}

View File

@ -13,7 +13,7 @@ const SCHEMA_CHANNELS: &'static str = "CREATE TABLE IF NOT EXISTS Channels ( \
name TEXT NOT NULL, \
link TEXT NOT NULL UNIQUE, \
description TEXT, \
last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP \
last_update INTEGER\
)";
const SCHEMA_ARTICLES: &'static str = "CREATE TABLE IF NOT EXISTS Articles ( \
@ -22,15 +22,15 @@ const SCHEMA_ARTICLES: &'static str = "CREATE TABLE IF NOT EXISTS Articles ( \
title TEXT NOT NULL, \
description TEXT, \
link TEXT NOT NULL UNIQUE, \
pub_date TIMESTAMP, \
last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \
unread BOOLEAN DEFAULT FALSE, \
pub_date INTEGER, \
last_update INTEGER , \
unread BOOLEAN DEFAULT TRUE, \
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, CURRENT_TIMESTAMP)\
ON CONFLICT(link) DO UPDATE SET name=?1, description=?3, last_update=CURRENT_TIMESTAMP";
VALUES (?1, ?2, ?3, ?4)\
ON CONFLICT(link) DO UPDATE SET name=?1, description=?3, last_update=?4";
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";
@ -38,9 +38,9 @@ const GET_CHANNEL: &'static str =
"SELECT id, name, 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) \
VALUES (?1, ?2, ?3, ?4, ?5, CURRENT_TIMESTAMP) \
ON CONFLICT(link) DO UPDATE SET pub_date=?5, last_update=CURRENT_TIMESTAMP";
"INSERT INTO Articles (channel_id, title, description, link, pub_date, last_update, unread) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, true) \
ON CONFLICT(link) DO UPDATE SET last_update=?6";
const GET_ARTICLES_BY_CHANNEL: &'static str =
"SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles WHERE channel_id = ?1";
@ -130,7 +130,12 @@ impl Db {
self.connection
.execute(
ADD_CHANNEL,
(&channel.title, &channel.link, &channel.description),
(
&channel.title,
&channel.link,
&channel.description,
OffsetDateTime::now_utc().unix_timestamp(),
),
)
.map_err(|e| TrsError::SqlError(e, "Failed to add channel".to_string()))?;
@ -201,7 +206,8 @@ impl Db {
&article.title,
&article.description,
&article.link,
article.date.map(|d| d.to_string()),
article.date.map(|d| d.unix_timestamp()),
OffsetDateTime::now_utc().unix_timestamp(),
),
)
.map_err(|e| TrsError::SqlError(e, "Failed to add article".to_string()))?;
@ -250,7 +256,7 @@ impl Db {
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
Db::read_datetime(4, &row)?,
Vec::new(),
))
}
@ -262,11 +268,26 @@ impl Db {
title: row.get(2)?,
description: row.get(3)?,
link: row.get(4)?,
pub_date: row.get(5).ok(),
last_update: row.get(6).ok(),
pub_date: Db::read_datetime(5, row).ok(),
last_update: Db::read_datetime(6, row).ok(),
unread: row.get(7)?,
})
}
fn read_datetime(
idx: usize,
row: &rusqlite::Row,
) -> std::result::Result<OffsetDateTime, rusqlite::Error> {
row.get::<usize, i64>(idx).map(|ts| {
OffsetDateTime::from_unix_timestamp(ts).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
idx,
rusqlite::types::Type::Integer,
Box::new(e),
)
})
})?
}
}
impl RssChannelD {

View File

@ -3,12 +3,17 @@ pub mod articles;
pub mod channels;
pub mod controls;
pub mod debug;
pub mod executor;
pub mod title;
use std::io::Stdout;
use std::{
io::Stdout,
sync::mpsc::{channel, Receiver, Sender},
thread,
};
use crate::{
args::{ListChannelArgs, UiArgs},
args::{self, ListChannelArgs, UiArgs},
commands::{self, TrsEnv},
error::{Result, TrsError},
persistence::RssChannelD,
@ -18,6 +23,7 @@ use channels::ChannelsWidget;
use controls::ControlsWidget;
use crossterm::event;
use debug::DebugWidget;
use executor::UiCommandExecutor;
use ratatui::{
prelude::*,
widgets::{Block, Borders},
@ -33,6 +39,10 @@ pub struct AppState {
highlighted_channel: Option<usize>,
highlighted_article: Option<usize>,
last_action: Option<UiAction>,
show_add_channel_ui: bool,
add_channel: String,
dispatcher: Sender<UiCommandDispatchActions>,
receiver: Receiver<u64>,
}
#[derive(Clone, Copy, PartialEq)]
@ -52,10 +62,31 @@ pub enum UiAction {
FocusEntryDown,
ToggleDebug,
OpenArticle,
ShowAddChannelUi,
RemoveChannel,
ToggleReadStatus,
Exit,
}
pub fn ui(ctx: &mut TrsEnv, args: &UiArgs) -> Result<()> {
#[derive(Debug, Clone, PartialEq)]
pub enum PopupUiAction {
None,
Submit,
AddChar(char),
Backspace,
Close,
}
#[derive(Debug)]
pub enum UiCommandDispatchActions {
AddChannel(args::AddChannelArgs),
RemoveChannel(args::RemoveChannelArgs),
MarkArticleRead(args::MarkReadArgs),
}
pub fn ui(ctx: TrsEnv, args: &UiArgs) -> Result<()> {
let (tdispatch, rdispatch) = channel();
let (tupdate, rupdate) = channel();
let mut terminal = ratatui::init();
let mut app_state = AppState {
channels: Vec::new(),
@ -66,19 +97,31 @@ pub fn ui(ctx: &mut TrsEnv, args: &UiArgs) -> Result<()> {
highlighted_article: None,
highlighted_channel: None,
last_action: None,
show_add_channel_ui: false,
add_channel: String::new(),
dispatcher: tdispatch,
receiver: rupdate,
};
let channels = commands::list_channels(ctx, &ListChannelArgs { limit: None })?;
let ctx_cloned = ctx.clone();
let executor = UiCommandExecutor::new(rdispatch, tupdate);
let executor_handle = thread::spawn(move || {
executor.run(ctx_cloned);
});
let channels = commands::list_channels(&ctx, &ListChannelArgs { limit: None })?;
app_state.channels = channels;
loop {
draw(&app_state, &mut terminal)?;
handle_events(&mut app_state)?;
handle_events(&mut app_state, &ctx)?;
if app_state.exit {
break;
}
}
drop(app_state);
executor_handle.join().unwrap();
ratatui::restore();
Ok(())
}
@ -93,8 +136,20 @@ fn draw(app_state: &AppState, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
Ok(())
}
fn handle_events(state: &mut AppState) -> Result<()> {
fn handle_events(state: &mut AppState, ctx: &TrsEnv) -> Result<()> {
let recv_action = state.receiver.try_recv();
if let Ok(_) = recv_action {
let channels = commands::list_channels(&ctx, &ListChannelArgs { limit: None })?;
state.channels = channels;
}
let raw_event = event::read().map_err(|e| TrsError::TuiError(e))?;
if state.show_add_channel_ui {
let event = controls::parse_popup_ui_action(raw_event);
return actions::handle_popup_action(state, event);
}
let event = controls::parse_ui_action(raw_event);
state.last_action = Some(event.clone());
actions::handle_action(state, event)

View File

@ -1,6 +1,10 @@
use crate::{error::TrsError, persistence::RssChannelD};
use crate::{
args,
error::TrsError,
persistence::{RssArticleD, RssChannelD},
};
use super::{AppState, FocussedPane, UiAction};
use super::{AppState, FocussedPane, PopupUiAction, UiAction, UiCommandDispatchActions};
pub fn handle_action(
app_state: &mut AppState,
@ -19,21 +23,100 @@ pub fn handle_action(
UiAction::OpenArticle => {
if let Some(channel_idx) = app_state.highlighted_channel {
if let Some(article_idx) = app_state.highlighted_article {
if let Some(channel) = app_state.channels.get(channel_idx) {
if let Some(article) = channel.articles.get(article_idx) {
let open_res = open::that(&article.link);
if let Err(e) = open_res {
eprintln!("Failed to open article: {}", e);
}
if let Some(channel) = app_state.channels.get_mut(channel_idx) {
if let Some(article) = channel.articles.get_mut(article_idx) {
article.unread = false;
app_state
.dispatcher
.send(UiCommandDispatchActions::MarkArticleRead(
args::MarkReadArgs {
id: article.id as u32,
unread: false,
},
))
.unwrap();
_ = open::that(&article.link);
}
}
}
}
}
UiAction::ShowAddChannelUi => {
app_state.show_add_channel_ui = true;
}
UiAction::RemoveChannel => {
let hi_channel = get_highlighted_channel(app_state);
let Some(channel) = hi_channel else {
return Ok(());
};
let remove_channel_args = args::RemoveChannelArgs {
id: channel.id as u32,
};
app_state
.dispatcher
.send(UiCommandDispatchActions::RemoveChannel(remove_channel_args))
.unwrap();
}
UiAction::ToggleReadStatus => {
let article = get_highlighted_article_mut(app_state);
let mut article_id = None;
let mut unread = None;
if let Some(article) = article {
article.unread = !article.unread;
article_id = Some(article.id);
unread = Some(article.unread);
}
if let Some(article_id) = article_id {
if let Some(unread) = unread {
app_state
.dispatcher
.send(UiCommandDispatchActions::MarkArticleRead(
args::MarkReadArgs {
id: article_id as u32,
unread,
},
))
.unwrap();
}
}
}
};
Ok(())
}
pub fn handle_popup_action(
state: &mut AppState,
event: PopupUiAction,
) -> std::result::Result<(), TrsError> {
match event {
PopupUiAction::None => {}
PopupUiAction::Submit => {
let add_channel_args = args::AddChannelArgs {
link: state.add_channel.clone(),
};
state
.dispatcher
.send(UiCommandDispatchActions::AddChannel(add_channel_args))
.unwrap();
state.show_add_channel_ui = false;
}
PopupUiAction::AddChar(c) => {
state.add_channel.push(c);
}
PopupUiAction::Backspace => {
state.add_channel.pop();
}
PopupUiAction::Close => {
state.show_add_channel_ui = false;
}
};
Ok(())
}
fn saturating_add(num: usize, to_add: usize, max: usize) -> usize {
if num + to_add > max {
max
@ -60,6 +143,18 @@ fn get_highlighted_channel<'a>(app_state: &'a AppState) -> Option<&'a RssChannel
.and_then(|idx| app_state.channels.get(idx).or_else(|| None))
}
fn get_highlighted_channel_mut<'a>(app_state: &'a mut AppState) -> Option<&'a mut RssChannelD> {
app_state
.highlighted_channel
.and_then(|idx| app_state.channels.get_mut(idx).or_else(|| None))
}
fn get_highlighted_article_mut<'a>(app_state: &'a mut AppState) -> Option<&'a mut RssArticleD> {
let hi_article = app_state.highlighted_article?;
let channel = get_highlighted_channel_mut(app_state)?;
channel.articles.get_mut(hi_article)
}
fn focus_entry_up(app_state: &mut AppState) {
match app_state.focussed {
FocussedPane::Channels => decrement_highlighted_channel_idx(app_state),
@ -77,12 +172,13 @@ fn focus_entry_down(app_state: &mut AppState) {
}
fn increment_highlighted_channel_idx(app_state: &mut AppState) -> Option<bool> {
let max_channel_idx = app_state.channels.len().saturating_sub(1);
if max_channel_idx == 0 {
let channels_len = app_state.channels.len();
if channels_len == 0 {
app_state.highlighted_channel = None;
return Some(false);
}
let max_channel_idx = channels_len.saturating_sub(1);
app_state.highlighted_channel = app_state
.highlighted_channel
.map(|idx| saturating_add(idx, 1, max_channel_idx))

View File

@ -58,22 +58,20 @@ impl<'a> Widget for ArticlesWidget<'a> {
.filter(|h| *h == idx)
.is_some();
let mut lines = Vec::new();
let id = Span::styled(
format!("{:>3}. ", idx + 1),
get_article_id_style(current_highlighted),
);
let id = if article.unread {
Span::styled(" * ", Style::default().fg(Color::Red))
} else {
Span::styled(
format!("{:>3}. ", idx + 1),
get_article_id_style(current_highlighted),
)
};
let title = Span::styled(
article.title.clone(),
get_article_title_style(current_highlighted),
);
let unread = if article.unread {
Span::styled(" (unread)", Style::default().fg(Color::Red))
} else {
Span::raw("")
};
lines.push(Line::from(vec![id, title, unread]));
lines.push(Line::from(vec![id, title]));
let para = Paragraph::new(lines)
.block(Block::default())
.style(get_channel_list_item_block_style(current_highlighted))

View File

@ -5,6 +5,7 @@ use ratatui::{
text::{Line, Span},
widgets::{Block, Paragraph, Widget},
};
use time::format_description;
use super::AppState;
@ -12,6 +13,10 @@ pub struct ChannelsWidget<'a> {
state: &'a AppState,
}
pub struct AddChannelWidget<'a> {
state: &'a str,
}
impl<'a> ChannelsWidget<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
@ -44,36 +49,50 @@ impl<'a> Widget for ChannelsWidget<'a> {
.highlighted_channel
.filter(|h| *h == idx)
.is_some();
let mut lines = Vec::new();
let mut spans = Vec::new();
let id = Span::styled(
format!("{:>3}. ", idx + 1),
get_channel_id_style(current_highlighted),
);
spans.push(id);
let title = Span::styled(
channel.title.clone(),
get_channel_title_style(current_highlighted),
);
spans.push(title);
lines.push(Line::from(vec![id, title]));
let format = format_description::parse("[year]-[month]-[day]").unwrap();
if let Some(article) = channel.articles.first() {
let pub_date_text = match article.pub_date {
Some(date) => format!("Last update: {}", date),
Some(date) => format!(" {}", date.format(&format).unwrap()),
None => "".to_string(),
};
let pub_date = Span::styled(
pub_date_text,
get_channel_pub_date_style(current_highlighted),
);
lines.push(Line::from(vec![pub_date]));
spans.push(pub_date);
}
let para = Paragraph::new(lines)
let para = Paragraph::new(Line::from(spans))
.block(Block::default())
.style(get_channel_list_item_block_style(current_highlighted))
.alignment(Alignment::Left);
para.render(row, buf);
}
if self.state.show_add_channel_ui {
let add_channel_area = Layout::default()
.direction(Direction::Vertical)
.constraints(Constraint::from_percentages(vec![90, 10]))
.split(area)
.to_vec();
AddChannelWidget {
state: &self.state.add_channel,
}
.render(add_channel_area[1], buf);
}
}
}
@ -116,3 +135,21 @@ fn get_channel_title_style(highlighted: bool) -> Style {
Style::default()
}
}
impl<'a> Widget for AddChannelWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let block = Block::default()
.title_top(Line::from("Add Channel").centered())
.title_style(
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD | Modifier::ITALIC | Modifier::UNDERLINED),
)
.borders(ratatui::widgets::Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let para = Paragraph::new(Line::from(self.state)).block(block);
para.render(area, buf);
}
}

View File

@ -7,10 +7,33 @@ use ratatui::{
widgets::{Block, Borders, Paragraph, Widget},
};
use super::UiAction;
use super::{PopupUiAction, UiAction};
pub struct ControlsWidget;
pub fn parse_popup_ui_action(raw_event: Event) -> PopupUiAction {
match raw_event {
Event::Key(key_event) => {
if key_event.kind != KeyEventKind::Press {
return PopupUiAction::None;
}
if key_event.modifiers != KeyModifiers::NONE {
return PopupUiAction::None;
}
return match key_event.code {
KeyCode::Backspace => PopupUiAction::Backspace,
KeyCode::Char(c) => PopupUiAction::AddChar(c),
KeyCode::Enter => PopupUiAction::Submit,
KeyCode::Esc => PopupUiAction::Close,
_ => PopupUiAction::None,
};
}
_ => PopupUiAction::None,
}
}
pub fn parse_ui_action(raw_event: Event) -> UiAction {
match raw_event {
Event::Key(key_event) => {
@ -36,6 +59,9 @@ pub fn parse_ui_action(raw_event: Event) -> UiAction {
KeyCode::Char('j') => UiAction::FocusEntryDown,
KeyCode::Char('d') => UiAction::ToggleDebug,
KeyCode::Char('q') => UiAction::Exit,
KeyCode::Char('a') => UiAction::ShowAddChannelUi,
KeyCode::Char('r') => UiAction::RemoveChannel,
KeyCode::Char('u') => UiAction::ToggleReadStatus,
KeyCode::Enter => UiAction::OpenArticle,
_ => UiAction::None,
};
@ -69,6 +95,12 @@ impl Widget for ControlsWidget {
description!(" to navigate up/down, "),
control!("h/l"),
description!(" to switch between channels and articles, "),
control!("a"),
description!(" add a new RSS channel, "),
control!("r"),
description!(" remove an RSS channel, "),
control!("u"),
description!(" toggle read state of article, "),
control!("q"),
description!(" to exit"),
])

View File

@ -23,27 +23,31 @@ impl<'a> Widget for DebugWidget<'a> {
lines.push(format!("channels: {}", self.state.channels.len()));
lines.push(format!("highlighted: {:?}", self.state.highlighted_channel));
if let Some(h) = self.state.highlighted_channel {
for channel in &self.state.channels {
if channel.id as usize == h {
lines.push(format!(
"highlighted channel: ({},{},{},{:?})",
channel.id, channel.title, channel.link, channel.last_update
));
let hi_channel = self.state.channels.get(h);
if let Some(channel) = hi_channel {
lines.push(format!(
"highlighted channel: ({}, {:?}, {})",
channel.id, channel.last_update, channel.title,
));
if let Some(article) = channel.articles.first() {
if let Some(article) = channel.articles.first() {
lines.push(format!(
"first article: ({} ,{:?}, {})",
article.id, article.last_update, article.title,
));
}
if let Some(h) = self.state.highlighted_article {
let hi_article = channel.articles.get(h);
if let Some(article) = hi_article {
lines.push(format!(
"first article: ({},{},{},{:?})",
article.id, article.title, article.link, article.last_update
"highlighted article: ({}, {:?}, {}, unread={})",
article.id, article.last_update, article.title, article.unread,
));
}
}
}
}
if let Some(h) = self.state.highlighted_article {
lines.push(format!("highlighted article: {}", h));
}
let line_areas = Layout::default()
.direction(Direction::Vertical)
.constraints(

49
src/ui/executor.rs Normal file
View File

@ -0,0 +1,49 @@
use std::sync::mpsc::{Receiver, Sender};
use super::UiCommandDispatchActions;
pub struct UiCommandExecutor {
pub command_receiver: Receiver<UiCommandDispatchActions>,
pub status_sender: Sender<u64>,
}
impl UiCommandExecutor {
pub fn new(
command_receiver: Receiver<UiCommandDispatchActions>,
status_sender: Sender<u64>,
) -> Self {
UiCommandExecutor {
command_receiver,
status_sender,
}
}
pub fn run(&self, ctx: crate::commands::TrsEnv) -> () {
loop {
let action = self.command_receiver.recv();
let Ok(action) = action else {
break;
};
match action {
UiCommandDispatchActions::AddChannel(args) => {
if let Ok(_) = crate::commands::add_channel(&ctx, &args) {
self.status_sender.send(1).unwrap_or_default();
};
}
UiCommandDispatchActions::RemoveChannel(args) => {
if let Ok(_) = crate::commands::remove_channel(&ctx, &args) {
self.status_sender.send(1).unwrap_or_default();
}
}
UiCommandDispatchActions::MarkArticleRead(args) => {
if let Ok(_) = crate::commands::mark_read(&ctx, &args) {
self.status_sender.send(1).unwrap_or_default();
}
}
}
}
}
}