rssume 0.2.3

RSS middleware with AI-powered translation and summarization
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Article {
    pub id: String,
    pub feed_name: String,
    pub title: String,
    pub original_title: String,
    pub link: String,
    pub content: String,
    pub original_content: String,
    pub summary: Option<String>,
    pub translated: bool,
    pub source_lang: Option<String>,
    pub published_at: String,
    pub processed_at: String,
    #[serde(default)]
    pub author: Option<String>,
    #[serde(default)]
    pub categories: Vec<String>,
    #[serde(default)]
    pub translated_title: bool,
    #[serde(default)]
    pub translation_model: Option<String>,
    #[serde(default)]
    pub translation_tokens: Option<u32>,
    #[serde(default)]
    pub enclosure: Option<Enclosure>,
    #[serde(skip)]
    pub published_at_rfc2822: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Enclosure {
    pub url: String,
    pub content_type: Option<String>,
    pub length: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedData {
    pub articles: Vec<Article>,
}

impl FeedData {
    pub fn load(feed_name: &str) -> Result<Self, crate::error::AppError> {
        let path = data_path(feed_name);
        if !path.exists() {
            return Ok(FeedData { articles: vec![] });
        }
        let content = std::fs::read_to_string(&path)?;
        let mut data: FeedData = toml::from_str(&content)
            .map_err(|e| crate::error::AppError::Storage(format!("parse TOML: {}", e)))?;
        for a in &mut data.articles {
            a.published_at_rfc2822 = chrono::DateTime::parse_from_rfc2822(&a.published_at)
                .ok()
                .map(|dt| dt.to_rfc2822());
        }
        Ok(data)
    }
    pub fn save(&self, feed_name: &str) -> Result<(), crate::error::AppError> {
        let dir = super::config::Config::data_dir();
        std::fs::create_dir_all(&dir)?;
        let c = toml::to_string_pretty(self)
            .map_err(|e| crate::error::AppError::Storage(format!("serialize: {}", e)))?;
        std::fs::write(data_path(feed_name), c)?;
        Ok(())
    }
    pub fn contains_link(&self, link: &str) -> bool {
        self.articles.iter().any(|a| a.link == link)
    }
    pub fn article_count(&self) -> usize {
        self.articles.len()
    }
    pub fn translated_count(&self) -> usize {
        self.articles.iter().filter(|a| a.translated).count()
    }
    pub fn with_summary_count(&self) -> usize {
        self.articles.iter().filter(|a| a.summary.is_some()).count()
    }
}

fn data_path(feed_name: &str) -> PathBuf {
    super::config::Config::data_dir().join(format!("{}.toml", feed_name))
}

pub fn all_feed_stats() -> Result<Vec<FeedStats>, crate::error::AppError> {
    let dir = super::config::Config::data_dir();
    if !dir.exists() {
        return Ok(vec![]);
    }
    let mut stats = vec![];
    for e in std::fs::read_dir(&dir)? {
        let e = e?;
        let p = e.path();
        if p.extension().is_some_and(|x| x == "toml")
            && p.file_name().is_some_and(|n| n != "token_usage.toml")
        {
            let name = p
                .file_stem()
                .unwrap_or_default()
                .to_string_lossy()
                .to_string();
            let d = FeedData::load(&name)?;
            stats.push(FeedStats {
                feed_name: name,
                article_count: d.article_count(),
                translated_count: d.translated_count(),
                with_summary_count: d.with_summary_count(),
            });
        }
    }
    Ok(stats)
}

#[derive(Debug, Clone, Serialize)]
pub struct FeedStats {
    pub feed_name: String,
    pub article_count: usize,
    pub translated_count: usize,
    pub with_summary_count: usize,
}