From f13e8be9f32c5ca7f70dc809e3d43144f8e31396 Mon Sep 17 00:00:00 2001 From: Akshay Date: Sun, 7 May 2023 17:11:48 +0530 Subject: progress --- src/dirs.rs | 17 +++++++ src/error.rs | 66 +++++++++++++++++++++++++ src/feed.rs | 148 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 5 ++ src/main.rs | 38 ++++++++++++++- src/manager.rs | 107 +++++++++++++++++++++++++++++++++++++++++ src/status.rs | 42 ++++++++++++++++ 7 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 src/dirs.rs create mode 100644 src/error.rs create mode 100644 src/feed.rs create mode 100644 src/lib.rs create mode 100644 src/manager.rs create mode 100644 src/status.rs (limited to 'src') diff --git a/src/dirs.rs b/src/dirs.rs new file mode 100644 index 0000000..de7c156 --- /dev/null +++ b/src/dirs.rs @@ -0,0 +1,17 @@ +use std::{env, path::PathBuf}; + +pub fn store_path() -> Option { + cache_dir().map(|c| c.join("syn")) +} + +fn cache_dir() -> Option { + env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .or_else(|| home_dir().map(|h| h.join(".cache"))) +} + +fn home_dir() -> Option { + env::var_os("HOME") + .and_then(|h| if h.is_empty() { None } else { Some(h) }) + .map(PathBuf::from) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..8a2059e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,66 @@ +use feed_rs::parser; +use thiserror::Error; +use url::Url; + +#[derive(Debug, Error)] +pub enum Error { + #[error("error pulling feed `{0}`: {1}")] + Pull(Url, PullError), + + #[error("error parsing entry: {0}")] + Entry(#[from] EntryError), + + #[error("error adding field: {0}")] + Add(#[from] AddError), +} + +#[derive(Debug, Error)] +pub enum PullError { + #[error("failed to make request")] + Request(#[from] reqwest::Error), + + #[error("invalid rss feed: {0}")] + Parse(#[from] parser::ParseFeedError), + + #[error("failed to pull feed title")] + TitleUpdate, + + #[error("failed to pull feed link")] + LinkUpdate, +} + +#[derive(Debug, Error)] +pub enum EntryError { + #[error("missing title")] + MissingTitle, + + #[error("missing link")] + MissingLink, + + #[error("invalid link")] + InvalidLink, + + #[error("missing publish-date")] + MissingPubDate, +} + +#[derive(Debug, Error)] +pub enum AddError { + #[error("invalid url: {0}")] + InvalidUrl(String), + + #[error("feed is already present")] + DuplicateLink, +} + +#[derive(Debug, Error)] +pub enum IOError { + #[error("unable to create or find store path")] + MissingStorePath, + + #[error("file error:")] + FileIO(#[from] std::io::Error), + + #[error("yaml ser/de error")] + Serde(#[from] serde_yaml::Error), +} diff --git a/src/feed.rs b/src/feed.rs new file mode 100644 index 0000000..3926fd4 --- /dev/null +++ b/src/feed.rs @@ -0,0 +1,148 @@ +use std::fmt; + +use crate::{ + error::{EntryError, PullError}, + status::PullStatus, +}; + +use chrono::prelude::*; +use feed_rs::{ + model::{Entry as ChannelEntry, Feed as Channel}, + parser, +}; +use serde::{Deserialize, Serialize}; +use url::Url; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Feed { + // channel url + pub link: Url, + + // channel data + entries: Vec, + + // channel meta + title: String, + html_link: String, +} + +impl Feed { + pub fn new(link: Url) -> Self { + Self { + link, + entries: Vec::new(), + title: String::new(), + html_link: String::new(), + } + } + + pub fn total_count(&self) -> usize { + self.entries.len() + } + + pub fn unread_count(&self) -> usize { + self.entries.iter().filter(|e| e.unread).count() + } + + fn update_title(&mut self, channel: &Channel) -> bool { + if let Some(t) = channel.title.as_ref() { + self.title = t.content.clone(); + return true; + } + false + } + + fn update_html_link(&mut self, channel: &Channel) -> bool { + // update html link + if let Some(l) = channel.links.first() { + self.html_link = l.href.clone(); + return true; + } + false + } + + pub fn entries(&self) -> &[Entry] { + self.entries.as_slice() + } + + pub async fn pull(&mut self) -> Result { + let content = reqwest::get(self.link.clone()).await?.bytes().await?; + let channel = parser::parse(&content[..])?; + + // update title + if !self.update_title(&channel) { + return Err(PullError::TitleUpdate); + } + + // update html link + if !self.update_html_link(&channel) { + return Err(PullError::LinkUpdate); + }; + + // fetch new entries + let (entries, errors): (Vec<_>, Vec<_>) = channel + .entries + .iter() + .map(Entry::try_from) + .partition(Result::is_ok); + + // pull status + let count = entries.len().saturating_sub(self.total_count()); + let errors = errors.into_iter().map(Result::unwrap_err).collect(); + + let pull_status = PullStatus::new(count, errors); + + // update entries + self.entries = entries.into_iter().map(Result::unwrap).collect(); + + Ok(pull_status) + } +} + +impl fmt::Display for Feed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.title) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Entry { + pub title: String, + pub link: Url, + pub published: DateTime, + pub unread: bool, +} + +impl TryFrom<&ChannelEntry> for Entry { + type Error = EntryError; + fn try_from(e: &ChannelEntry) -> Result { + let title = e + .title + .as_ref() + .map(|t| t.content.clone()) + .ok_or(EntryError::MissingTitle)?; + let raw_link = e + .links + .first() + .map(|l| l.href.clone()) + .ok_or(EntryError::MissingLink)?; + let link = Url::parse(&raw_link).map_err(|_| EntryError::InvalidLink)?; + let published = e + .published + .or(e.updated) + .ok_or(EntryError::MissingPubDate)?; + + Ok(Self { + title, + link, + published, + unread: true, + }) + } +} + +impl fmt::Display for Entry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {} {}", self.link, self.title, self.published) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b66a7b4 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +mod dirs; +pub mod error; +pub mod feed; +pub mod manager; +pub mod status; diff --git a/src/main.rs b/src/main.rs index e7a11a9..fccc7fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,37 @@ -fn main() { - println!("Hello, world!"); +use syn::manager::Manager; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let mut manager = Manager::default(); + + let feeds = vec![ + "https://peppe.rs/index.xml", + "https://jvns.ca/atom.xml", + // "https://www.youtube.com/feeds/videos.xml?channel_id=UCuTaETsuCOkJ0H_GAztWt0Q", + ]; + + for f in feeds { + match manager.add_feed(f).await { + Ok(s) => println!("{s}"), + Err(e) => println!("{e}"), + } + } + + for entry in manager.list_entries() { + println!("{entry}"); + } + + // let mut feed = Feed::new(url); + + // feed.resolve().await.unwrap(); + + // let last_read = DateTime::parse_from_rfc2822("Mon, 16 Mar 2020 18:30:00 +0000") + // .unwrap() + // .with_timezone(&Utc); + + // feed.last_read = last_read; + + // for i in feed.unread().unwrap() { + // println!("{}", i.title.as_ref().unwrap().content) + // } } diff --git a/src/manager.rs b/src/manager.rs new file mode 100644 index 0000000..839c8dc --- /dev/null +++ b/src/manager.rs @@ -0,0 +1,107 @@ +use crate::{ + error::{AddError, Error, IOError, PullError}, + feed::{Entry, Feed}, + status::{PullStatus, StoreStatus}, +}; + +use chrono::prelude::*; +use url::Url; + +#[derive(Default)] +pub struct Manager { + feeds: Vec, +} + +impl Manager { + pub async fn add_feed(&mut self, url: &str) -> Result { + let link = Url::parse(&url).map_err(|e| AddError::InvalidUrl(e.to_string()))?; + + // check if this feed is already present + if self.feeds.iter().any(|f| f.link == link) { + return Err(AddError::DuplicateLink.into()); + } + + // construct a new feed + let mut feed = Feed::new(link.clone()); + + let status = feed + .pull() + .await + .map_err(|pull_err| Error::Pull(link, pull_err))?; + + // add new feed + self.feeds.push(feed); + + Ok(status) + } + + pub async fn pull(&mut self) -> Vec> { + futures::future::join_all(self.feeds.iter_mut().map(Feed::pull)).await + } + + pub fn list_entries(&self) -> impl Iterator { + EntryIterator { + all_entries: self.feeds.iter().map(Feed::entries).collect(), + } + } + + pub fn list_feeds(&self) -> impl Iterator { + self.feeds.iter() + } + + pub async fn store(&self) -> Result { + let path = crate::dirs::store_path().ok_or(IOError::MissingStorePath)?; + let content = serde_yaml::to_string(&self.feeds)?; + std::fs::write(path, content)?; + + Ok(StoreStatus::new(self.feeds.len())) + } + + pub async fn load() -> Result { + let path = crate::dirs::store_path().ok_or(IOError::MissingStorePath)?; + let content = std::fs::read_to_string(path)?; + let feeds = serde_yaml::from_str(&content)?; + + Ok(Self { feeds }) + } +} + +struct EntryIterator<'e> { + all_entries: Vec<&'e [Entry]>, +} + +impl<'e> Iterator for EntryIterator<'e> { + type Item = &'e Entry; + + fn next(&mut self) -> Option { + let mut min_index = None; + let mut last_date = DateTime::::MIN_UTC; + for (idx, latest_entry) in self + .all_entries + .iter() + .map(|entries| entries.first()) + .enumerate() + { + if let Some(entry) = latest_entry { + if last_date < entry.published { + last_date = entry.published; + min_index = Some(idx); + } + } + } + + match min_index { + Some(idx) => { + let entries = self.all_entries.get_mut(idx).unwrap(); + let e = &entries[0]; + if entries.len() > 1 { + *entries = &entries[1..]; + } else { + self.all_entries.remove(idx); + } + Some(e) + } + None => None, + } + } +} diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..6874e88 --- /dev/null +++ b/src/status.rs @@ -0,0 +1,42 @@ +use crate::error::EntryError; +use std::fmt; + +#[derive(Debug)] +pub struct PullStatus { + count: usize, + errors: Vec, +} + +impl PullStatus { + pub fn new(count: usize, errors: Vec) -> Self { + Self { count, errors } + } +} + +impl fmt::Display for PullStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "pulled {} entries with {} errors", + self.count, + self.errors.len() + ) + } +} + +#[derive(Debug)] +pub struct StoreStatus { + count: usize, +} + +impl StoreStatus { + pub fn new(count: usize) -> Self { + Self { count } + } +} + +impl fmt::Display for StoreStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "stored {} feeds", self.count,) + } +} -- cgit v1.2.3