This commit is contained in:
cool-mist 2025-07-06 10:30:43 +05:30
parent cdf1322a92
commit 3c01effdf1
4 changed files with 224 additions and 28 deletions

View File

@ -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<u32>,
}
/// 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<u32>,
/// 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")]

View File

@ -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<Vec<RssChannelD>, 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)
}

View File

@ -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),
}
}

View File

@ -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<RssArticleD>,
}
pub struct RssArticleD {
pub id: i64,
pub channel_id: i64,
pub title: String,
pub description: String,
pub link: String,
pub pub_date: Option<OffsetDateTime>,
pub last_update: Option<OffsetDateTime>,
pub unread: bool,
}
macro_rules! schema_sql {
@ -100,11 +115,15 @@ impl Db {
}
pub fn get_channel(&self, link: &str) -> Result<RssChannelD> {
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<RssChannelD> {
@ -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<Vec<RssChannelD>> {
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::<Vec<RssChannelD>>();
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<usize> {
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<usize> {
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<RssArticleD> {
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<RssArticleD> {
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<Vec<RssArticleD>> {
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::<Vec<RssArticleD>>();
Ok(articles)
}
fn list_articles(&self) -> Result<Vec<RssArticleD>> {
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::<Vec<RssArticleD>>();
Ok(articles)
}
fn map_rsschanneld(row: &rusqlite::Row) -> std::result::Result<RssChannelD, rusqlite::Error> {
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<RssArticleD, rusqlite::Error> {
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<RssArticleD>,
) -> 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,
}
}
}