1use std::collections::HashSet;
2use std::fs::File;
3use std::io::{Read, Write};
4use std::path::PathBuf;
5
6use feed_rs::model::Feed;
7use log::{debug, warn};
8use miette::{IntoDiagnostic, WrapErr};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
14pub(crate) struct CachedEntry {
15 pub(crate) id: String,
17 pub(crate) title: String,
19}
20
21impl CachedEntry {
22 pub(crate) fn from_feed_entry(entry: &feed_rs::model::Entry) -> Option<Self> {
24 let id = if !entry.id.is_empty() {
26 entry.id.clone()
27 } else if let Some(title) = &entry.title {
28 title.content.clone()
29 } else {
30 format!("entry_{:p}", entry)
31 };
32
33 let title = entry
34 .title
35 .as_ref()
36 .map(|t| t.content.clone())
37 .unwrap_or_else(|| "Untitled".to_string());
38
39 Some(CachedEntry { id, title })
40 }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub(crate) struct CachedFeed {
46 pub(crate) url: String,
48 pub(crate) title: String,
50 pub(crate) entries: HashSet<CachedEntry>,
52 #[serde(skip)]
54 pub(crate) server_name: String,
55}
56
57impl CachedFeed {
58 pub(crate) fn from_feed(url: &str, feed: &Feed, server_name: &str) -> Self {
60 let title = feed
61 .title
62 .as_ref()
63 .map(|t| t.content.clone())
64 .unwrap_or_else(|| "Untitled".to_string());
65
66 let entries = feed
67 .entries
68 .iter()
69 .filter_map(CachedEntry::from_feed_entry)
70 .collect();
71
72 CachedFeed {
73 url: url.to_string(),
74 title,
75 entries,
76 server_name: server_name.to_string(),
77 }
78 }
79
80 fn get_cache_path(feed_url: &str, server_name: &str) -> miette::Result<PathBuf> {
82 let base_path = if let Ok(base_path) = std::env::var("IRSSC_DATA_DIR") {
83 let p = PathBuf::from(base_path);
84 if !p.is_dir() {
85 warn!("IRSSC_DATA_DIR was set to a non-existing directory!");
86 }
87 p
88 } else {
89 PathBuf::from(".")
90 };
91
92 let hash = format!("{:x}", fxhash::hash64(feed_url));
94 let filename = format!("feed_cache_{}.toml", hash);
95 Ok(base_path.join("storage").join(server_name).join(filename))
96 }
97
98 pub(crate) async fn load(
100 feed_url: &str,
101 server_name: &str,
102 ) -> miette::Result<Option<CachedFeed>> {
103 let cache_path = Self::get_cache_path(feed_url, server_name)?;
104
105 if !cache_path.exists() {
106 return Ok(None);
107 }
108
109 let mut f = File::open(&cache_path).into_diagnostic().wrap_err(format!(
110 "Could not open feed cache at path {}",
111 cache_path.display()
112 ))?;
113
114 let fsize = f.metadata().map(|m| m.len()).unwrap_or(1024);
115 let mut readbuffer = String::with_capacity(fsize as usize);
116 f.read_to_string(&mut readbuffer).into_diagnostic()?;
117
118 let mut cached_feed: CachedFeed =
119 toml::from_str(&readbuffer)
120 .into_diagnostic()
121 .wrap_err(format!(
122 "Could not parse feed cache from {}",
123 cache_path.display()
124 ))?;
125 cached_feed.server_name = server_name.to_string();
126
127 Ok(Some(cached_feed))
128 }
129
130 pub(crate) async fn save(&self) -> miette::Result<()> {
132 let cache_path = Self::get_cache_path(&self.url, &self.server_name)?;
133
134 if let Some(parent) = cache_path.parent() {
136 tokio::fs::create_dir_all(parent)
137 .await
138 .into_diagnostic()
139 .wrap_err(format!(
140 "Could not create cache directory {}",
141 parent.display()
142 ))?;
143 }
144
145 let mut f = File::create(&cache_path)
146 .into_diagnostic()
147 .wrap_err(format!(
148 "Could not create feed cache file at {}",
149 cache_path.display()
150 ))?;
151
152 let s = toml::to_string_pretty(self).into_diagnostic()?;
153 f.write_all(s.as_bytes()).into_diagnostic()?;
154
155 debug!(
156 "Saved feed cache for {} to {}",
157 self.url,
158 cache_path.display()
159 );
160 Ok(())
161 }
162
163 pub(crate) fn get_new_entries_from_feed<'a>(
165 &self,
166 new_feed: &'a Feed,
167 ) -> Vec<&'a feed_rs::model::Entry> {
168 new_feed
169 .entries
170 .iter()
171 .filter(|entry| {
172 if let Some(cached) = CachedEntry::from_feed_entry(entry) {
173 !self.entries.contains(&cached)
174 } else {
175 false
176 }
177 })
178 .collect()
179 }
180}