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"
[dependencies]
argh = "0.1.13"
reqwest = { version = "0.12.20", features = ["blocking"] }
rusqlite = { version = "0.36.0", features = ["bundled"] }
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
- 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 xml::reader::Error;
#[derive(Debug)]
pub enum TrsError {
XmlParseError(String),
XmlRsError(Error),
Error(String),
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 {
@ -14,8 +26,10 @@ impl Display for TrsError {
f,
"{}",
match self {
TrsError::XmlParseError(msg) => format!("XML Parse Error: {}", msg),
TrsError::XmlRsError(err) => format!("XML Reader Error: {}", err),
TrsError::Error(msg) => format!("XML Parse Error: {}", msg),
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 xml::ParserConfig;
use rusqlite::Connection;
pub mod args;
pub mod error;
pub mod parser;
fn main() -> Result<(), TrsError> {
let bytes = include_bytes!("../sample/rss.xml");
let xml_source_stream = ParserConfig::new()
.ignore_invalid_encoding_declarations(true)
.create_reader(&bytes[..]);
let rss_channel = parser::parse_rss_channel(xml_source_stream)?;
let args = argh::from_env::<TrsArgs>();
println!("{}", rss_channel.title);
println!("{}", rss_channel.link);
println!("{}", rss_channel.description);
for article in &rss_channel.articles {
let max_title_chars = article.title.len().min(47);
let max_link_chars = article.link.len().min(67);
println!(
"| {} | {:.<50} | {:.<70} |",
article.date,
&article.title[0..max_title_chars],
&article.link[0..max_link_chars]
);
let conn = init_db()?;
match args.sub_command {
TrsSubCommand::AddChannel(add_channel_args) => {
add_channel(&conn, &add_channel_args.link)?;
}
TrsSubCommand::ListChannels(list_channel_args) => {
list_channels(&conn, list_channel_args.limit)?;
}
}
println!(
"There are {} articles in the channel.",
rss_channel.articles.len()
);
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 link: String,
pub description: String,
pub articles: Vec<Article>,
pub articles: Vec<RssArticle>,
}
impl RssChannel {
@ -24,7 +24,7 @@ impl RssChannel {
fn update_channel_field(&mut self, field: &XmlTagField, value: String) -> Result<(), TrsError> {
let last_article = self.articles.last_mut();
let no_item_error = || {
TrsError::XmlParseError(format!(
TrsError::Error(format!(
"No item found to update field <{}>",
field.hierarchical_tag
))
@ -43,15 +43,15 @@ impl RssChannel {
}
}
pub struct Article {
pub struct RssArticle {
pub title: String,
pub link: String,
pub date: String,
}
impl Article {
impl RssArticle {
fn new() -> Self {
Article {
RssArticle {
title: String::new(),
link: 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() {
"item" => {
tag_prefix = "item > ";
channel.articles.push(Article::new());
channel.articles.push(RssArticle::new());
}
tag => {
let None = current_field else {
let current_field_name = current_field.unwrap();
return Err(TrsError::XmlParseError(format!(
return Err(TrsError::Error(format!(
"Unexpected <{}> start tag without closing existing tag <{}>",
tag, current_field_name.hierarchical_tag
)));
@ -133,7 +133,7 @@ pub fn parse_rss_channel<R: Read>(
"item" => {
let None = current_field else {
let current_field_name = current_field.unwrap();
return Err(TrsError::XmlParseError(format!(
return Err(TrsError::Error(format!(
"Unexpected </item> end tag without closing field {}",
current_field_name.hierarchical_tag
)));
@ -145,7 +145,7 @@ pub fn parse_rss_channel<R: Read>(
if field.tag == tag {
current_field = None;
} else {
return Err(TrsError::XmlParseError(format!(
return Err(TrsError::Error(format!(
"Unexpected </{}> end tag, expected </{}>",
tag, field.hierarchical_tag
)));
@ -164,12 +164,19 @@ pub fn parse_rss_channel<R: Read>(
}
Err(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)
}