Add and List channels

This commit is contained in:
cool-mist 2025-06-22 23:30:36 +05:30
parent ddfdaf3347
commit f36ed6096b
7 changed files with 1869 additions and 39 deletions

1677
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,4 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
argh = "0.1.13"
reqwest = { version = "0.12.20", features = ["blocking"] }
rusqlite = { version = "0.36.0", features = ["bundled"] }
xml-rs = "0.8.26" xml-rs = "0.8.26"

View File

@ -7,4 +7,18 @@ Keep the goal simple.
- Have a way to mark an article as read/unread - Have a way to mark an article as read/unread
- https://www.rssboard.org/rss-specification - https://www.rssboard.org/rss-specification
## Database
Channels = Id | FeedName | Link | Atom/RSS | checksum
Articles = Id | ChannelId | Title | Link (unique) | Description | Published | Read
https://sqlite.org/lang_upsert.html
INSERT INTO Articles(ChannelId, Title, Link, Description, Published, 0)
VALUES('Alice','704-555-1212','2018-05-08')
ON CONFLICT(name) DO UPDATE SET
Read=0,
Published=excluded.Published
WHERE excluded.Published>Articles.Published;

41
src/args.rs Normal file
View File

@ -0,0 +1,41 @@
use argh::FromArgs;
/// Tiny RSS reader
#[derive(FromArgs, PartialEq, Debug)]
pub struct TrsArgs {
#[argh(subcommand)]
pub sub_command: TrsSubCommand,
}
#[derive(FromArgs, PartialEq, Debug)]
#[argh(subcommand)]
pub enum TrsSubCommand {
AddChannel(AddChannelArgs),
ListChannels(ListChannelArgs),
}
/// Add a new RSS channel
#[derive(FromArgs, PartialEq, Debug)]
#[argh(subcommand, name = "add")]
pub struct AddChannelArgs {
/// link to RSS channel
#[argh(option, from_str_fn(valid_url))]
pub link: String,
}
/// List RSS channels
#[derive(FromArgs, PartialEq, Debug)]
#[argh(subcommand, name = "list")]
pub struct ListChannelArgs {
/// limit the number of channels to list
#[argh(option)]
pub limit: Option<u32>,
}
pub fn valid_url(url: &str) -> Result<String, String> {
if url.starts_with("http://") || url.starts_with("https://") {
Ok(url.to_string())
} else {
Err(format!("Invalid URL: {}", url))
}
}

View File

@ -1,11 +1,23 @@
use std::fmt::Display; use std::fmt::Display;
use xml::reader::Error;
#[derive(Debug)] #[derive(Debug)]
pub enum TrsError { pub enum TrsError {
XmlParseError(String), Error(String),
XmlRsError(Error), XmlRsError(xml::reader::Error, String),
SqlError(rusqlite::Error, String),
ReqwestError(reqwest::Error, String),
}
impl From<rusqlite::Error> for TrsError {
fn from(err: rusqlite::Error) -> Self {
TrsError::SqlError(err, "No additional context provided".to_string())
}
}
impl From<reqwest::Error> for TrsError {
fn from(err: reqwest::Error) -> Self {
TrsError::ReqwestError(err, "No additional context provided".to_string())
}
} }
impl Display for TrsError { impl Display for TrsError {
@ -14,8 +26,10 @@ impl Display for TrsError {
f, f,
"{}", "{}",
match self { match self {
TrsError::XmlParseError(msg) => format!("XML Parse Error: {}", msg), TrsError::Error(msg) => format!("XML Parse Error: {}", msg),
TrsError::XmlRsError(err) => format!("XML Reader Error: {}", err), TrsError::XmlRsError(err, msg) => format!("{} XML Rs error {}", msg, err),
TrsError::SqlError(err, msg) => format!("SQL Error: {} - {}", err, msg),
TrsError::ReqwestError(err, msg) => format!("Reqwest Error: {} - {}", err, msg),
} }
) )
} }

View File

