This commit is contained in:
cool-mist 2025-07-06 00:51:23 +05:30
parent c15745b156
commit cdf1322a92
8 changed files with 333 additions and 152 deletions

54
Cargo.lock generated
View File

@ -253,6 +253,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "deranged"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.0.1" version = "2.0.1"
@ -920,6 +929,12 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]] [[package]]
name = "object" name = "object"
version = "0.36.7" version = "0.36.7"
@ -1041,6 +1056,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.95" version = "1.0.95"
@ -1163,6 +1184,7 @@ dependencies = [
"hashlink", "hashlink",
"libsqlite3-sys", "libsqlite3-sys",
"smallvec", "smallvec",
"time",
] ]
[[package]] [[package]]
@ -1499,6 +1521,37 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "time"
version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
[[package]]
name = "time-macros"
version = "0.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
dependencies = [
"num-conv",
"time-core",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.1" version = "0.8.1"
@ -1630,6 +1683,7 @@ dependencies = [
"ratatui", "ratatui",
"reqwest", "reqwest",
"rusqlite", "rusqlite",
"time",
"xml-rs", "xml-rs",
] ]

View File

@ -8,5 +8,6 @@ argh = "0.1.13"
crossterm = "0.29.0" crossterm = "0.29.0"
ratatui = "0.29.0" ratatui = "0.29.0"
reqwest = { version = "0.12.20", features = ["blocking"] } reqwest = { version = "0.12.20", features = ["blocking"] }
rusqlite = { version = "0.36.0", features = ["bundled"] } rusqlite = { version = "0.36.0", features = ["bundled", "time"] }
time = { version = "0.3.41", features = ["parsing"] }
xml-rs = "0.8.26" xml-rs = "0.8.26"

10
justfile Normal file
View File

@ -0,0 +1,10 @@
set quiet := true
build:
cargo build
test:
cargo test
run +args='list':
cargo run -- {{args}}

View File

