agro 0.1.0

A very simple feed aggregator.
use clap::Parser;
use feed_rs::parser;
use serde::{Deserialize, Serialize};
use std::{
    fs::{read_to_string, write},
    path::PathBuf,
    thread::sleep,
    time::Duration,
};
use tera::{Context, Tera};

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
    #[arg(short, long)]
    config: Option<PathBuf>,
}

#[derive(Deserialize)]
struct Config {
    name: String,
    feeds: Vec<String>,
    interval: Option<u64>,
    template: Option<String>,
    output: Option<String>,
}

#[derive(Serialize)]
struct Entry {
    title: String,
    url: String,
    feed: String,
    published: String,
    summary: Option<String>,
    authors: String,
}

#[derive(Serialize)]
struct Data {
    name: String,
    entries: Vec<Entry>,
}

fn build(config: &Config) -> anyhow::Result<String> {
    let mut entries: Vec<Entry> = vec![];

    for url in &config.feeds {
        let text = reqwest::blocking::get(url)?.text()?;
        let feed = parser::parse(text.as_bytes()).unwrap();

        let feed_title = {
            if feed.title.is_some() {
                feed.title.unwrap().content
            } else {
                url.to_string()
            }
        };

        for entry in feed.entries {
            let mut index = 0;

            if entry.title.is_none() || entry.published.is_none() {
                continue;
            }

            for e in &entries {
                if e.published < entry.published.unwrap_or_default().to_rfc3339() {
                    break;
                } else {
                    index += 1;
                }
            }

            let summary = {
                if entry.summary.is_some() {
                    Some(entry.summary.unwrap().content)
                } else {
                    None
                }
            };

            let mut authors = vec![];
            for author in entry.authors {
                authors.push(author.name);
            }

            entries.insert(
                index,
                Entry {
                    title: entry.title.unwrap().content,
                    url: entry.links[0].clone().href,
                    feed: feed_title.clone(),
                    published: entry
                        .published
                        .unwrap()
                        .to_rfc3339()
                        .split_once('+')
                        .unwrap()
                        .0
                        .to_string(),
                    authors: authors.join(", "),
                    summary,
                },
            );
        }
    }

    let data = Data {
        name: config.name.clone(),
        entries,
    };

    let template: String;
    if let Some(f) = &config.template {
        template = read_to_string(f).expect("Cannot read template file :(");
    } else {
        template = include_str!("default.html").to_string();
    }

    Ok(Tera::one_off(
        &template,
        &Context::from_serialize(data)?,
        true,
    )?)
}

fn main() {
    let args = Cli::parse();

    let config: Config = toml::from_str(
        &read_to_string(args.config.unwrap_or(PathBuf::from("agro.toml")))
            .expect("Cannot read config file :("),
    )
    .expect("Invalid config!");

    let _ = sd_notify::notify(true, &[sd_notify::NotifyState::Ready]);

    loop {
        println!("Fetching...");
        let html = build(&config);
        if let Err(err) = html {
            println!("{}", err);
            continue;
        }
        write(
            config.output.as_ref().unwrap_or(&"out.html".to_string()),
            html.unwrap(),
        )
        .expect("Could not write to output");
        sleep(Duration::from_secs(60 * config.interval.unwrap_or(60)));
    }
}