use std::{
cmp::Reverse,
io::{self, Write},
path::PathBuf,
};
use atom_syndication::{
ContentBuilder, Entry, FeedBuilder, Generator, Link, Person, TextBuilder,
};
use chrono::{DurationRound, TimeDelta, Utc};
use crate::{
Blog, Metadata, Mime,
serve::{Serve, Url},
};
#[derive(Clone, Debug)]
pub struct Feed {
pub feed_url: Url,
pub blog_url: Url,
pub kind: FeedKind,
pub index: String,
pub length: Option<usize>,
}
impl Feed {
pub fn atom(url: impl Into<Url>) -> Self {
let mut url = url.into();
if let Ok(without) = url.strip_prefix("/") {
url = without.into();
}
Self {
feed_url: url,
blog_url: Url(PathBuf::new()),
kind: FeedKind::Atom,
index: String::new(),
length: None,
}
}
pub fn index(mut self, index: impl Into<String>) -> Self {
self.index = index.into();
self
}
pub fn len(mut self, len: usize) -> Self {
self.length = Some(len);
self
}
pub fn link(mut self, link: impl Into<PathBuf>) -> Self {
self.blog_url = Url(link.into());
self
}
pub fn mime(&self) -> Mime {
self.kind.mime()
}
fn render_atom(&self, blog: &Blog, into: &mut dyn io::Write) -> io::Result<()> {
let mut feed = FeedBuilder::default();
let feed_abs = format!(
"{}/{}",
blog.base_url.trim_end_matches('/'),
self.feed_url.to_str().unwrap_or(""),
);
let blog_abs = format!(
"{}/{}",
blog.base_url.trim_end_matches("/"),
self.blog_url.to_str().unwrap_or(""),
);
let feed = feed
.id(&blog.base_url)
.title(TextBuilder::default().value(&blog.name).build())
.updated(Utc::now().duration_round(TimeDelta::seconds(1)).unwrap())
.generator(Generator {
value: "blog/reblog".into(),
uri: Some(env!("CARGO_PKG_HOMEPAGE").into()),
version: Some(env!("CARGO_PKG_VERSION").into()),
})
.link(Link {
rel: "self".into(),
href: feed_abs,
..Default::default()
})
.link(Link {
rel: "alternate".into(),
href: blog_abs,
..Default::default()
});
for author in &blog.authors {
feed.author(Person {
name: author.name.clone(),
email: author.email.clone(),
uri: author.email.clone(),
extensions: Default::default(),
});
}
let posts = blog.metadata.posts.get(&self.index).cloned().unwrap_or_default();
let mut posts: Vec<_> = posts.into_iter().collect();
let take = self.length.unwrap_or(posts.len());
posts.sort_by_key(|p| Reverse(p.published));
for post in posts.into_iter().take(take) {
let published = post.published.expect("indexed unpublished post?");
let post_abs =
format!("{}/{}", blog.base_url.trim_end_matches('/'), post.url.str(),);
let serve = &blog.urls.get(&post.url).expect("post had a nonexistent url?").1;
let mut body = vec![0u8; 0];
if let Some(st) = &post.subtitle {
let _ = write!(
&mut body,
"<p><em>{}</em></p>",
st.replace('&', "&").replace('<', "<").replace('>', ">"),
);
}
serve.post_body(blog, &post.url, &mut body)?;
let body = String::from_utf8(body).expect("non-utf8");
feed.entry(Entry {
id: post_abs.clone(),
title: TextBuilder::default().value(&post.title).build(),
updated: Utc::now().into(),
published: Some(published.into()),
links: vec![Link {
rel: "alternate".into(),
href: post_abs.clone(),
..Default::default()
}],
summary: post.subtitle.map(|t| TextBuilder::default().value(&t).build()),
content: Some(
ContentBuilder::default()
.content_type("html".to_string())
.value(body)
.build(),
),
..Default::default()
});
}
match feed.build().write_to(into) {
Ok(_) => Ok(()),
Err(e) => Err(io::Error::other(format!("{e}"))),
}
}
}
#[derive(Clone, Debug)]
pub enum FeedKind {
Atom,
}
impl FeedKind {
fn mime(&self) -> Mime {
match self {
Self::Atom => Mime::ATOM,
}
}
}
impl Serve for Feed {
fn base(&self) -> &Url {
&self.feed_url
}
fn scan(&self, _: &std::path::Path) -> io::Result<Metadata> {
Ok(Metadata {
files: [(self.feed_url.clone(), self.mime())].into_iter().collect(),
..Default::default()
})
}
fn render(&self, blog: &Blog, _: &Url, into: &mut dyn io::Write) -> io::Result<()> {
match self.kind {
FeedKind::Atom => self.render_atom(blog, into),
}
}
}