summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/dirs.rs17
-rw-r--r--src/error.rs66
-rw-r--r--src/feed.rs148
-rw-r--r--src/lib.rs5
-rw-r--r--src/main.rs38
-rw-r--r--src/manager.rs107
-rw-r--r--src/status.rs42
7 files changed, 421 insertions, 2 deletions
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 @@
1use std::{env, path::PathBuf};
2
3pub fn store_path() -> Option<PathBuf> {
4 cache_dir().map(|c| c.join("syn"))
5}
6
7fn cache_dir() -> Option<PathBuf> {
8 env::var_os("XDG_CACHE_HOME")
9 .map(PathBuf::from)
10 .or_else(|| home_dir().map(|h| h.join(".cache")))
11}
12
13fn home_dir() -> Option<PathBuf> {
14 env::var_os("HOME")
15 .and_then(|h| if h.is_empty() { None } else { Some(h) })
16 .map(PathBuf::from)
17}
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 @@
1use feed_rs::parser;
2use thiserror::Error;
3use url::Url;
4
5#[derive(Debug, Error)]
6pub enum Error {
7 #[error("error pulling feed `{0}`: {1}")]
8 Pull(Url, PullError),
9
10 #[error("error parsing entry: {0}")]
11 Entry(#[from] EntryError),
12
13 #[error("error adding field: {0}")]
14 Add(#[from] AddError),
15}
16
17#[derive(Debug, Error)]
18pub enum PullError {
19 #[error("failed to make request")]
20 Request(#[from] reqwest::Error),
21
22 #[error("invalid rss feed: {0}")]
23 Parse(#[from] parser::ParseFeedError),
24
25 #[error("failed to pull feed title")]
26 TitleUpdate,
27
28 #[error("failed to pull feed link")]
29 LinkUpdate,
30}
31
32#[derive(Debug, Error)]
33pub enum EntryError {
34 #[error("missing title")]
35 MissingTitle,
36
37 #[error("missing link")]
38 MissingLink,
39
40 #[error("invalid link")]
41 InvalidLink,
42
43 #[error("missing publish-date")]
44 MissingPubDate,
45}
46
47#[derive(Debug, Error)]
48pub enum AddError {
49 #[error("invalid url: {0}")]
50 InvalidUrl(String),
51
52 #[error("feed is already present")]
53 DuplicateLink,
54}
55
56#[derive(Debug, Error)]
57pub enum IOError {
58 #[error("unable to create or find store path")]
59 MissingStorePath,
60
61 #[error("file error:")]
62 FileIO(#[from] std::io::Error),
63
64 #[error("yaml ser/de error")]
65 Serde(#[from] serde_yaml::Error),
66}
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 @@
1use std::fmt;
2
3use crate::{
4 error::{EntryError, PullError},
5 status::PullStatus,
6};
7
8use chrono::prelude::*;
9use feed_rs::{
10 model::{Entry as ChannelEntry, Feed as Channel},
11 parser,
12};
13use serde::{Deserialize, Serialize};
14use url::Url;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Feed {
18 // channel url
19 pub link: Url,
20
21 // channel data
22 entries: Vec<Entry>,
23
24 // channel meta
25 title: String,
26 html_link: String,
27}
28
29impl Feed {
30 pub fn new(link: Url) -> Self {
31 Self {
32 link,
33 entries: Vec::new(),
34 title: String::new(),
35 html_link: String::new(),
36 }
37 }
38
39 pub fn total_count(&self) -> usize {
40 self.entries.len()
41 }
42
43 pub fn unread_count(&self) -> usize {
44 self.entries.iter().filter(|e| e.unread).count()
45 }
46
47 fn update_title(&mut self, channel: &Channel) -> bool {
48 if let Some(t) = channel.title.as_ref() {
49 self.title = t.content.clone();
50 return true;
51 }
52 false
53 }
54
55 fn update_html_link(&mut self, channel: &Channel) -> bool {
56 // update html link
57 if let Some(l) = channel.links.first() {
58 self.html_link = l.href.clone();
59 return true;
60 }
61 false
62 }
63
64 pub fn entries(&self) -> &[Entry] {
65 self.entries.as_slice()
66 }
67
68 pub async fn pull(&mut self) -> Result<PullStatus, PullError> {
69 let content = reqwest::get(self.link.clone()).await?.bytes().await?;
70 let channel = parser::parse(&content[..])?;
71
72 // update title
73 if !self.update_title(&channel) {
74 return Err(PullError::TitleUpdate);
75 }
76
77 // update html link
78 if !self.update_html_link(&channel) {
79 return Err(PullError::LinkUpdate);
80 };
81
82 // fetch new entries
83 let (entries, errors): (Vec<_>, Vec<_>) = channel
84 .entries
85 .iter()
86 .map(Entry::try_from)
87 .partition(Result::is_ok);
88
89 // pull status
90 let count = entries.len().saturating_sub(self.total_count());
91 let errors = errors.into_iter().map(Result::unwrap_err).collect();
92
93 let pull_status = PullStatus::new(count, errors);
94
95 // update entries
96 self.entries = entries.into_iter().map(Result::unwrap).collect();
97
98 Ok(pull_status)
99 }
100}
101
102impl fmt::Display for Feed {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 write!(f, "{}", self.title)
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct Entry {
110 pub title: String,
111 pub link: Url,
112 pub published: DateTime<Utc>,
113 pub unread: bool,
114}
115
116impl TryFrom<&ChannelEntry> for Entry {
117 type Error = EntryError;
118 fn try_from(e: &ChannelEntry) -> Result<Self, Self::Error> {
119 let title = e
120 .title
121 .as_ref()
122 .map(|t| t.content.clone())
123 .ok_or(EntryError::MissingTitle)?;
124 let raw_link = e
125 .links
126 .first()
127 .map(|l| l.href.clone())
128 .ok_or(EntryError::MissingLink)?;
129 let link = Url::parse(&raw_link).map_err(|_| EntryError::InvalidLink)?;
130 let published = e
131 .published
132 .or(e.updated)
133 .ok_or(EntryError::MissingPubDate)?;
134
135 Ok(Self {
136 title,
137 link,
138 published,
139 unread: true,
140 })
141 }
142}
143
144impl fmt::Display for Entry {
145 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146 write!(f, "{} {} {}", self.link, self.title, self.published)
147 }
148}
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 @@
1mod dirs;
2pub mod error;
3pub mod feed;
4pub mod manager;
5pub 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 @@
1fn main() { 1use syn::manager::Manager;
2 println!("Hello, world!"); 2
3#[tokio::main(flavor = "current_thread")]
4async fn main() {
5 let mut manager = Manager::default();
6
7 let feeds = vec![
8 "https://peppe.rs/index.xml",
9 "https://jvns.ca/atom.xml",
10 // "https://www.youtube.com/feeds/videos.xml?channel_id=UCuTaETsuCOkJ0H_GAztWt0Q",
11 ];
12
13 for f in feeds {
14 match manager.add_feed(f).await {
15 Ok(s) => println!("{s}"),
16 Err(e) => println!("{e}"),
17 }
18 }
19
20 for entry in manager.list_entries() {
21 println!("{entry}");
22 }
23
24 // let mut feed = Feed::new(url);
25
26 // feed.resolve().await.unwrap();
27
28 // let last_read = DateTime::parse_from_rfc2822("Mon, 16 Mar 2020 18:30:00 +0000")
29 // .unwrap()
30 // .with_timezone(&Utc);
31
32 // feed.last_read = last_read;
33
34 // for i in feed.unread().unwrap() {
35 // println!("{}", i.title.as_ref().unwrap().content)
36 // }
3} 37}
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 @@
1use crate::{
2 error::{AddError, Error, IOError, PullError},
3 feed::{Entry, Feed},
4 status::{PullStatus, StoreStatus},
5};
6
7use chrono::prelude::*;
8use url::Url;
9
10#[derive(Default)]
11pub struct Manager {
12 feeds: Vec<Feed>,
13}
14
15impl Manager {
16 pub async fn add_feed(&mut self, url: &str) -> Result<PullStatus, Error> {
17 let link = Url::parse(&url).map_err(|e| AddError::InvalidUrl(e.to_string()))?;
18
19 // check if this feed is already present
20 if self.feeds.iter().any(|f| f.link == link) {
21 return Err(AddError::DuplicateLink.into());
22 }
23
24 // construct a new feed
25 let mut feed = Feed::new(link.clone());
26
27 let status = feed
28 .pull()
29 .await
30 .map_err(|pull_err| Error::Pull(link, pull_err))?;
31
32 // add new feed
33 self.feeds.push(feed);
34
35 Ok(status)
36 }
37
38 pub async fn pull(&mut self) -> Vec<Result<PullStatus, PullError>> {
39 futures::future::join_all(self.feeds.iter_mut().map(Feed::pull)).await
40 }
41
42 pub fn list_entries(&self) -> impl Iterator<Item = &Entry> {
43 EntryIterator {
44 all_entries: self.feeds.iter().map(Feed::entries).collect(),
45 }
46 }
47
48 pub fn list_feeds(&self) -> impl Iterator<Item = &Feed> {
49 self.feeds.iter()
50 }
51
52 pub async fn store(&self) -> Result<StoreStatus, IOError> {
53 let path = crate::dirs::store_path().ok_or(IOError::MissingStorePath)?;
54 let content = serde_yaml::to_string(&self.feeds)?;
55 std::fs::write(path, content)?;
56
57 Ok(StoreStatus::new(self.feeds.len()))
58 }
59
60 pub async fn load() -> Result<Self, IOError> {
61 let path = crate::dirs::store_path().ok_or(IOError::MissingStorePath)?;
62 let content = std::fs::read_to_string(path)?;
63 let feeds = serde_yaml::from_str(&content)?;
64
65 Ok(Self { feeds })
66 }
67}
68
69struct EntryIterator<'e> {
70 all_entries: Vec<&'e [Entry]>,
71}
72
73impl<'e> Iterator for EntryIterator<'e> {
74 type Item = &'e Entry;
75
76 fn next(&mut self) -> Option<Self::Item> {
77 let mut min_index = None;
78 let mut last_date = DateTime::<Utc>::MIN_UTC;
79 for (idx, latest_entry) in self
80 .all_entries
81 .iter()
82 .map(|entries| entries.first())
83 .enumerate()
84 {
85 if let Some(entry) = latest_entry {
86 if last_date < entry.published {
87 last_date = entry.published;
88 min_index = Some(idx);
89 }
90 }
91 }
92
93 match min_index {
94 Some(idx) => {
95 let entries = self.all_entries.get_mut(idx).unwrap();
96 let e = &entries[0];
97 if entries.len() > 1 {
98 *entries = &entries[1..];
99 } else {
100 self.all_entries.remove(idx);
101 }
102 Some(e)
103 }
104 None => None,
105 }
106 }
107}
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 @@
1use crate::error::EntryError;
2use std::fmt;
3
4#[derive(Debug)]
5pub struct PullStatus {
6 count: usize,
7 errors: Vec<EntryError>,
8}
9
10impl PullStatus {
11 pub fn new(count: usize, errors: Vec<EntryError>) -> Self {
12 Self { count, errors }
13 }
14}
15
16impl fmt::Display for PullStatus {
17 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18 write!(
19 f,
20 "pulled {} entries with {} errors",
21 self.count,
22 self.errors.len()
23 )
24 }
25}
26
27#[derive(Debug)]
28pub struct StoreStatus {
29 count: usize,
30}
31
32impl StoreStatus {
33 pub fn new(count: usize) -> Self {
34 Self { count }
35 }
36}
37
38impl fmt::Display for StoreStatus {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 write!(f, "stored {} feeds", self.count,)
41 }
42}