use std::{ collections::hash_map::DefaultHasher, hash::{Hash, Hasher}, }; use ::rss::{ extension::atom::{AtomExtension, Link}, Category, Channel, Guid, Image, Item, }; use actix_web::{get, web, HttpResponse, Responder}; use cached::proc_macro::once; use chrono::{DateTime, Datelike, Local, NaiveDateTime, Utc}; use chrono_tz::Europe; use comrak::{parse_document, Arena}; use ramhorns::Content; use crate::{ config::Config, misc::{ date::Date, markdown::{ get_metadata, get_options, read_file, File, FileMetadataBlog, TypeFileMetadata, }, utils::{get_url, make_kw}, }, template::{Infos, NavBar}, }; const MIME_TYPE_RSS: &str = "application/rss+xml"; #[get("/blog")] async fn index(config: web::Data) -> impl Responder { HttpResponse::Ok().body(build_index(config.get_ref().to_owned())) } #[derive(Content, Debug)] struct BlogIndexTemplate { navbar: NavBar, posts: Vec, no_posts: bool, } #[once(time = 60)] fn build_index(config: Config) -> String { let mut posts = get_posts("data/blog"); // Sort from newest to oldest posts.sort_by_cached_key(|p| (p.date.year, p.date.month, p.date.day)); posts.reverse(); config.tmpl.render( "blog/index.html", BlogIndexTemplate { navbar: NavBar { blog: true, ..NavBar::default() }, no_posts: posts.is_empty(), posts, }, Infos { page_title: Some("Blog".into()), page_desc: Some(format!( "Liste des posts d'{}", config.fc.name.unwrap_or_default() )), page_kw: make_kw(&["blog", "blogging"]), }, ) } #[derive(Content, Debug)] struct Post { title: String, date: Date, url: String, desc: Option, content: Option, tags: Vec, } impl Post { // Fetch the file content fn fetch_content(&mut self) { let blog_dir = "data/blog"; let ext = ".md"; if let Some(file) = read_file( &format!("{blog_dir}/{}{ext}", self.url), TypeFileMetadata::Blog, ) { self.content = Some(file.content); } } } impl Hash for Post { fn hash(&self, state: &mut H) { if let Some(content) = &self.content { content.hash(state) } } } fn get_posts(location: &str) -> Vec { let entries = match std::fs::read_dir(location) { Ok(res) => res .flatten() .filter(|f| match f.path().extension() { Some(ext) => ext == "md", None => false, }) .collect::>(), Err(_) => vec![], }; entries .iter() .filter_map(|f| { let _filename = f.file_name(); let filename = _filename.to_string_lossy(); let file_without_ext = filename.split_at(filename.len() - 3).0; let file_metadata = match std::fs::read_to_string(format!("{location}/{filename}")) { Ok(text) => { let arena = Arena::new(); let options = get_options(); let root = parse_document(&arena, &text, &options); let mut metadata = get_metadata(root, TypeFileMetadata::Blog).blog.unwrap(); // Always have a title metadata.title = match metadata.title { Some(title) => Some(title), None => Some(file_without_ext.into()), }; metadata } Err(_) => FileMetadataBlog { title: Some(file_without_ext.into()), ..FileMetadataBlog::default() }, }; if let Some(true) = file_metadata.publish { Some(Post { url: file_without_ext.into(), title: file_metadata.title.unwrap(), date: file_metadata.date.unwrap_or({ let m = f.metadata().unwrap(); let date = std::convert::Into::>::into( m.modified().unwrap_or(m.created().unwrap()), ) .date_naive(); Date { day: date.day(), month: date.month(), year: date.year(), } }), desc: file_metadata.description, content: None, tags: file_metadata .tags .unwrap_or_default() .iter() .map(|t| t.name.to_owned()) .collect(), }) } else { None } }) .collect::>() } #[derive(Content, Debug)] struct BlogPostTemplate { navbar: NavBar, post: Option, toc: String, } #[get("/blog/p/{id}")] async fn page(path: web::Path<(String,)>, config: web::Data) -> impl Responder { HttpResponse::Ok().body(build_post(path.into_inner().0, config.get_ref().to_owned())) } fn build_post(file: String, config: Config) -> String { let mut post = None; let (infos, toc) = get_post(&mut post, file, config.fc.name.unwrap_or_default()); config.tmpl.render( "blog/post.html", BlogPostTemplate { navbar: NavBar { blog: true, ..NavBar::default() }, post, toc, }, infos, ) } fn get_post(post: &mut Option, filename: String, name: String) -> (Infos, String) { let blog_dir = "data/blog"; let ext = ".md"; *post = read_file( &format!("{blog_dir}/{filename}{ext}"), TypeFileMetadata::Blog, ); let default = (&filename, Vec::new(), String::new()); let (title, tags, toc) = match post { Some(data) => ( match &data.metadata.info.blog.as_ref().unwrap().title { Some(text) => text, None => default.0, }, match &data.metadata.info.blog.as_ref().unwrap().tags { Some(tags) => tags.clone(), None => default.1, }, match &data.metadata.info.blog.as_ref().unwrap().toc { // TODO: Generate TOC Some(true) => String::new(), _ => default.2, }, ), None => default, }; ( Infos { page_title: Some(format!("Post: {}", title)), page_desc: Some(format!("Blog d'{name}")), page_kw: Some( ["blog", "blogging", "write", "writing"] .iter() .map(|&tag| tag.to_owned()) .chain(tags.into_iter().map(|t| t.name)) .collect::>() .join(", "), ), }, toc, ) } #[get("/blog/rss")] async fn rss(config: web::Data) -> impl Responder { HttpResponse::Ok() .append_header(("content-type", MIME_TYPE_RSS)) .body(build_rss(config.get_ref().to_owned())) } #[once(time = 10800)] // 3h fn build_rss(config: Config) -> String { let mut posts = get_posts("data/blog"); // Sort from newest to oldest posts.sort_by_cached_key(|p| (p.date.year, p.date.month, p.date.day)); posts.reverse(); // Only the 20 newest let max = 20; if posts.len() > max { posts.drain(max..); } let link_to_site = get_url(config.fc.clone()); let author = if let (Some(mail), Some(name)) = (config.fc.mail, config.fc.fullname.to_owned()) { Some(format!("{mail} ({name})")) } else { None }; let title = format!("Blog d'{}", config.fc.name.unwrap_or_default()); let lang = "fr"; let channel = Channel { title: title.to_owned(), link: link_to_site.to_owned(), description: "Un fil qui parle d'informatique notamment".into(), language: Some(lang.into()), managing_editor: author.to_owned(), webmaster: author, pub_date: Some(Local::now().to_rfc2822()), categories: ["blog", "blogging", "write", "writing"] .iter() .map(|&c| Category { name: c.into(), ..Category::default() }) .collect(), generator: Some("ewp with rss crate".into()), docs: Some("https://www.rssboard.org/rss-specification".into()), image: Some(Image { url: format!("{}/icons/favicon-32x32.png", link_to_site), title: title.to_owned(), link: link_to_site.to_owned(), ..Image::default() }), items: posts .iter_mut() .map(|p| { // Get post data p.fetch_content(); // Build item Item { title: Some(p.title.to_owned()), link: Some(format!("{}/blog/p/{}", link_to_site, p.url)), description: p.content.to_owned(), categories: p .tags .iter() .map(|c| Category { name: c.to_owned(), ..Category::default() }) .collect(), guid: Some(Guid { value: format!("urn:hash:{}", { let mut hasher = DefaultHasher::new(); p.hash(&mut hasher); hasher.finish() }), permalink: false, }), pub_date: Some( NaiveDateTime::parse_from_str( &format!("{}-{}-{} 13:12:00", p.date.day, p.date.month, p.date.year), "%d-%m-%Y %H:%M:%S", ) .unwrap() .and_local_timezone(Europe::Paris) .unwrap() .to_rfc2822(), ), ..Item::default() } }) .collect(), atom_ext: Some(AtomExtension { links: vec![Link { href: format!("{}/blog/rss", link_to_site), rel: "self".into(), hreflang: Some(lang.into()), mime_type: Some(MIME_TYPE_RSS.into()), title: Some(title), length: None, }], }), ..Channel::default() }; std::str::from_utf8(&channel.write_to(Vec::new()).unwrap()) .unwrap() .into() }