use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use etcetera::BaseStrategy;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::feed_source::RawFeed;
#[derive(Clone, Debug)]
pub struct CacheStore {
data_dir: PathBuf,
}
impl CacheStore {
pub fn new(data_dir: impl Into<PathBuf>) -> Self {
Self {
data_dir: data_dir.into(),
}
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
fn feed_path(&self, url: &str) -> PathBuf {
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
let hash = format!("{:x}", hasher.finalize());
self.data_dir.join(format!("{}.json", &hash[..16]))
}
pub fn load_http_metadata(&self, url: &str) -> HttpMetadata {
let path = self.feed_path(url);
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => {
return HttpMetadata {
etag: None,
last_modified: None,
}
}
};
match serde_json::from_str::<CachedFeed>(&content) {
Ok(cached) => HttpMetadata {
etag: cached.etag,
last_modified: cached.last_modified,
},
Err(_) => HttpMetadata {
etag: None,
last_modified: None,
},
}
}
pub fn load_feed(&self, url: &str) -> Option<CachedFeed> {
let path = self.feed_path(url);
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
pub fn save_feed(
&self,
url: &str,
feed: &RawFeed,
etag: Option<&str>,
last_modified: Option<&str>,
) -> Result<()> {
fs::create_dir_all(&self.data_dir).with_context(|| {
format!(
"Failed to create data directory: {}",
self.data_dir.display()
)
})?;
let path = self.feed_path(url);
let mut articles: Vec<CachedArticle> = if let Ok(content) = fs::read_to_string(&path) {
if let Ok(existing) = serde_json::from_str::<CachedFeed>(&content) {
existing.articles
} else {
Vec::new()
}
} else {
Vec::new()
};
let fetched_urls: HashSet<&str> = feed.entries.iter().map(|e| e.url.as_str()).collect();
let now = Utc::now();
for article in &mut articles {
if fetched_urls.contains(article.url.as_str()) {
article.last_seen = now;
}
}
let existing_urls: HashSet<String> = articles.iter().map(|e| e.url.clone()).collect();
for entry in &feed.entries {
if !entry.url.is_empty() && !existing_urls.contains(&entry.url) {
articles.push(CachedArticle {
title: entry.title.clone(),
url: entry.url.clone(),
published: entry.published,
read: false,
rss_content: entry.rss_content.clone(),
last_seen: now,
});
}
}
articles.sort_by(|a, b| b.published.cmp(&a.published));
let cached = CachedFeed {
feed_url: url.to_string(),
feed_title: feed.title.clone(),
last_fetched: Utc::now(),
etag: etag.map(String::from),
last_modified: last_modified.map(String::from),
articles,
};
let json = serde_json::to_string(&cached).context("Failed to serialize cache")?;
fs::write(&path, json)
.with_context(|| format!("Failed to write cache: {}", path.display()))?;
Ok(())
}
pub fn purge_old_entries(&self, retention_days: i32) -> Result<()> {
if retention_days <= 0 {
return Ok(()); }
let cutoff = Utc::now() - chrono::Duration::days(retention_days as i64);
if !self.data_dir.exists() {
return Ok(());
}
for entry in fs::read_dir(&self.data_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let content = fs::read_to_string(&path)?;
if let Ok(mut cached) = serde_json::from_str::<CachedFeed>(&content) {
let before = cached.articles.len();
cached.articles.retain(|e| e.last_seen > cutoff);
if cached.articles.len() != before {
if cached.articles.is_empty() {
fs::remove_file(&path)?;
} else {
let json = serde_json::to_string(&cached)?;
fs::write(&path, json)?;
}
}
}
}
Ok(())
}
pub fn set_read_status(&self, feed_url: &str, article_url: &str, read: bool) -> Result<()> {
if !self.data_dir.exists() {
return Ok(());
}
let path = self.feed_path(feed_url);
if !path.exists() {
return Ok(());
}
let content = fs::read_to_string(&path)?;
if let Ok(mut cached) = serde_json::from_str::<CachedFeed>(&content) {
let mut changed = false;
for e in &mut cached.articles {
if e.url == article_url && e.read != read {
e.read = read;
changed = true;
}
}
if changed {
let json = serde_json::to_string(&cached)?;
fs::write(&path, json)?;
}
}
Ok(())
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CachedArticle {
pub title: String,
pub url: String,
pub published: Option<DateTime<Utc>>,
#[serde(default)]
pub read: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rss_content: Option<String>,
#[serde(default = "Utc::now")]
pub last_seen: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CachedFeed {
pub feed_url: String,
pub feed_title: String,
pub last_fetched: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_modified: Option<String>,
pub articles: Vec<CachedArticle>,
}
pub struct HttpMetadata {
pub etag: Option<String>,
pub last_modified: Option<String>,
}
pub fn data_dir(config_path: Option<&str>) -> Result<PathBuf> {
if let Some(p) = config_path {
Ok(PathBuf::from(p))
} else {
let strategy =
etcetera::choose_base_strategy().context("Could not determine home directory")?;
Ok(strategy.data_dir().join("feed"))
}
}