@ -1,33 +1,107 @@
use std::env;
use args::{TrsArgs, TrsSubCommand};
use error::TrsError; use error::TrsError;
use xml::ParserConfig; use rusqlite::Connection;
pub mod args;
pub mod error; pub mod error;
pub mod parser; pub mod parser;
fn main() -> Result<(), TrsError> { fn main() -> Result<(), TrsError> {
let bytes = include_bytes!("../sample/rss.xml"); let args = argh::from_env::<TrsArgs>();
let xml_source_stream = ParserConfig::new()
.ignore_invalid_encoding_declarations(true)
.create_reader(&bytes[..]);
let rss_channel = parser::parse_rss_channel(xml_source_stream)?;
println!("{}", rss_channel.title); let conn = init_db()?;
println!("{}", rss_channel.link); match args.sub_command {
println!("{}", rss_channel.description); TrsSubCommand::AddChannel(add_channel_args) => {
for article in &rss_channel.articles { add_channel(&conn, &add_channel_args.link)?;
let max_title_chars = article.title.len().min(47); }
let max_link_chars = article.link.len().min(67); TrsSubCommand::ListChannels(list_channel_args) => {
println!( list_channels(&conn, list_channel_args.limit)?;
"| {} | {:.<50} | {:.<70} |", }
article.date,
&article.title[0..max_title_chars],
&article.link[0..max_link_chars]
);
} }
println!(
"There are {} articles in the channel.",
rss_channel.articles.len()
);
Ok(()) Ok(())
} }
fn add_channel(conn: &Connection, link: &str) -> Result<(), TrsError> {
let client = reqwest::blocking::Client::new();
let rss = client.get(link).send().map_err(|e| {
TrsError::ReqwestError(
e,
"Unable to download provided RSS channel link".to_string(),
)
})?;
// TODO: Streaming read
let bytes = rss.bytes().map_err(|e| {
TrsError::ReqwestError(e, "Unable to read bytes from RSS response".to_string())
})?;
let xml_source_stream = xml::ParserConfig::new()
.ignore_invalid_encoding_declarations(true)
.create_reader(&bytes[..]);
let channel = parser::parse_rss_channel(xml_source_stream)?;
let mut stmt =
conn.prepare(
"INSERT INTO Channels (name, link, description) VALUES (?1, ?2, ?3) ON CONFLICT(link) DO UPDATE SET name=?1, description=?3")?;
stmt.execute((channel.title, link, channel.description))
.map_err(|e| TrsError::SqlError(e, "Failed to insert channel into database".to_string()))?;
Ok(())
}
fn list_channels(conn: &Connection, limit: Option<u32>) -> Result<(), TrsError> {
let mut stmt = conn.prepare("SELECT id, name, link, description FROM Channels")?;
let channels_iter = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})?;
for row in channels_iter {
let (id, name, link, description) = row?;
println!(
"ID: {}, Name: {}, Link: {}, Description: {}",
id, name, link, description
);
}
Ok(())
}
fn init_db() -> Result<Connection, TrsError> {
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("test.db");
let conn = Connection::open(db_file)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS Channels (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
link TEXT NOT NULL UNIQUE,
description TEXT
)",
(),
)
.map_err(|e| TrsError::SqlError(e, "Failed to create Channels table".to_string()))?;
Ok(conn)
}

View File

@ -8,7 +8,7 @@ pub struct RssChannel {
pub title: String, pub title: String,
pub link: String, pub link: String,
pub description: String, pub description: String,
pub articles: Vec<Article>, pub articles: Vec<RssArticle>,
} }
impl RssChannel { impl RssChannel {
@ -24,7 +24,7 @@ impl RssChannel {
fn update_channel_field(&mut self, field: &XmlTagField, value: String) -> Result<(), TrsError> { fn update_channel_field(&mut self, field: &XmlTagField, value: String) -> Result<(), TrsError> {
let last_article = self.articles.last_mut(); let last_article = self.articles.last_mut();
let no_item_error = || { let no_item_error = || {
TrsError::XmlParseError(format!( TrsError::Error(format!(
"No item found to update field <{}>", "No item found to update field <{}>",
field.hierarchical_tag field.hierarchical_tag
)) ))
@ -43,15 +43,15 @@ impl RssChannel {
} }
} }
pub struct Article { pub struct RssArticle {
pub title: String, pub title: String,
pub link: String, pub link: String,
pub date: String, pub date: String,
} }
impl Article { impl RssArticle {
fn new() -> Self { fn new() -> Self {
Article { RssArticle {
title: String::new(), title: String::new(),
link: String::new(), link: String::new(),
date: String::new(), date: String::new(),
@ -114,12 +114,12 @@ pub fn parse_rss_channel<R: Read>(
Ok(XmlEvent::StartElement { name, .. }) => match name.local_name.as_str() { Ok(XmlEvent::StartElement { name, .. }) => match name.local_name.as_str() {
"item" => { "item" => {
tag_prefix = "item > "; tag_prefix = "item > ";
channel.articles.push(Article::new()); channel.articles.push(RssArticle::new());
} }
tag => { tag => {
let None = current_field else { let None = current_field else {
let current_field_name = current_field.unwrap(); let current_field_name = current_field.unwrap();
return Err(TrsError::XmlParseError(format!( return Err(TrsError::Error(format!(
"Unexpected <{}> start tag without closing existing tag <{}>", "Unexpected <{}> start tag without closing existing tag <{}>",
tag, current_field_name.hierarchical_tag tag, current_field_name.hierarchical_tag
))); )));
@ -133,7 +133,7 @@ pub fn parse_rss_channel<R: Read>(
"item" => { "item" => {
let None = current_field else { let None = current_field else {
let current_field_name = current_field.unwrap(); let current_field_name = current_field.unwrap();
return Err(TrsError::XmlParseError(format!( return Err(TrsError::Error(format!(
"Unexpected </item> end tag without closing field {}", "Unexpected </item> end tag without closing field {}",
current_field_name.hierarchical_tag current_field_name.hierarchical_tag
))); )));
@ -145,7 +145,7 @@ pub fn parse_rss_channel<R: Read>(
if field.tag == tag { if field.tag == tag {
current_field = None; current_field = None;
} else { } else {
return Err(TrsError::XmlParseError(format!( return Err(TrsError::Error(format!(
"Unexpected </{}> end tag, expected </{}>", "Unexpected </{}> end tag, expected </{}>",
tag, field.hierarchical_tag tag, field.hierarchical_tag
))); )));
@ -164,12 +164,19 @@ pub fn parse_rss_channel<R: Read>(
} }
Err(e) => { Err(e) => {
eprintln!("Error parsing XML: {}", e); eprintln!("Error parsing XML: {}", e);
return Err(TrsError::XmlRsError(e)); return Err(TrsError::XmlRsError(
e,
"Unexpected XML parsing error".to_string(),
));
} }
_ => {} _ => {}
} }
} }
if channel.title.is_empty() || channel.link.is_empty() || channel.description.is_empty() {
return Err(TrsError::Error("This is not a valid RSS feed".to_string()));
}
Ok(channel) Ok(channel)
} }