use std::{ collections::hash_map::DefaultHasher, hash::{Hash, Hasher}, }; use ::rss::{ extension::atom::{AtomExtension, Link}, Category, Channel, Guid, Image, Item, }; use actix_web::{get, http::header::ContentType, routes, 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, File, FileMetadataBlog, TypeFileMetadata}, utils::{get_url, make_kw, read_file, Html}, }, template::{InfosPage, NavBar}, }; const MIME_TYPE_RSS: &str = "application/rss+xml"; const BLOG_DIR: &str = "blog"; const POST_DIR: &str = "posts"; #[get("/blog")] pub async fn index(config: web::Data) -> impl Responder { Html(build_index(config.get_ref().to_owned())) } #[derive(Content, Debug)] struct BlogIndexTemplate { navbar: NavBar, about: Option, posts: Vec, no_posts: bool, } #[once(time = 60)] fn build_index(config: Config) -> String { let blog_dir = format!("{}/{}", config.locations.data_dir, BLOG_DIR); let mut posts = get_posts(&format!("{blog_dir}/{POST_DIR}")); // Get about let about: Option = read_file(&format!("{blog_dir}/about.md"), &TypeFileMetadata::Generic); // 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() }, about, no_posts: posts.is_empty(), posts, }, InfosPage { title: Some("Blog".into()), desc: Some(format!( "Liste des posts d'{}", config.fc.name.unwrap_or_default() )), kw: Some(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, data_dir: &str) { let blog_dir = format!("{data_dir}/{BLOG_DIR}/{POST_DIR}"); 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 = std::fs::read_dir(location).map_or_else( |_| vec![], |res| { res.flatten() .filter(|f| f.path().extension().map_or(false, |ext| ext == "md")) .collect::>() }, ); entries .iter() .filter_map(|f| { let fname = f.file_name(); let filename = fname.to_string_lossy(); let file_without_ext = filename.split_at(filename.len() - 3).0; let file_metadata = std::fs::read_to_string(format!("{location}/{filename}")) .map_or_else( |_| FileMetadataBlog { title: Some(file_without_ext.into()), ..FileMetadataBlog::default() }, |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 = metadata .title .map_or_else(|| Some(file_without_ext.into()), Some); metadata }, ); if file_metadata.publish == Some(true) { 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_else(|_| 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.clone()) .collect(), }) } else { None } }) .collect::>() } #[derive(Content, Debug)] struct BlogPostTemplate { navbar: NavBar, post: Option, toc: String, } #[get("/blog/p/{id}")] pub async fn page(path: web::Path<(String,)>, config: web::Data) -> impl Responder { Html(build_post( &path.into_inner().0, config.get_ref().to_owned(), )) } fn build_post(file: &str, config: Config) -> String { let mut post = None; let (infos, toc) = get_post( &mut post, file, &config.fc.name.unwrap_or_default(), &config.locations.data_dir, ); config.tmpl.render( "blog/post.html", BlogPostTemplate { navbar: NavBar { blog: true, ..NavBar::default() }, post, toc, }, infos, ) } fn get_post( post: &mut Option, filename: &str, name: &str, data_dir: &str, ) -> (InfosPage, String) { let blog_dir = format!("{data_dir}/{BLOG_DIR}/{POST_DIR}"); let ext = ".md"; *post = read_file( &format!("{blog_dir}/{filename}{ext}"), &TypeFileMetadata::Blog, ); let default = ( filename, &format!("Blog d'{name}"), Vec::new(), String::new(), ); let (title, desc, 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().description { Some(desc) => desc, None => default.1, }, match &data.metadata.info.blog.as_ref().unwrap().tags { Some(tags) => tags.clone(), None => default.2, }, match &data.metadata.info.blog.as_ref().unwrap().toc { // TODO: Generate TOC Some(true) => String::new(), _ => default.3, }, ), None => default, }; ( InfosPage { title: Some(format!("Post: {title}")), desc: Some(desc.clone()), kw: Some(make_kw( &["blog", "blogging", "write", "writing"] .into_iter() .chain(tags.iter().map(|t| t.name.as_str())) .collect::>(), )), }, toc, ) } #[routes] #[get("/blog/blog.rss")] #[get("/blog/rss")] pub async fn rss(config: web::Data) -> impl Responder { HttpResponse::Ok() .content_type(ContentType(MIME_TYPE_RSS.parse().unwrap())) .body(build_rss(config.get_ref().to_owned())) } #[once(time = 10800)] // 3h fn build_rss(config: Config) -> String { let mut posts = get_posts(&format!( "{}/{}/{}", config.locations.data_dir, BLOG_DIR, POST_DIR )); // 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.clone()) { 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.clone(), link: link_to_site.clone(), description: "Un fil qui parle d'informatique notamment".into(), language: Some(lang.into()), managing_editor: author.clone(), 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!("{link_to_site}/icons/favicon-32x32.png"), title: title.clone(), link: link_to_site.clone(), ..Image::default() }), items: posts .iter_mut() .map(|p| { // Get post data p.fetch_content(&config.locations.data_dir); // Build item Item { title: Some(p.title.clone()), link: Some(format!("{}/blog/p/{}", link_to_site, p.url)), description: p.content.clone(), 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!("{link_to_site}/blog/rss"), 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() }