Add and List channels
This commit is contained in:
parent
ddfdaf3347
commit
f36ed6096b
1677
Cargo.lock
generated
1677
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
@ -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
41
src/args.rs
Normal 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))
|
||||
}
|
||||
}
|
26
src/error.rs
26
src/error.rs
@ -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),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
120
src/main.rs
120
src/main.rs
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user