diff --git a/src/args.rs b/src/args.rs index c2fe7be..dd8b882 100644 --- a/src/args.rs +++ b/src/args.rs @@ -12,7 +12,9 @@ pub struct TrsArgs { pub enum TrsSubCommand { AddChannel(AddChannelArgs), ListChannels(ListChannelArgs), + GetArticles(GetArticlesArgs), RemoveChannel(RemoveChannelArgs), + MarkRead(MarkReadArgs), } /// Add a new RSS channel @@ -33,6 +35,32 @@ pub struct ListChannelArgs { pub limit: Option, } +/// Get articles +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "articles")] +pub struct GetArticlesArgs { + /// id of the channel to get articles from + #[argh(option, short = 'c')] + pub channel_id: Option, + + /// only get unread articles + #[argh(switch)] + pub unread: bool, +} + +/// Mark article as read/unread +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "read")] +pub struct MarkReadArgs { + /// id of the article to mark read/unread + #[argh(option)] + pub id: u32, + + /// mark the article as unread + #[argh(switch)] + pub unread: bool, +} + /// Delete an RSS channel #[derive(FromArgs, PartialEq, Debug)] #[argh(subcommand, name = "remove")] diff --git a/src/commands.rs b/src/commands.rs index f24c5c3..a180ba4 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,5 +1,5 @@ use crate::{ - args::{AddChannelArgs, ListChannelArgs, RemoveChannelArgs}, + args::{self, AddChannelArgs, ListChannelArgs, RemoveChannelArgs}, error::TrsError, parser, persistence::{Db, RssChannelD}, @@ -51,3 +51,34 @@ pub fn list_channels( pub fn remove_channel(ctx: &mut TrsEnv, args: &RemoveChannelArgs) -> Result<(), TrsError> { ctx.db.remove_channel(args.id).map(|_| ()) } + +pub fn mark_read(ctx: &mut 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)?, + }; + + Ok(()) +} + +pub fn get_articles_by_channel( + ctx: &mut TrsEnv, + args: &args::GetArticlesArgs, +) -> Result, TrsError> { + let mut channels = ctx.db.list_channels(u32::MAX)?; + channels.retain(|channel| { + if let Some(channel_id) = args.channel_id { + channel_id as i64 == channel.id + } else { + true + } + }); + + if args.unread { + for channel in &mut channels { + channel.articles.retain(|article| article.unread); + } + } + + Ok(channels) +} diff --git a/src/main.rs b/src/main.rs index 37ee7a7..8c4a783 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,5 +33,22 @@ fn main() -> Result<()> { Ok(()) } TrsSubCommand::RemoveChannel(args) => commands::remove_channel(&mut ctx, &args), + TrsSubCommand::GetArticles(args) => { + let channels = commands::get_articles_by_channel(&mut ctx, &args)?; + for channel in channels { + println!("Channel #{}: {} ({})", channel.id, channel.title, channel.link); + for article in channel.articles { + println!( + " #{} - {} ({}) [{}]", + article.id, + article.title, + article.link, + article.pub_date.map_or("No date".to_string(), |d| d.to_string()) + ); + } + } + Ok(()) + } + TrsSubCommand::MarkRead(args) => commands::mark_read(&mut ctx, &args), } } diff --git a/src/persistence.rs b/src/persistence.rs index 65e9ece..c4a973e 100644 --- a/src/persistence.rs +++ b/src/persistence.rs @@ -5,6 +5,7 @@ use time::OffsetDateTime; use crate::error::Result; use crate::error::TrsError; +use crate::parser::RssArticle; use crate::parser::RssChannel; const SCHEMA_CHANNELS: &'static str = "CREATE TABLE IF NOT EXISTS Channels ( \ @@ -21,9 +22,9 @@ const SCHEMA_ARTICLES: &'static str = "CREATE TABLE IF NOT EXISTS Articles ( \ title TEXT NOT NULL, \ description TEXT, \ link TEXT NOT NULL UNIQUE, \ - pub TIMESTAMP, \ + pub_date TIMESTAMP, \ last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ - unread BOOLEAN DEFAULT TRUE, \ + unread BOOLEAN DEFAULT FALSE, \ FOREIGN KEY(channel_id) REFERENCES Channels(id) ON DELETE CASCADE \ )"; @@ -36,21 +37,23 @@ const LIST_CHANNELS: &'static str = 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, last_update) \ +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=?5, last_update=CURRENT_TIMESTAMP"; + ON CONFLICT(link) DO UPDATE SET pub_date=?5, last_update=CURRENT_TIMESTAMP"; const GET_ARTICLES_BY_CHANNEL: &'static str = - "SELECT id, channel_id, title, description, link, pub, last_update, unread FROM Articles WHERE channel_id = ?1"; + "SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles WHERE channel_id = ?1"; + +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, last_update, unread FROM Articles ORDER BY pub DESC LIMIT ?2"; + "SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles"; -const MARK_ARTICLE_READ: &'static str = - "UPDATE Articles SET unread = FALSE WHERE id = ?1"; +const MARK_ARTICLE_READ: &'static str = "UPDATE Articles SET unread = FALSE WHERE id = ?1"; -const MARK_ARTICLE_UNREAD: &'static str = - "UPDATE Articles SET unread = TRUE WHERE id = ?1"; +const MARK_ARTICLE_UNREAD: &'static str = "UPDATE Articles SET unread = TRUE WHERE id = ?1"; pub struct Db { connection: Connection, @@ -62,6 +65,18 @@ pub struct RssChannelD { pub link: String, pub description: String, pub last_update: OffsetDateTime, + pub articles: Vec, +} + +pub struct RssArticleD { + pub id: i64, + pub channel_id: i64, + pub title: String, + pub description: String, + pub link: String, + pub pub_date: Option, + pub last_update: Option, + pub unread: bool, } macro_rules! schema_sql { @@ -100,11 +115,15 @@ impl Db { } pub fn get_channel(&self, link: &str) -> Result { - self.connection + let mut channel = self + .connection .query_row(GET_CHANNEL, (link,), Db::map_rsschanneld) .map_err(|e| { TrsError::SqlError(e, format!("Failed to retrieve channel with link {}", link)) - }) + })?; + + channel.articles = self.list_articles_by_channel(channel.id)?; + Ok(channel) } pub fn add_channel(&self, channel: &RssChannel) -> Result { @@ -115,24 +134,17 @@ impl Db { ) .map_err(|e| TrsError::SqlError(e, "Failed to add channel".to_string()))?; - let inserted_channel = self.get_channel(&channel.link) - .map_err(|e| TrsError::Error(format!("Failed to retrieve channel after adding: {}", e)))?; + let mut inserted_channel = self.get_channel(&channel.link).map_err(|e| { + TrsError::Error(format!("Failed to retrieve channel after adding: {}", e)) + })?; + let mut articles = Vec::new(); for article in &channel.articles { - self.connection - .execute( - ADD_ARTICLE, - ( - &inserted_channel.id, - &article.title, - &article.description, - &article.link, - article.date.map(|d| d.to_string()), - ), - ) - .map_err(|e| TrsError::SqlError(e, "Failed to add article".to_string()))?; + let article = self.add_article(inserted_channel.id, article)?; + articles.push(article); } + inserted_channel.articles = articles; Ok(inserted_channel) } @@ -144,7 +156,7 @@ impl Db { } pub fn list_channels(&self, limit: u32) -> Result> { - let channels = self + let mut channels = self .connection .prepare(LIST_CHANNELS) .map_err(|e| TrsError::SqlError(e, "Failed to prepare query".to_string()))? @@ -153,9 +165,86 @@ impl Db { .map(|r| r.unwrap()) .collect::>(); + let mut articles = self.list_articles()?; + + for channel in &mut channels { + for article in &mut articles { + if article.channel_id == channel.id { + let copied = std::mem::replace(article, RssArticleD::dummy()); + channel.articles.push(copied); + } + } + } + Ok(channels) } + pub fn mark_article_read(&self, id: i64) -> Result { + self.connection + .execute(MARK_ARTICLE_READ, (id,)) + .map(|rows| rows as usize) + .map_err(|e| TrsError::SqlError(e, "Failed to mark article as read".to_string())) + } + + pub fn mark_article_unread(&self, id: i64) -> Result { + self.connection + .execute(MARK_ARTICLE_UNREAD, (id,)) + .map(|rows| rows as usize) + .map_err(|e| TrsError::SqlError(e, "Failed to mark article as unread".to_string())) + } + + fn add_article(&self, channel_id: i64, article: &RssArticle) -> Result { + self.connection + .execute( + ADD_ARTICLE, + ( + channel_id, + &article.title, + &article.description, + &article.link, + article.date.map(|d| d.to_string()), + ), + ) + .map_err(|e| TrsError::SqlError(e, "Failed to add article".to_string()))?; + + self.get_article(&article.link) + .map_err(|e| TrsError::Error(format!("Failed to retrieve article after adding: {}", e))) + } + + fn get_article(&self, link: &str) -> Result { + self.connection + .query_row(GET_ARTICLE, (link,), Db::map_rssarticled) + .map_err(|e| { + TrsError::SqlError(e, format!("Failed to retrieve article with link {}", link)) + }) + } + + fn list_articles_by_channel(&self, channel_id: i64) -> Result> { + let articles = self + .connection + .prepare(GET_ARTICLES_BY_CHANNEL) + .map_err(|e| TrsError::SqlError(e, "Failed to prepare query".to_string()))? + .query_map((channel_id,), Db::map_rssarticled) + .map_err(|e| TrsError::SqlError(e, "Failed to list articles".to_string()))? + .map(|r| r.unwrap()) + .collect::>(); + + Ok(articles) + } + + fn list_articles(&self) -> Result> { + let articles = self + .connection + .prepare(LIST_ARTICLES) + .map_err(|e| TrsError::SqlError(e, "Failed to prepare query".to_string()))? + .query_map([], Db::map_rssarticled) + .map_err(|e| TrsError::SqlError(e, "Failed to list articles".to_string()))? + .map(|r| r.unwrap()) + .collect::>(); + + Ok(articles) + } + fn map_rsschanneld(row: &rusqlite::Row) -> std::result::Result { Ok(RssChannelD::new( row.get(0)?, @@ -163,8 +252,22 @@ impl Db { row.get(2)?, row.get(3)?, row.get(4)?, + Vec::new(), )) } + + fn map_rssarticled(row: &rusqlite::Row) -> std::result::Result { + Ok(RssArticleD { + id: row.get(0)?, + channel_id: row.get(1)?, + title: row.get(2)?, + description: row.get(3)?, + link: row.get(4)?, + pub_date: row.get(5).ok(), + last_update: row.get(6).ok(), + unread: row.get(7)?, + }) + } } impl RssChannelD { @@ -174,6 +277,7 @@ impl RssChannelD { link: String, description: String, last_update: OffsetDateTime, + articles: Vec, ) -> Self { RssChannelD { id, @@ -181,6 +285,22 @@ impl RssChannelD { link, description, last_update, + articles, + } + } +} + +impl RssArticleD { + fn dummy() -> Self { + RssArticleD { + id: -1, + channel_id: 0, + title: String::new(), + description: String::new(), + link: String::new(), + pub_date: None, + last_update: None, + unread: false, } } }