@ -1,31 +1,28 @@
use crate::{ use crate::{
args::{AddChannelArgs, ListChannelArgs, RemoveChannelArgs, TrsArgs, TrsSubCommand}, args::{AddChannelArgs, ListChannelArgs, RemoveChannelArgs},
error::TrsError, error::TrsError,
parser, parser,
persistence::Db, persistence::{Db, RssChannelD},
}; };
pub struct RssChannelD { pub struct TrsEnv {
pub id: i64, db: Db,
pub title: String, http_client: reqwest::blocking::Client,
pub link: String,
pub description: String,
} }
impl RssChannelD { impl TrsEnv {
fn new(id: i64, title: String, link: String, description: String) -> Self { pub fn new(instance_name: &str) -> Result<Self, TrsError> {
RssChannelD { let db = Db::create(instance_name)?;
id, let http_client = reqwest::blocking::Client::builder()
title, .user_agent("cool-mist/trs")
link, .build()
description, .map_err(|e| TrsError::ReqwestError(e, "Failed to create HTTP client".to_string()))?;
} Ok(TrsEnv { db, http_client })
} }
} }
pub fn add_channel(db: &mut Db, args: &AddChannelArgs) -> Result<(), TrsError> { pub fn add_channel(ctx: &mut TrsEnv, args: &AddChannelArgs) -> Result<RssChannelD, TrsError> {
let client = reqwest::blocking::Client::new(); let rss = ctx.http_client.get(&args.link).send().map_err(|e| {
let rss = client.get(&args.link).send().map_err(|e| {
TrsError::ReqwestError( TrsError::ReqwestError(
e, e,
"Unable to download provided RSS channel link".to_string(), "Unable to download provided RSS channel link".to_string(),
@ -41,49 +38,16 @@ pub fn add_channel(db: &mut Db, args: &AddChannelArgs) -> Result<(), TrsError> {
.ignore_invalid_encoding_declarations(true) .ignore_invalid_encoding_declarations(true)
.create_reader(&bytes[..]); .create_reader(&bytes[..]);
let channel = parser::parse_rss_channel(xml_source_stream)?; let channel = parser::parse_rss_channel(xml_source_stream)?;
db.add_channel ctx.db.add_channel(&channel)
.execute((channel.title, &args.link, channel.description))
.map_err(|e| TrsError::SqlError(e, "Failed to insert channel into database".to_string()))?;
Ok(())
} }
pub fn list_channels(conn: &mut Db, args: &ListChannelArgs) -> Result<Vec<RssChannelD>, TrsError> { pub fn list_channels(
let channels_iter = ctx: &mut TrsEnv,
conn.list_channels args: &ListChannelArgs,
.query_map([args.limit.unwrap_or_else(|| 999)], |row| { ) -> Result<Vec<RssChannelD>, TrsError> {
Ok(( ctx.db.list_channels(args.limit.unwrap_or(u32::MAX))
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})?;
let mut channels = Vec::new();
for row in channels_iter {
let (id, name, link, description) = row?;
let channel = RssChannelD::new(id, name, link, description);
channels.push(channel);
}
Ok(channels)
} }
pub fn remove_channel(db: &mut Db, args: &RemoveChannelArgs) -> Result<(), TrsError> { pub fn remove_channel(ctx: &mut TrsEnv, args: &RemoveChannelArgs) -> Result<(), TrsError> {
let rows_affected = db ctx.db.remove_channel(args.id).map(|_| ())
.remove_channel
.execute([args.id])
.map_err(|e| TrsError::SqlError(e, "Failed to delete channel from database".to_string()))?;
if rows_affected == 0 {
return Err(TrsError::Error(format!(
"No channel found with ID: {}",
args.id
)));
}
println!("Channel with ID {} deleted successfully.", args.id);
Ok(())
} }

View File

@ -1,4 +1,5 @@
use args::{TrsArgs, TrsSubCommand}; use args::{TrsArgs, TrsSubCommand};
use commands::TrsEnv;
use error::Result; use error::Result;
pub mod args; pub mod args;
pub mod commands; pub mod commands;
@ -10,26 +11,27 @@ pub mod ui;
fn main() -> Result<()> { fn main() -> Result<()> {
if std::env::args().len() < 2 { if std::env::args().len() < 2 {
let terminal = ratatui::init(); let terminal = ratatui::init();
let conn = persistence::init_connection()?; let ctx = TrsEnv::new("test")?;
let db = persistence::init_db(&conn)?; ui::ui(ctx, terminal)?;
ui::ui(db, terminal)?;
ratatui::restore(); ratatui::restore();
return Ok(()); return Ok(());
} }
let args = argh::from_env::<TrsArgs>(); let args = argh::from_env::<TrsArgs>();
let conn = persistence::init_connection()?; let mut ctx = TrsEnv::new("test")?;
let mut db = persistence::init_db(&conn)?;
match args.sub_command { match args.sub_command {
TrsSubCommand::AddChannel(args) => commands::add_channel(&mut db, &args), TrsSubCommand::AddChannel(args) => {
commands::add_channel(&mut ctx, &args)?;
Ok(())
}
TrsSubCommand::ListChannels(args) => { TrsSubCommand::ListChannels(args) => {
let channels = commands::list_channels(&mut db, &args)?; let channels = commands::list_channels(&mut ctx, &args)?;
for channel in channels { for channel in channels {
println!("{}: {} ({})", channel.id, channel.title, channel.link); println!("{}: {} ({}) updated on {}", channel.id, channel.title, channel.link, channel.last_update);
} }
return Ok(()); Ok(())
} }
TrsSubCommand::RemoveChannel(args) => commands::remove_channel(&mut db, &args), TrsSubCommand::RemoveChannel(args) => commands::remove_channel(&mut ctx, &args),
} }
} }

View File

@ -1,5 +1,11 @@
use std::io::Read; use std::io::Read;
use time::format_description;
use time::format_description::well_known::Iso8601;
use time::format_description::well_known::Rfc2822;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use time::PrimitiveDateTime;
use xml::{reader::XmlEvent, EventReader}; use xml::{reader::XmlEvent, EventReader};
use crate::error::Result; use crate::error::Result;
@ -12,6 +18,13 @@ pub struct RssChannel {
pub articles: Vec<RssArticle>, pub articles: Vec<RssArticle>,
} }
pub struct RssArticle {
pub title: String,
pub link: String,
pub description: String,
pub date: Option<OffsetDateTime>,
}
impl RssChannel { impl RssChannel {
fn new() -> Self { fn new() -> Self {
RssChannel { RssChannel {
@ -32,41 +45,64 @@ impl RssChannel {
}; };
match field.field { match field.field {
XmlField::ArticleTitle => self.title = value, XmlField::ChannelTitle => self.title = value,
XmlField::ArticleLink => self.link = value, XmlField::ChannelLink => self.link = value,
XmlField::ArticleDescription => self.description = value, XmlField::ChannelDescription => self.description = value,
XmlField::ItemTitle => last_article.ok_or_else(no_item_error)?.title = value, XmlField::ArticleTitle => last_article.ok_or_else(no_item_error)?.title = value,
XmlField::ItemLink => last_article.ok_or_else(no_item_error)?.link = value, XmlField::ArticleLink => last_article.ok_or_else(no_item_error)?.link = value,
XmlField::ItemPubDate => last_article.ok_or_else(no_item_error)?.date = value, XmlField::ArticleDescription => {
last_article.ok_or_else(no_item_error)?.description = value
}
XmlField::ArticlePubDate => {
last_article.ok_or_else(no_item_error)?.date = Some(RssArticle::parse_date(&value)?)
}
} }
Ok(()) Ok(())
} }
} }
pub struct RssArticle {
pub title: String,
pub link: String,
pub date: String,
}
impl RssArticle { impl RssArticle {
fn new() -> Self { fn new() -> Self {
RssArticle { RssArticle {
title: String::new(), title: String::new(),
link: String::new(), link: String::new(),
date: String::new(), description: String::new(),
date: None,
}
}
fn parse_date(value: &str) -> Result<OffsetDateTime> {
let weird_format = format_description::parse(
"[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] UTC",
)
.unwrap();
let parsed = OffsetDateTime::parse(value, &Rfc2822)
.or(OffsetDateTime::parse(value, &Rfc3339))
.or(OffsetDateTime::parse(value, &Iso8601::DEFAULT));
match parsed {
Ok(date) => Ok(date),
Err(_) => {
// Try parsing with the weird format
PrimitiveDateTime::parse(value, &weird_format)
.map(|dt| dt.assume_utc())
.map_err(|e| {
TrsError::Error(format!("Failed to parse date '{}': {}", value, e))
})
}
} }
} }
} }
enum XmlField { enum XmlField {
ItemTitle,
ItemLink,
ItemPubDate,
ArticleTitle, ArticleTitle,
ArticleLink, ArticleLink,
ArticlePubDate,
ArticleDescription, ArticleDescription,
ChannelTitle,
ChannelLink,
ChannelDescription,
} }
struct XmlTagField { struct XmlTagField {
@ -95,13 +131,18 @@ impl XmlTagField {
} }
} }
const FIELD_TAG_MAPPINGS: [XmlTagField; 6] = [ const FIELD_TAG_MAPPINGS: [XmlTagField; 7] = [
XmlTagField::mapping("title", "title", XmlField::ArticleTitle), XmlTagField::mapping("title", "title", XmlField::ChannelTitle),
XmlTagField::mapping("link", "link", XmlField::ArticleLink), XmlTagField::mapping("link", "link", XmlField::ChannelLink),
XmlTagField::mapping("description", "description", XmlField::ArticleDescription), XmlTagField::mapping("description", "description", XmlField::ChannelDescription),
XmlTagField::mapping("item > title", "title", XmlField::ItemTitle), XmlTagField::mapping("item > title", "title", XmlField::ArticleTitle),
XmlTagField::mapping("item > link", "link", XmlField::ItemLink), XmlTagField::mapping("item > link", "link", XmlField::ArticleLink),
XmlTagField::mapping("item > pubDate", "pubDate", XmlField::ItemPubDate), XmlTagField::mapping(
"item > description",
"description",
XmlField::ArticleDescription,
),
XmlTagField::mapping("item > pubDate", "pubDate", XmlField::ArticlePubDate),
]; ];
pub fn parse_rss_channel<R: Read>(xml_source_stream: EventReader<R>) -> Result<RssChannel> { pub fn parse_rss_channel<R: Read>(xml_source_stream: EventReader<R>) -> Result<RssChannel> {
@ -201,7 +242,8 @@ mod tests {
for article in &rss_channel.articles { for article in &rss_channel.articles {
assert!(!article.title.is_empty()); assert!(!article.title.is_empty());
assert!(!article.link.is_empty()); assert!(!article.link.is_empty());
assert!(!article.date.is_empty()); assert!(!article.description.is_empty());
assert!(article.date.is_some());
} }
} }
}; };

View File

@ -1,78 +1,186 @@
use std::env; use std::env;
use rusqlite::{Connection, Statement}; use rusqlite::Connection;
use time::OffsetDateTime;
use crate::error::Result; use crate::error::Result;
use crate::error::TrsError; use crate::error::TrsError;
use crate::parser::RssChannel;
const CREATE_TABLE: &'static str = "CREATE TABLE IF NOT EXISTS Channels ( \ const SCHEMA_CHANNELS: &'static str = "CREATE TABLE IF NOT EXISTS Channels ( \
id INTEGER PRIMARY KEY, \ id INTEGER PRIMARY KEY, \
name TEXT NOT NULL, \ name TEXT NOT NULL, \
link TEXT NOT NULL UNIQUE, \ link TEXT NOT NULL UNIQUE, \
description TEXT \ description TEXT, \
last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP \
)"; )";
const ADD_CHANNEL: &'static str = "INSERT INTO Channels (name, link, description) \ const SCHEMA_ARTICLES: &'static str = "CREATE TABLE IF NOT EXISTS Articles ( \
VALUES (?1, ?2, ?3)\ id INTEGER PRIMARY KEY, \
ON CONFLICT(link) DO UPDATE SET name=?1, description=?3"; channel_id INTEGER NOT NULL, \
const REMOVE_CHANNEL: &'static str = "DELETE FROM Channels WHERE id = ?1"; title TEXT NOT NULL, \
const LIST_CHANNELS: &'static str = "SELECT id, name, link, description FROM Channels LIMIT ?1"; description TEXT, \
link TEXT NOT NULL UNIQUE, \
pub TIMESTAMP, \
last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \
unread BOOLEAN DEFAULT TRUE, \
FOREIGN KEY(channel_id) REFERENCES Channels(id) ON DELETE CASCADE \
)";
pub struct Db<'a> { const ADD_CHANNEL: &'static str = "INSERT INTO Channels (name, link, description, last_update) \
pub add_channel: Statement<'a>, VALUES (?1, ?2, ?3, CURRENT_TIMESTAMP)\
pub remove_channel: Statement<'a>, ON CONFLICT(link) DO UPDATE SET name=?1, description=?3, last_update=CURRENT_TIMESTAMP";
pub list_channels: Statement<'a>, 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";
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) \
VALUES (?1, ?2, ?3, ?4, ?5, CURRENT_TIMESTAMP) \
ON CONFLICT(link) DO UPDATE SET pub=?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";
const LIST_ARTICLES: &'static str =
"SELECT id, channel_id, title, description, link, pub, last_update, unread FROM Articles ORDER BY pub DESC LIMIT ?2";
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";
pub struct Db {
connection: Connection,
} }
macro_rules! prepare_sql { pub struct RssChannelD {
pub id: i64,
pub title: String,
pub link: String,
pub description: String,
pub last_update: OffsetDateTime,
}
macro_rules! schema_sql {
($conn:expr, $sql:expr) => { ($conn:expr, $sql:expr) => {
$conn.prepare($sql).map_err(|e| { $conn
TrsError::SqlError(e, format!("Failed to prepare SQL statement: {}", $sql)) .execute($sql, ())
}) .map_err(|e| TrsError::SqlError(e, "Failed to create Channels table".to_string()))?;
}; };
} }
impl<'a> Db<'a> { impl Db {
fn create(conn: &'a Connection) -> Result<Self> { pub fn create(instance_name: &str) -> Result<Self> {
let add_channel = prepare_sql!(conn, ADD_CHANNEL)?; let home_dir = env::home_dir();
let remove_channel = prepare_sql!(conn, REMOVE_CHANNEL)?; let db_dir = home_dir
let list_channels = prepare_sql!(conn, LIST_CHANNELS)?; .map(|dir| dir.join(".config").join("trs"))
Ok(Db { .ok_or(TrsError::Error(
add_channel, "Unable to determine home directory".to_string(),
remove_channel, ))?;
list_channels,
}) match std::fs::create_dir_all(&db_dir) {
Ok(_) => {}
Err(e) => {
return Err(TrsError::Error(format!(
"Failed to create database directory: {}",
e
)));
}
}
let db_file = db_dir.join(format!("{}.db", instance_name));
let connection = Connection::open(db_file)?;
schema_sql!(connection, SCHEMA_CHANNELS);
schema_sql!(connection, SCHEMA_ARTICLES);
Ok(Db { connection })
}
pub fn get_channel(&self, link: &str) -> Result<RssChannelD> {
self.connection
.query_row(GET_CHANNEL, (link,), Db::map_rsschanneld)
.map_err(|e| {
TrsError::SqlError(e, format!("Failed to retrieve channel with link {}", link))
})
}
pub fn add_channel(&self, channel: &RssChannel) -> Result<RssChannelD> {
self.connection
.execute(
ADD_CHANNEL,
(&channel.title, &channel.link, &channel.description),
)
.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)))?;
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()))?;
}
Ok(inserted_channel)
}
pub fn remove_channel(&self, id: u32) -> Result<usize> {
self.connection
.execute(REMOVE_CHANNEL, (id,))
.map(|rows| rows as usize)
.map_err(|e| TrsError::SqlError(e, "Failed to remove channel".to_string()))
}
pub fn list_channels(&self, limit: u32) -> Result<Vec<RssChannelD>> {
let channels = self
.connection
.prepare(LIST_CHANNELS)
.map_err(|e| TrsError::SqlError(e, "Failed to prepare query".to_string()))?
.query_map((limit as i64,), Db::map_rsschanneld)
.map_err(|e| TrsError::SqlError(e, "Failed to list channels".to_string()))?
.map(|r| r.unwrap())
.collect::<Vec<RssChannelD>>();
Ok(channels)
}
fn map_rsschanneld(row: &rusqlite::Row) -> std::result::Result<RssChannelD, rusqlite::Error> {
Ok(RssChannelD::new(
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
} }
} }
pub fn init_db(conn: &Connection) -> Result<Db> { impl RssChannelD {
conn.execute(CREATE_TABLE, ()) fn new(
.map_err(|e| TrsError::SqlError(e, "Failed to create Channels table".to_string()))?; id: i64,
Db::create(conn) title: String,
} link: String,
description: String,
pub fn init_connection() -> Result<Connection> { last_update: OffsetDateTime,
let home_dir = env::home_dir(); ) -> Self {
let db_dir = home_dir RssChannelD {
.map(|dir| dir.join(".config").join("trs")) id,
.ok_or(TrsError::Error( title,
"Unable to determine home directory".to_string(), link,
))?; description,
last_update,
match std::fs::create_dir_all(&db_dir) {
Ok(_) => {}
Err(e) => {
return Err(TrsError::Error(format!(
"Failed to create database directory: {}",
e
)));
} }
} }
let db_file = db_dir.join("test.db");
let conn = Connection::open(db_file)?;
conn.execute(CREATE_TABLE, ())
.map_err(|e| TrsError::SqlError(e, "Failed to create Channels table".to_string()))?;
Ok(conn)
} }

View File

@ -2,9 +2,9 @@ use std::io::Stdout;
use crate::{ use crate::{
args::ListChannelArgs, args::ListChannelArgs,
commands::{self, RssChannelD}, commands::{self, TrsEnv},
error::{Result, TrsError}, error::{Result, TrsError},
persistence::Db, persistence::RssChannelD,
}; };
use crossterm::event::{self, Event, KeyEventKind}; use crossterm::event::{self, Event, KeyEventKind};
use ratatui::{ use ratatui::{
@ -72,13 +72,13 @@ struct AppState {
channels: Vec<RssChannelD>, channels: Vec<RssChannelD>,
} }
pub fn ui(mut db: Db, mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> { pub fn ui(mut ctx: TrsEnv, mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
let mut app_state = AppState { let mut app_state = AppState {
channels: Vec::new(), channels: Vec::new(),
exit: false, exit: false,
}; };
let channels = commands::list_channels(&mut db, &ListChannelArgs { limit: None })?; let channels = commands::list_channels(&mut ctx, &ListChannelArgs { limit: None })?;
app_state.channels = channels; app_state.channels = channels;
loop { loop {