From cdf1322a928a336a1c53081f65bc1acad6752d1b Mon Sep 17 00:00:00 2001 From: cool-mist Date: Sun, 6 Jul 2025 00:51:23 +0530 Subject: [PATCH] wip --- Cargo.lock | 54 ++++++++++++ Cargo.toml | 3 +- justfile | 10 +++ src/commands.rs | 82 +++++------------ src/main.rs | 22 ++--- src/parser.rs | 90 ++++++++++++++----- src/persistence.rs | 216 +++++++++++++++++++++++++++++++++------------ src/ui.rs | 8 +- 8 files changed, 333 insertions(+), 152 deletions(-) create mode 100644 justfile diff --git a/Cargo.lock b/Cargo.lock index 4eb1c17..4a3efdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,6 +253,15 @@ dependencies = [ "syn", ] +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_more" version = "2.0.1" @@ -920,6 +929,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "object" version = "0.36.7" @@ -1041,6 +1056,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1163,6 +1184,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "time", ] [[package]] @@ -1499,6 +1521,37 @@ dependencies = [ "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]] name = "tinystr" version = "0.8.1" @@ -1630,6 +1683,7 @@ dependencies = [ "ratatui", "reqwest", "rusqlite", + "time", "xml-rs", ] diff --git a/Cargo.toml b/Cargo.toml index 7f9fdf1..7d49fcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,5 +8,6 @@ argh = "0.1.13" crossterm = "0.29.0" ratatui = "0.29.0" 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" diff --git a/justfile b/justfile new file mode 100644 index 0000000..34d244a --- /dev/null +++ b/justfile @@ -0,0 +1,10 @@ +set quiet := true + +build: + cargo build + +test: + cargo test + +run +args='list': + cargo run -- {{args}} diff --git a/src/commands.rs b/src/commands.rs index 636677b..f24c5c3 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,31 +1,28 @@ use crate::{ - args::{AddChannelArgs, ListChannelArgs, RemoveChannelArgs, TrsArgs, TrsSubCommand}, + args::{AddChannelArgs, ListChannelArgs, RemoveChannelArgs}, error::TrsError, parser, - persistence::Db, + persistence::{Db, RssChannelD}, }; -pub struct RssChannelD { - pub id: i64, - pub title: String, - pub link: String, - pub description: String, +pub struct TrsEnv { + db: Db, + http_client: reqwest::blocking::Client, } -impl RssChannelD { - fn new(id: i64, title: String, link: String, description: String) -> Self { - RssChannelD { - id, - title, - link, - description, - } +impl TrsEnv { + pub fn new(instance_name: &str) -> Result { + let db = Db::create(instance_name)?; + let http_client = reqwest::blocking::Client::builder() + .user_agent("cool-mist/trs") + .build() + .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> { - let client = reqwest::blocking::Client::new(); - let rss = client.get(&args.link).send().map_err(|e| { +pub fn add_channel(ctx: &mut TrsEnv, args: &AddChannelArgs) -> Result { + let rss = ctx.http_client.get(&args.link).send().map_err(|e| { TrsError::ReqwestError( e, "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) .create_reader(&bytes[..]); let channel = parser::parse_rss_channel(xml_source_stream)?; - db.add_channel - .execute((channel.title, &args.link, channel.description)) - .map_err(|e| TrsError::SqlError(e, "Failed to insert channel into database".to_string()))?; - - Ok(()) + ctx.db.add_channel(&channel) } -pub fn list_channels(conn: &mut Db, args: &ListChannelArgs) -> Result, TrsError> { - let channels_iter = - conn.list_channels - .query_map([args.limit.unwrap_or_else(|| 999)], |row| { - Ok(( - 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 list_channels( + ctx: &mut TrsEnv, + args: &ListChannelArgs, +) -> Result, TrsError> { + ctx.db.list_channels(args.limit.unwrap_or(u32::MAX)) } -pub fn remove_channel(db: &mut Db, args: &RemoveChannelArgs) -> Result<(), TrsError> { - let rows_affected = db - .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(()) +pub fn remove_channel(ctx: &mut TrsEnv, args: &RemoveChannelArgs) -> Result<(), TrsError> { + ctx.db.remove_channel(args.id).map(|_| ()) } diff --git a/src/main.rs b/src/main.rs index c1a6300..37ee7a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use args::{TrsArgs, TrsSubCommand}; +use commands::TrsEnv; use error::Result; pub mod args; pub mod commands; @@ -10,26 +11,27 @@ pub mod ui; fn main() -> Result<()> { if std::env::args().len() < 2 { let terminal = ratatui::init(); - let conn = persistence::init_connection()?; - let db = persistence::init_db(&conn)?; - ui::ui(db, terminal)?; + let ctx = TrsEnv::new("test")?; + ui::ui(ctx, terminal)?; ratatui::restore(); return Ok(()); } let args = argh::from_env::(); - let conn = persistence::init_connection()?; - let mut db = persistence::init_db(&conn)?; + let mut ctx = TrsEnv::new("test")?; 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) => { - let channels = commands::list_channels(&mut db, &args)?; + let channels = commands::list_channels(&mut ctx, &args)?; 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), } } diff --git a/src/parser.rs b/src/parser.rs index 180e8e1..d17e336 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1,5 +1,11 @@ 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 crate::error::Result; @@ -12,6 +18,13 @@ pub struct RssChannel { pub articles: Vec, } +pub struct RssArticle { + pub title: String, + pub link: String, + pub description: String, + pub date: Option, +} + impl RssChannel { fn new() -> Self { RssChannel { @@ -32,41 +45,64 @@ impl RssChannel { }; match field.field { - XmlField::ArticleTitle => self.title = value, - XmlField::ArticleLink => self.link = value, - XmlField::ArticleDescription => self.description = value, - XmlField::ItemTitle => last_article.ok_or_else(no_item_error)?.title = value, - XmlField::ItemLink => last_article.ok_or_else(no_item_error)?.link = value, - XmlField::ItemPubDate => last_article.ok_or_else(no_item_error)?.date = value, + XmlField::ChannelTitle => self.title = value, + XmlField::ChannelLink => self.link = value, + XmlField::ChannelDescription => self.description = value, + XmlField::ArticleTitle => last_article.ok_or_else(no_item_error)?.title = value, + XmlField::ArticleLink => last_article.ok_or_else(no_item_error)?.link = 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(()) } } -pub struct RssArticle { - pub title: String, - pub link: String, - pub date: String, -} - impl RssArticle { fn new() -> Self { RssArticle { title: String::new(), link: String::new(), - date: String::new(), + description: String::new(), + date: None, + } + } + + fn parse_date(value: &str) -> Result { + 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 { - ItemTitle, - ItemLink, - ItemPubDate, ArticleTitle, ArticleLink, + ArticlePubDate, ArticleDescription, + ChannelTitle, + ChannelLink, + ChannelDescription, } struct XmlTagField { @@ -95,13 +131,18 @@ impl XmlTagField { } } -const FIELD_TAG_MAPPINGS: [XmlTagField; 6] = [ - XmlTagField::mapping("title", "title", XmlField::ArticleTitle), - XmlTagField::mapping("link", "link", XmlField::ArticleLink), - XmlTagField::mapping("description", "description", XmlField::ArticleDescription), - XmlTagField::mapping("item > title", "title", XmlField::ItemTitle), - XmlTagField::mapping("item > link", "link", XmlField::ItemLink), - XmlTagField::mapping("item > pubDate", "pubDate", XmlField::ItemPubDate), +const FIELD_TAG_MAPPINGS: [XmlTagField; 7] = [ + XmlTagField::mapping("title", "title", XmlField::ChannelTitle), + XmlTagField::mapping("link", "link", XmlField::ChannelLink), + XmlTagField::mapping("description", "description", XmlField::ChannelDescription), + XmlTagField::mapping("item > title", "title", XmlField::ArticleTitle), + XmlTagField::mapping("item > link", "link", XmlField::ArticleLink), + XmlTagField::mapping( + "item > description", + "description", + XmlField::ArticleDescription, + ), + XmlTagField::mapping("item > pubDate", "pubDate", XmlField::ArticlePubDate), ]; pub fn parse_rss_channel(xml_source_stream: EventReader) -> Result { @@ -201,7 +242,8 @@ mod tests { for article in &rss_channel.articles { assert!(!article.title.is_empty()); assert!(!article.link.is_empty()); - assert!(!article.date.is_empty()); + assert!(!article.description.is_empty()); + assert!(article.date.is_some()); } } }; diff --git a/src/persistence.rs b/src/persistence.rs index b7c13b7..65e9ece 100644 --- a/src/persistence.rs +++ b/src/persistence.rs @@ -1,78 +1,186 @@ use std::env; -use rusqlite::{Connection, Statement}; +use rusqlite::Connection; +use time::OffsetDateTime; use crate::error::Result; 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, \ name TEXT NOT NULL, \ 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) \ - VALUES (?1, ?2, ?3)\ - ON CONFLICT(link) DO UPDATE SET name=?1, description=?3"; -const REMOVE_CHANNEL: &'static str = "DELETE FROM Channels WHERE id = ?1"; -const LIST_CHANNELS: &'static str = "SELECT id, name, link, description FROM Channels LIMIT ?1"; +const SCHEMA_ARTICLES: &'static str = "CREATE TABLE IF NOT EXISTS Articles ( \ + id INTEGER PRIMARY KEY, \ + channel_id INTEGER NOT NULL, \ + title TEXT NOT NULL, \ + 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> { - pub add_channel: Statement<'a>, - pub remove_channel: Statement<'a>, - pub list_channels: Statement<'a>, +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"; +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.prepare($sql).map_err(|e| { - TrsError::SqlError(e, format!("Failed to prepare SQL statement: {}", $sql)) - }) + $conn + .execute($sql, ()) + .map_err(|e| TrsError::SqlError(e, "Failed to create Channels table".to_string()))?; }; } -impl<'a> Db<'a> { - fn create(conn: &'a Connection) -> Result { - let add_channel = prepare_sql!(conn, ADD_CHANNEL)?; - let remove_channel = prepare_sql!(conn, REMOVE_CHANNEL)?; - let list_channels = prepare_sql!(conn, LIST_CHANNELS)?; - Ok(Db { - add_channel, - remove_channel, - list_channels, - }) +impl Db { + pub fn create(instance_name: &str) -> Result { + let home_dir = env::home_dir(); + let db_dir = home_dir + .map(|dir| dir.join(".config").join("trs")) + .ok_or(TrsError::Error( + "Unable to determine home directory".to_string(), + ))?; + + 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 { + 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 { + 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 { + 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> { + 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::>(); + + Ok(channels) + } + + fn map_rsschanneld(row: &rusqlite::Row) -> std::result::Result { + 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 { - conn.execute(CREATE_TABLE, ()) - .map_err(|e| TrsError::SqlError(e, "Failed to create Channels table".to_string()))?; - Db::create(conn) -} - -pub fn init_connection() -> Result { - let home_dir = env::home_dir(); - let db_dir = home_dir - .map(|dir| dir.join(".config").join("trs")) - .ok_or(TrsError::Error( - "Unable to determine home directory".to_string(), - ))?; - - match std::fs::create_dir_all(&db_dir) { - Ok(_) => {} - Err(e) => { - return Err(TrsError::Error(format!( - "Failed to create database directory: {}", - e - ))); +impl RssChannelD { + fn new( + id: i64, + title: String, + link: String, + description: String, + last_update: OffsetDateTime, + ) -> Self { + RssChannelD { + id, + title, + link, + description, + last_update, } } - - 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) } diff --git a/src/ui.rs b/src/ui.rs index c022411..f7cf768 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,9 +2,9 @@ use std::io::Stdout; use crate::{ args::ListChannelArgs, - commands::{self, RssChannelD}, + commands::{self, TrsEnv}, error::{Result, TrsError}, - persistence::Db, + persistence::RssChannelD, }; use crossterm::event::{self, Event, KeyEventKind}; use ratatui::{ @@ -72,13 +72,13 @@ struct AppState { channels: Vec, } -pub fn ui(mut db: Db, mut terminal: Terminal>) -> Result<()> { +pub fn ui(mut ctx: TrsEnv, mut terminal: Terminal>) -> Result<()> { let mut app_state = AppState { channels: Vec::new(), 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; loop {