use { super::{ config::{BlogCategory, BlogPost}, post::Post, }, crate::{ notion, response::success, route::track_mount, url::ROOT_GEMINI_URL, xml::{Item as XmlItem, Writer as XmlWriter}, }, std::sync::{LazyLock, Mutex, RwLock}, }; pub static POSTS: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); static BLOG_CATEGORIES: LazyLock>> = LazyLock::new(|| RwLock::new(Vec::new())); static BLOG_POSTS: LazyLock>> = LazyLock::new(|| RwLock::new(Vec::new())); #[allow(clippy::too_many_lines)] pub fn module(router: &mut windmark::router::Router) { std::thread::spawn(fetch_from_notion) .join() .expect("initial Notion fetch failed"); track_mount(router, "/blog", "Fuwn's blogs", |context| { let categories = BLOG_CATEGORIES.read().unwrap(); let listing = categories .iter() .map(|category| { let slug = slugify(&category.title); format!( "=> /blog/{} {}{}", slug, category.title, category .description .as_ref() .map_or_else(String::new, |description_text| format!( " ― {description_text}" )) ) }) .collect::>() .join("\n"); success(&format!("# Blogs ({})\n\n{}", categories.len(), listing), &context) }); track_mount( router, "/blog/:blog_name", "-A blog on Fuwn's capsule", |context| { let raw_blog_name = context.parameters.get("blog_name").cloned().unwrap_or_default(); if let Some(blog_slug) = raw_blog_name.strip_suffix(".xml") { return build_rss_feed(blog_slug); } let blog_slug = raw_blog_name; let categories = BLOG_CATEGORIES.read().unwrap(); let posts = BLOG_POSTS.read().unwrap(); let matched_category = categories .iter() .find(|category| slugify(&category.title) == blog_slug); let Some(category) = matched_category else { return windmark::response::Response::not_found( "This blog could not be found.", ); }; let category_posts: Vec<_> = posts .iter() .filter(|post| post.blog_id == category.notion_id) .collect(); let post_listing = category_posts .iter() .map(|post| { let post_slug = slugify(&post.title); format!( "=> /blog/{}/{} {}{}", blog_slug, post_slug, post.title, post .description .as_ref() .filter(|description_text| !description_text.is_empty()) .map_or_else(String::new, |description_text| format!( " ― {description_text}" )) ) }) .collect::>() .join("\n"); success( &format!( "# {} ({})\n\n{}\n\n{}\n\n## Really Simple Syndication\n\nAccess \ {0}'s RSS feed\n\n=> /blog/{}.xml here!", category.title, category_posts.len(), category.description.as_deref().unwrap_or(""), post_listing, blog_slug, ), &context, ) }, ); track_mount( router, "/blog/:blog_name/:post_title", "-An entry to one of Fuwn's blogs", |context| { let blog_slug = context.parameters.get("blog_name").cloned().unwrap_or_default(); let post_slug = context.parameters.get("post_title").cloned().unwrap_or_default(); let categories = BLOG_CATEGORIES.read().unwrap(); let posts = BLOG_POSTS.read().unwrap(); let matched_category = categories .iter() .find(|category| slugify(&category.title) == blog_slug); let Some(category) = matched_category else { return windmark::response::Response::not_found( "This blog could not be found.", ); }; let matched_post = posts.iter().find(|post| { post.blog_id == category.notion_id && slugify(&post.title) == post_slug }); let Some(post) = matched_post else { return windmark::response::Response::not_found( "This post could not be found.", ); }; let header = construct_header(post); success(&format!("{}\n{}", header, post.content), &context) }, ); } fn construct_header(post: &BlogPost) -> String { let title_line = format!("# {}", post.title); let has_metadata = post.author.is_some() || post.created.is_some() || post.last_modified.is_some() || post.description.is_some(); if !has_metadata { return title_line; } let author_fragment = post.author.as_ref().map_or_else(String::new, |author_name| { format!("Written by {author_name}") }); let created_fragment = post .created .as_ref() .map_or_else(String::new, |created_date| format!(" on {created_date}")); let modified_fragment = post.last_modified.as_ref().map_or_else(String::new, |modified_date| { format!(" (last modified on {modified_date})\n") }); let description_fragment = post .description .as_ref() .filter(|description_text| !description_text.is_empty()) .map_or_else(String::new, |description_text| { format!("\n{description_text}\n") }); format!( "{title_line}\n\n{author_fragment}{created_fragment}{modified_fragment}{description_fragment}" ) } fn slugify(text: &str) -> String { text.replace(' ', "_").to_lowercase() } fn build_rss_feed(blog_slug: &str) -> windmark::response::Response { let categories = BLOG_CATEGORIES.read().unwrap(); let posts = BLOG_POSTS.read().unwrap(); let matched_category = categories.iter().find(|category| slugify(&category.title) == blog_slug); let Some(category) = matched_category else { return windmark::response::Response::not_found( "This blog could not be found.", ); }; let mut xml = XmlWriter::builder(); xml.add_field("title", &category.title); xml.add_field("link", &format!("{ROOT_GEMINI_URL}/blog/{blog_slug}")); if let Some(ref description_text) = category.description { xml.add_field("description", description_text); } xml.add_field("generator", "locus"); xml.add_field("lastBuildDate", &chrono::Local::now().to_rfc2822()); xml.add_link(&format!("{ROOT_GEMINI_URL}/blog/{blog_slug}.xml")); for post in posts.iter().filter(|post| post.blog_id == category.notion_id) { let post_slug = slugify(&post.title); xml.add_item(&{ let mut builder = XmlItem::builder(); builder.add_field( "link", &format!("{ROOT_GEMINI_URL}/blog/{blog_slug}/{post_slug}"), ); builder.add_field("description", &post.content); builder.add_field( "guid", &format!("{ROOT_GEMINI_URL}/blog/{blog_slug}/{post_slug}"), ); builder.add_field("title", &post.title); if let Some(ref created_date) = post.created { builder.add_field("pubDate", created_date); } builder }); } windmark::response::Response::success(xml.to_string()) .with_mime("text/rss+xml") .clone() } fn fetch_from_notion() { let api_key = std::env::var("NOTION_API_KEY") .expect("NOTION_API_KEY environment variable is required"); let blogs_database_identifier = std::env::var("NOTION_BLOGS_DATABASE_ID") .expect("NOTION_BLOGS_DATABASE_ID environment variable is required"); let posts_database_identifier = std::env::var("NOTION_POSTS_DATABASE_ID") .expect("NOTION_POSTS_DATABASE_ID environment variable is required"); let http_client = reqwest::blocking::Client::new(); let blog_pages = notion::query_database(&http_client, &api_key, &blogs_database_identifier) .expect("failed to query Notion blogs database"); let mut categories: Vec = blog_pages .iter() .map(|page| { let description_text = notion::extract_rich_text(&page.properties, "Description"); BlogCategory { title: notion::extract_title(&page.properties, "Title"), description: if description_text.is_empty() { None } else { Some(description_text) }, priority: notion::extract_number(&page.properties, "Priority") .unwrap_or(0), notion_id: page.id.clone(), } }) .collect(); categories.sort_by(|first, second| second.priority.cmp(&first.priority)); let post_pages = notion::query_database(&http_client, &api_key, &posts_database_identifier) .expect("failed to query Notion posts database"); let fetched_posts: Vec = post_pages .iter() .map(|page| { let page_content = notion::fetch_page_content(&http_client, &api_key, &page.id) .unwrap_or_default(); let blog_relation_ids = notion::extract_relation_ids(&page.properties, "Blog"); let blog_identifier = blog_relation_ids.first().cloned().unwrap_or_default(); let created_raw = notion::extract_date(&page.properties, "Created"); let last_modified_raw = notion::extract_date(&page.properties, "Last Modified"); let description_text = notion::extract_rich_text(&page.properties, "Description"); let author_text = notion::extract_rich_text(&page.properties, "Author"); BlogPost { title: notion::extract_title(&page.properties, "Title"), description: if description_text.is_empty() { None } else { Some(description_text) }, author: if author_text.is_empty() { None } else { Some(author_text) }, created: if created_raw.is_empty() { None } else { Some(notion::format_notion_date(&created_raw)) }, last_modified: if last_modified_raw.is_empty() { None } else { Some(notion::format_notion_date(&last_modified_raw)) }, content: page_content, blog_id: blog_identifier, } }) .collect(); { let mut global_posts = POSTS.lock().unwrap(); global_posts.clear(); for post in &fetched_posts { let blog_title = categories .iter() .find(|category| category.notion_id == post.blog_id) .map(|category| category.title.clone()) .unwrap_or_default(); global_posts.push(Post::new( format!("{}, {}", blog_title, post.title), format!("/blog/{}/{}", slugify(&blog_title), slugify(&post.title)), post.created.clone().unwrap_or_default(), )); } } *BLOG_CATEGORIES.write().unwrap() = categories; *BLOG_POSTS.write().unwrap() = fetched_posts; info!("fetched blog data from Notion"); } pub fn refresh_loop() { let refresh_interval_seconds = std::env::var("NOTION_REFRESH_INTERVAL") .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(300); info!("spawned Notion blog refresh loop ({refresh_interval_seconds}s interval)"); loop { std::thread::sleep(std::time::Duration::from_secs(refresh_interval_seconds)); match std::panic::catch_unwind(fetch_from_notion) { Ok(()) => info!("refreshed blog data from Notion"), Err(_) => warn!("failed to refresh blog data from Notion"), } } }