diff options
| author | Fuwn <[email protected]> | 2026-02-11 23:43:28 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-11 23:43:31 -0800 |
| commit | 17475e06c8822c854dcfa1335f44957b6a3eb629 (patch) | |
| tree | 4e85234cf29e54ef747f1dc01ad2c523f18bd692 | |
| parent | chore: Update CI references to updated Rust toolchain channel (diff) | |
| download | locus-17475e06c8822c854dcfa1335f44957b6a3eb629.tar.xz locus-17475e06c8822c854dcfa1335f44957b6a3eb629.zip | |
feat: Replace static blog system with Notion-backed dynamic content
| -rw-r--r-- | amenadiel/src/lib.rs | 10 | ||||
| -rw-r--r-- | content/unused/last_blogs/finger_archive/2024_06_27.gmi (renamed from content/blogs/finger_archive/2024_06_27.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/finger_archive/2024_06_29.gmi (renamed from content/blogs/finger_archive/2024_06_29.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/finger_archive/blog.json (renamed from content/blogs/finger_archive/blog.json) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/news/Finger_Gateway.gmi (renamed from content/blogs/news/Finger_Gateway.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/news/blog.json (renamed from content/blogs/news/blog.json) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/C++.gmi (renamed from content/blogs/technology/C++.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/Compilers.gmi (renamed from content/blogs/technology/Compilers.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/Forth.gmi (renamed from content/blogs/technology/Forth.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/Gemini.gmi (renamed from content/blogs/technology/Gemini.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/Go.gmi (renamed from content/blogs/technology/Go.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/Gopher.gmi (renamed from content/blogs/technology/Gopher.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/I2P.gmi (renamed from content/blogs/technology/I2P.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/Maths.gmi (renamed from content/blogs/technology/Maths.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/Nix.gmi (renamed from content/blogs/technology/Nix.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/OCaml.gmi (renamed from content/blogs/technology/OCaml.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/Rust.gmi (renamed from content/blogs/technology/Rust.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/Tor.gmi (renamed from content/blogs/technology/Tor.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/Unix-like.gmi (renamed from content/blogs/technology/Unix-like.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/Web.gmi (renamed from content/blogs/technology/Web.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/technology/blog.json (renamed from content/blogs/technology/blog.json) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/the_daily/airpods_4_anc.gmi (renamed from content/blogs/the_daily/airpods_4_anc.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/the_daily/blog.json (renamed from content/blogs/the_daily/blog.json) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/the_daily/fixing_a_broken_time_machine_disk.gmi (renamed from content/blogs/the_daily/fixing_a_broken_time_machine_disk.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/the_daily/nvme_troubles_part_1.gmi (renamed from content/blogs/the_daily/nvme_troubles_part_1.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/the_daily/nvme_troubles_part_2.gmi (renamed from content/blogs/the_daily/nvme_troubles_part_2.gmi) | 0 | ||||
| -rw-r--r-- | content/unused/last_blogs/the_daily/nvme_troubles_part_3.gmi (renamed from content/blogs/the_daily/nvme_troubles_part_3.gmi) | 0 | ||||
| -rw-r--r-- | src/main.rs | 2 | ||||
| -rw-r--r-- | src/modules/blog.rs | 2 | ||||
| -rw-r--r-- | src/modules/blog/config.rs | 65 | ||||
| -rw-r--r-- | src/modules/blog/module.rs | 656 | ||||
| -rw-r--r-- | src/notion.rs | 207 |
32 files changed, 553 insertions, 389 deletions
diff --git a/amenadiel/src/lib.rs b/amenadiel/src/lib.rs index 0facb6b..8ed7450 100644 --- a/amenadiel/src/lib.rs +++ b/amenadiel/src/lib.rs @@ -27,16 +27,16 @@ pub fn modules(input: TokenStream) -> TokenStream { let mut mounted_modules = quote!(); for module_name in input_modules { - if module_name == "search" { + if module_name == "search" || module_name == "blog" { legitimised_modules.extend(quote!( - pub mod search; + pub mod #module_name; )); - mounted_modules.extend(quote!(router.attach_stateless(search::module);)); } else { legitimised_modules.extend(quote!(mod #module_name;)); - mounted_modules - .extend(quote!(router.attach_stateless(#module_name::module);)); } + + mounted_modules + .extend(quote!(router.attach_stateless(#module_name::module);)); } quote! { diff --git a/content/blogs/finger_archive/2024_06_27.gmi b/content/unused/last_blogs/finger_archive/2024_06_27.gmi index 7821f3b..7821f3b 100644 --- a/content/blogs/finger_archive/2024_06_27.gmi +++ b/content/unused/last_blogs/finger_archive/2024_06_27.gmi diff --git a/content/blogs/finger_archive/2024_06_29.gmi b/content/unused/last_blogs/finger_archive/2024_06_29.gmi index 97326ee..97326ee 100644 --- a/content/blogs/finger_archive/2024_06_29.gmi +++ b/content/unused/last_blogs/finger_archive/2024_06_29.gmi diff --git a/content/blogs/finger_archive/blog.json b/content/unused/last_blogs/finger_archive/blog.json index 2148303..2148303 100644 --- a/content/blogs/finger_archive/blog.json +++ b/content/unused/last_blogs/finger_archive/blog.json diff --git a/content/blogs/news/Finger_Gateway.gmi b/content/unused/last_blogs/news/Finger_Gateway.gmi index a8c5278..a8c5278 100644 --- a/content/blogs/news/Finger_Gateway.gmi +++ b/content/unused/last_blogs/news/Finger_Gateway.gmi diff --git a/content/blogs/news/blog.json b/content/unused/last_blogs/news/blog.json index 181335d..181335d 100644 --- a/content/blogs/news/blog.json +++ b/content/unused/last_blogs/news/blog.json diff --git a/content/blogs/technology/C++.gmi b/content/unused/last_blogs/technology/C++.gmi index f95c434..f95c434 100644 --- a/content/blogs/technology/C++.gmi +++ b/content/unused/last_blogs/technology/C++.gmi diff --git a/content/blogs/technology/Compilers.gmi b/content/unused/last_blogs/technology/Compilers.gmi index 3267197..3267197 100644 --- a/content/blogs/technology/Compilers.gmi +++ b/content/unused/last_blogs/technology/Compilers.gmi diff --git a/content/blogs/technology/Forth.gmi b/content/unused/last_blogs/technology/Forth.gmi index 0986735..0986735 100644 --- a/content/blogs/technology/Forth.gmi +++ b/content/unused/last_blogs/technology/Forth.gmi diff --git a/content/blogs/technology/Gemini.gmi b/content/unused/last_blogs/technology/Gemini.gmi index 938d4a0..938d4a0 100644 --- a/content/blogs/technology/Gemini.gmi +++ b/content/unused/last_blogs/technology/Gemini.gmi diff --git a/content/blogs/technology/Go.gmi b/content/unused/last_blogs/technology/Go.gmi index 29162c1..29162c1 100644 --- a/content/blogs/technology/Go.gmi +++ b/content/unused/last_blogs/technology/Go.gmi diff --git a/content/blogs/technology/Gopher.gmi b/content/unused/last_blogs/technology/Gopher.gmi index 9a13d75..9a13d75 100644 --- a/content/blogs/technology/Gopher.gmi +++ b/content/unused/last_blogs/technology/Gopher.gmi diff --git a/content/blogs/technology/I2P.gmi b/content/unused/last_blogs/technology/I2P.gmi index 1bee2d6..1bee2d6 100644 --- a/content/blogs/technology/I2P.gmi +++ b/content/unused/last_blogs/technology/I2P.gmi diff --git a/content/blogs/technology/Maths.gmi b/content/unused/last_blogs/technology/Maths.gmi index 85a2970..85a2970 100644 --- a/content/blogs/technology/Maths.gmi +++ b/content/unused/last_blogs/technology/Maths.gmi diff --git a/content/blogs/technology/Nix.gmi b/content/unused/last_blogs/technology/Nix.gmi index 88f501c..88f501c 100644 --- a/content/blogs/technology/Nix.gmi +++ b/content/unused/last_blogs/technology/Nix.gmi diff --git a/content/blogs/technology/OCaml.gmi b/content/unused/last_blogs/technology/OCaml.gmi index 15c6ce7..15c6ce7 100644 --- a/content/blogs/technology/OCaml.gmi +++ b/content/unused/last_blogs/technology/OCaml.gmi diff --git a/content/blogs/technology/Rust.gmi b/content/unused/last_blogs/technology/Rust.gmi index 9a79bc0..9a79bc0 100644 --- a/content/blogs/technology/Rust.gmi +++ b/content/unused/last_blogs/technology/Rust.gmi diff --git a/content/blogs/technology/Tor.gmi b/content/unused/last_blogs/technology/Tor.gmi index b965e95..b965e95 100644 --- a/content/blogs/technology/Tor.gmi +++ b/content/unused/last_blogs/technology/Tor.gmi diff --git a/content/blogs/technology/Unix-like.gmi b/content/unused/last_blogs/technology/Unix-like.gmi index 5cb3c5f..5cb3c5f 100644 --- a/content/blogs/technology/Unix-like.gmi +++ b/content/unused/last_blogs/technology/Unix-like.gmi diff --git a/content/blogs/technology/Web.gmi b/content/unused/last_blogs/technology/Web.gmi index 36f6337..36f6337 100644 --- a/content/blogs/technology/Web.gmi +++ b/content/unused/last_blogs/technology/Web.gmi diff --git a/content/blogs/technology/blog.json b/content/unused/last_blogs/technology/blog.json index d58ec59..d58ec59 100644 --- a/content/blogs/technology/blog.json +++ b/content/unused/last_blogs/technology/blog.json diff --git a/content/blogs/the_daily/airpods_4_anc.gmi b/content/unused/last_blogs/the_daily/airpods_4_anc.gmi index fce9fb3..fce9fb3 100644 --- a/content/blogs/the_daily/airpods_4_anc.gmi +++ b/content/unused/last_blogs/the_daily/airpods_4_anc.gmi diff --git a/content/blogs/the_daily/blog.json b/content/unused/last_blogs/the_daily/blog.json index 513737f..513737f 100644 --- a/content/blogs/the_daily/blog.json +++ b/content/unused/last_blogs/the_daily/blog.json diff --git a/content/blogs/the_daily/fixing_a_broken_time_machine_disk.gmi b/content/unused/last_blogs/the_daily/fixing_a_broken_time_machine_disk.gmi index 295532a..295532a 100644 --- a/content/blogs/the_daily/fixing_a_broken_time_machine_disk.gmi +++ b/content/unused/last_blogs/the_daily/fixing_a_broken_time_machine_disk.gmi diff --git a/content/blogs/the_daily/nvme_troubles_part_1.gmi b/content/unused/last_blogs/the_daily/nvme_troubles_part_1.gmi index 35eb19e..35eb19e 100644 --- a/content/blogs/the_daily/nvme_troubles_part_1.gmi +++ b/content/unused/last_blogs/the_daily/nvme_troubles_part_1.gmi diff --git a/content/blogs/the_daily/nvme_troubles_part_2.gmi b/content/unused/last_blogs/the_daily/nvme_troubles_part_2.gmi index ba3aa75..ba3aa75 100644 --- a/content/blogs/the_daily/nvme_troubles_part_2.gmi +++ b/content/unused/last_blogs/the_daily/nvme_troubles_part_2.gmi diff --git a/content/blogs/the_daily/nvme_troubles_part_3.gmi b/content/unused/last_blogs/the_daily/nvme_troubles_part_3.gmi index 328a4ea..328a4ea 100644 --- a/content/blogs/the_daily/nvme_troubles_part_3.gmi +++ b/content/unused/last_blogs/the_daily/nvme_troubles_part_3.gmi diff --git a/src/main.rs b/src/main.rs index dd130b7..e51cc1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ #![allow(clippy::cast_precision_loss, clippy::significant_drop_tightening)] mod modules; +mod notion; mod response; mod route; mod timing; @@ -80,6 +81,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { router.attach_stateless(modules::module); }); std::thread::spawn(modules::search::index); + std::thread::spawn(modules::blog::refresh_loop); router.run().await } diff --git a/src/modules/blog.rs b/src/modules/blog.rs index 573daa5..9e84e14 100644 --- a/src/modules/blog.rs +++ b/src/modules/blog.rs @@ -2,4 +2,4 @@ mod config; mod module; mod post; -pub use module::{module, POSTS}; +pub use module::{module, refresh_loop, POSTS}; diff --git a/src/modules/blog/config.rs b/src/modules/blog/config.rs index c4c7ced..a061b7f 100644 --- a/src/modules/blog/config.rs +++ b/src/modules/blog/config.rs @@ -1,53 +1,18 @@ -use { - serde::{Deserialize, Serialize}, - std::collections::HashMap, -}; - -#[derive(Serialize, Deserialize, Clone, Default)] -pub struct Entry { - description: Option<String>, - author: Option<String>, - created: Option<String>, - last_modified: Option<String>, - name: Option<String>, -} -impl Entry { - pub const fn description(&self) -> Option<&String> { - self.description.as_ref() - } - - pub const fn author(&self) -> Option<&String> { self.author.as_ref() } - - pub const fn name(&self) -> Option<&String> { self.name.as_ref() } - - pub const fn created(&self) -> Option<&String> { self.created.as_ref() } - - pub const fn last_modified(&self) -> Option<&String> { - self.last_modified.as_ref() - } +#[derive(Clone)] +pub struct BlogCategory { + pub title: String, + pub description: Option<String>, + pub priority: u8, + pub notion_id: String, } -#[derive(Serialize, Deserialize, Clone, Default)] -pub struct Blog { - name: Option<String>, - description: Option<String>, - posts: Option<HashMap<String, Entry>>, - priority: Option<u8>, -} -impl Blog { - pub const fn description(&self) -> Option<&String> { - self.description.as_ref() - } - - pub const fn name(&self) -> Option<&String> { self.name.as_ref() } - - pub const fn posts(&self) -> Option<&HashMap<String, Entry>> { - self.posts.as_ref() - } - - pub const fn priority(&self) -> Option<&u8> { self.priority.as_ref() } - - pub fn from_string(string: &str) -> serde_json::Result<Self> { - serde_json::from_str(string) - } +#[derive(Clone)] +pub struct BlogPost { + pub title: String, + pub description: Option<String>, + pub author: Option<String>, + pub created: Option<String>, + pub last_modified: Option<String>, + pub content: String, + pub blog_id: String, } diff --git a/src/modules/blog/module.rs b/src/modules/blog/module.rs index 2fda040..bb88122 100644 --- a/src/modules/blog/module.rs +++ b/src/modules/blog/module.rs @@ -1,375 +1,365 @@ use { - super::post::Post, + super::{ + config::{BlogCategory, BlogPost}, + post::Post, + }, crate::{ - modules::blog::config::Blog, + notion, response::success, route::track_mount, url::ROOT_GEMINI_URL, xml::{Item as XmlItem, Writer as XmlWriter}, }, - std::{ - collections::HashMap, - fs::{self, read_dir}, - io::Read, - sync::{LazyLock, Mutex}, - }, + std::sync::{LazyLock, Mutex, RwLock}, }; pub static POSTS: LazyLock<Mutex<Vec<Post>>> = LazyLock::new(|| Mutex::new(Vec::new())); +static BLOG_CATEGORIES: LazyLock<RwLock<Vec<BlogCategory>>> = + LazyLock::new(|| RwLock::new(Vec::new())); +static BLOG_POSTS: LazyLock<RwLock<Vec<BlogPost>>> = + LazyLock::new(|| RwLock::new(Vec::new())); #[allow(clippy::too_many_lines)] pub fn module(router: &mut windmark::router::Router) { - let paths = read_dir("content/blogs").unwrap(); - let mut blogs: HashMap<String, HashMap<_, _>> = HashMap::new(); - - for path in paths { - let blog = path.unwrap().path().display().to_string(); - let blog_paths = read_dir(&blog).unwrap(); - let mut entries: HashMap<_, String> = HashMap::new(); - - blog_paths.map(|e| e.unwrap().path().display().to_string()).for_each( - |file| { - let mut contents = String::new(); - - fs::File::open(&file).unwrap().read_to_string(&mut contents).unwrap(); - - let mut entry_key = - file.strip_prefix(&blog).unwrap_or(&file).to_string(); - - if std::path::Path::new(&entry_key) - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("gmi")) - { - entry_key.truncate(entry_key.len() - 4); - } - - if entry_key.starts_with({ - #[cfg(windows)] - { - '\\' - } - - #[cfg(unix)] - { - '/' - } - }) { - entry_key.remove(0); - } - - entries.insert(entry_key, contents); - }, - ); - - blogs.insert( - blog - .replace( - { - #[cfg(windows)] - { - "content/blogs\\" - } - - #[cfg(unix)] - { - "content/blogs/" - } - }, - "", + 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}" + )) ) - .split('_') - .map(|s| { - // https://gist.github.com/jpastuszek/2704f3c5a3864b05c48ee688d0fd21d7 - let mut c = s.chars(); - - c.next().map_or_else(String::new, |f| { - f.to_uppercase().chain(c.flat_map(char::to_lowercase)).collect() - }) + }) + .collect::<Vec<_>>() + .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::<Vec<_>>() - .join(" "), - entries, - ); - } + .join("\n"); - let mut blog_clone: Vec<_> = blogs - .clone() - .into_iter() - .map(|(blog, entries)| { - ( - blog, - entries - .get("blog.json") - .and_then(|content| Blog::from_string(content).ok()) - .unwrap_or_default() - .priority() - .copied(), - entries, + 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, ) - }) - .collect(); + }, + ); + 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); - blog_clone.sort_by(|a, b| b.1.cmp(&a.1)); - blog_clone.reverse(); - - track_mount(router, "/blog", "Fuwn's blogs", move |context| { - success( - &format!( - "# Blogs ({})\n\n{}", - blog_clone.len(), - blog_clone - .iter() - .map(|(title, _, entries)| { - let config: Option<Blog> = entries - .get("blog.json") - .and_then(|content| Blog::from_string(content).ok()); - let name = config - .as_ref() - .and_then(|c| c.name()) - .cloned() - .unwrap_or_else(|| title.clone()); - let description = - config.as_ref().and_then(|c| c.description()).cloned(); - - format!( - "=> {} {}{}", - format_args!("/blog/{}", name.replace(' ', "_").to_lowercase(),), - name, - description - .map_or_else(String::new, |description_reference| format!( - " ― {description_reference}" - )) - ) - }) - .collect::<Vec<_>>() - .join("\n") - ), - &context, - ) - }); + success(&format!("{}\n{}", header, post.content), &context) + }, + ); +} - for (blog, mut entries) in blogs { - let fixed_blog_name = blog.replace(' ', "_").to_lowercase(); - let fixed_blog_name_clone = fixed_blog_name.clone(); - let fixed_blog_name_clone_2 = fixed_blog_name.clone(); - let config: Option<Blog> = entries - .remove_entry("blog.json") - .and_then(|(_, content)| Blog::from_string(&content).ok()); - let entries_clone = entries.clone(); - let name = config - .as_ref() - .and_then(|c| c.name()) - .cloned() - .unwrap_or_else(|| blog.clone()); - let description = config.as_ref().and_then(|c| c.description()).cloned(); - let config_clone = config.clone(); - let mut xml = XmlWriter::builder(); - - xml.add_field("title", &name); - xml.add_field("link", &format!("{ROOT_GEMINI_URL}/blog/{fixed_blog_name}")); - - if let Some(ref description_reference) = description { - xml.add_field("description", description_reference); - } +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(); - xml.add_field("generator", "locus"); - xml.add_field("lastBuildDate", &chrono::Local::now().to_rfc2822()); - xml.add_link(&format!("{ROOT_GEMINI_URL}/blog/{fixed_blog_name}.xml")); - - track_mount( - router, - &format!("/blog/{fixed_blog_name}"), - &format!( - "{name}{}", - description - .as_ref() - .map_or_else(String::new, |description_reference| format!( - " ― {description_reference}" - )) - ), - move |context| { - let fixed_blog_name = fixed_blog_name_clone.clone(); - - success( - &format!( - "# {} ({})\n\n{}\n\n{}\n\n## Really Simple Syndication\n\nAccess \ - {0}'s RSS feed\n\n=> {} here!", - blog, - entries_clone.len(), - description.as_deref().map_or("", |v| v), - entries_clone - .keys() - .map(|title| { - let postish = config_clone - .as_ref() - .and_then(|c| c.posts()) - .and_then(|posts| posts.get(title)) - .cloned() - .unwrap_or_default(); - - format!( - "=> {} {}{}", - format_args!( - "/blog/{}/{}", - fixed_blog_name, - title.to_lowercase() - ), - { postish.name().cloned().unwrap_or_else(|| title.clone()) }, - { - let post = - postish.description().cloned().unwrap_or_default(); - - if post.is_empty() { - String::new() - } else { - format!(" ― {post}") - } - } - ) - }) - .collect::<Vec<_>>() - .join("\n"), - format_args!("/blog/{}.xml", fixed_blog_name), - ), - &context, - ) - }, - ); + if !has_metadata { + return title_line; + } - for (title, contents) in entries { - let header = construct_header(config.as_ref(), &title) - .unwrap_or_else(|()| (String::new(), String::new())); - let fixed_blog_name = fixed_blog_name_clone_2.clone(); - let mut real_title = "Unknown"; - let mut created = ""; - - xml.add_item(&{ - let mut builder = XmlItem::builder(); - - builder.add_field( - "link", - &format!( - "{ROOT_GEMINI_URL}/blog/{}/{}", - fixed_blog_name, - title.to_lowercase() - ), - ); - builder.add_field("description", &contents); - builder.add_field( - "guid", - &format!( - "{ROOT_GEMINI_URL}/blog/{}/{}", - fixed_blog_name, - title.to_lowercase() - ), - ); + 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}" + ) +} - if let Some(configuration) = &config { - if let Some(posts) = configuration.posts() { - if let Some(post) = posts.get(&title) { - if let Some(name) = post.name() { - real_title = name; - } +fn slugify(text: &str) -> String { text.replace(' ', "_").to_lowercase() } - if let Some(date) = post.created() { - builder.add_field("pubDate", date); +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(); - created = date; - } - } - } - } + xml.add_field("title", &category.title); + xml.add_field("link", &format!("{ROOT_GEMINI_URL}/blog/{blog_slug}")); - builder.add_field("title", real_title); + if let Some(ref description_text) = category.description { + xml.add_field("description", description_text); + } - builder - }); + 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")); - let link = format!("/blog/{}/{}", fixed_blog_name, title.to_lowercase()); - let title = format!("{name}, {real_title}"); + for post in posts.iter().filter(|post| post.blog_id == category.notion_id) { + let post_slug = slugify(&post.title); - (*POSTS.lock().unwrap()).push(Post::new( - title.clone(), - link.clone(), - created.to_string(), - )); - track_mount( - router, - &link, - &format!( - "{} ― {}", - title, - if header.1.is_empty() { - "An entry to one of Fuwn's blogs".to_string() - } else { - header.1 - } - ), - move |context| { - success(&format!("{}\n{}", header.0, contents,), &context) - }, + 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); - track_mount( - router, - &format!("/blog/{fixed_blog_name}.xml"), - &format!("Really Simple Syndication for the {name} blog"), - move |_| { - windmark::response::Response::success(xml.to_string()) - .with_mime("text/rss+xml") - .clone() - }, - ); + 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 construct_header( - config: Option<&Blog>, - name: &str, -) -> Result<(String, String), ()> { - let post = - if let Some(posts) = config.cloned().unwrap_or_default().posts().cloned() { - if let Some(post) = posts.get(name) { - post.clone() - } else { - return Err(()); +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<BlogCategory> = 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(), } - } else { - return Err(()); - }; - - macro_rules! field { - ($getter:ident, $format:literal) => { - if post.$getter().is_some() { - format!($format, post.$getter().clone().unwrap()) - } else { - "".to_string() + }) + .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<BlogPost> = 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(); - macro_rules! any_is_some { - ($($getter:ident),*) => { - $(post.$getter().is_some() ||)* false - }; + { + 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(), + )); + } } - Ok(( - format!( - "# {}\n{}{}{}{}{}", - post.name().cloned().unwrap_or_else(|| name.to_string()), - if any_is_some![author, created, last_modified, description] { - "\n" - } else { - "" + *BLOG_CATEGORIES.write().unwrap() = categories; + *BLOG_POSTS.write().unwrap() = fetched_posts; + + info!("fetched blog data from Notion"); +} + +pub fn refresh_loop() { + info!("spawned Notion blog refresh loop"); + + loop { + std::thread::sleep(std::time::Duration::from_secs( + #[cfg(debug_assertions)] + 5, + #[cfg(not(debug_assertions))] + { + 60 * 5 }, - field!(author, "Written by {}"), - field!(created, " on {}"), - field!(last_modified, " (last modified on {})\n"), - field!(description, "\n{}\n"), - ), - post.description().cloned().unwrap_or_default(), - )) + )); + + match std::panic::catch_unwind(fetch_from_notion) { + Ok(()) => info!("refreshed blog data from Notion"), + Err(_) => warn!("failed to refresh blog data from Notion"), + } + } } diff --git a/src/notion.rs b/src/notion.rs new file mode 100644 index 0000000..74bde89 --- /dev/null +++ b/src/notion.rs @@ -0,0 +1,207 @@ +use serde::Deserialize; + +const NOTION_API_VERSION: &str = "2022-06-28"; + +#[derive(Deserialize)] +pub struct QueryResponse { + pub results: Vec<Page>, + pub has_more: bool, +} + +#[derive(Deserialize)] +pub struct Page { + pub id: String, + pub properties: serde_json::Value, +} + +#[derive(Deserialize)] +pub struct BlockChildrenResponse { + pub results: Vec<Block>, + pub has_more: bool, +} + +#[derive(Deserialize)] +pub struct Block { + #[serde(rename = "type")] + pub kind: String, + #[serde(flatten)] + pub content: serde_json::Value, + pub id: String, +} + +pub fn extract_title(properties: &serde_json::Value, field: &str) -> String { + properties[field]["title"] + .as_array() + .and_then(|rich_text_array| rich_text_array.first()) + .and_then(|rich_text_element| rich_text_element["plain_text"].as_str()) + .unwrap_or("") + .to_string() +} + +pub fn extract_rich_text( + properties: &serde_json::Value, + field: &str, +) -> String { + properties[field]["rich_text"] + .as_array() + .map(|rich_text_array| { + rich_text_array + .iter() + .filter_map(|rich_text_element| { + rich_text_element["plain_text"].as_str() + }) + .collect::<Vec<_>>() + .join("") + }) + .unwrap_or_default() +} + +pub fn extract_number( + properties: &serde_json::Value, + field: &str, +) -> Option<u8> { + properties[field]["number"] + .as_u64() + .and_then(|number_value| u8::try_from(number_value).ok()) +} + +pub fn extract_date(properties: &serde_json::Value, field: &str) -> String { + properties[field]["date"]["start"].as_str().unwrap_or("").to_string() +} + +pub fn extract_relation_ids( + properties: &serde_json::Value, + field: &str, +) -> Vec<String> { + properties[field]["relation"] + .as_array() + .map(|relation_array| { + relation_array + .iter() + .filter_map(|relation_entry| { + relation_entry["id"].as_str().map(String::from) + }) + .collect() + }) + .unwrap_or_default() +} + +pub fn format_notion_date(iso_date: &str) -> String { + if iso_date.len() < 10 { + return iso_date.to_string(); + } + + let year = &iso_date[0..4]; + let month = &iso_date[5..7]; + let day = &iso_date[8..10]; + + format!("{year}. {month}. {day}.") +} + +pub fn extract_block_plain_text(block: &Block) -> String { + let block_content = &block.content[&block.kind]; + + block_content["rich_text"] + .as_array() + .map(|rich_text_array| { + rich_text_array + .iter() + .filter_map(|rich_text_element| { + rich_text_element["plain_text"].as_str() + }) + .collect::<Vec<_>>() + .join("") + }) + .unwrap_or_default() +} + +pub fn query_database( + http_client: &reqwest::blocking::Client, + api_key: &str, + database_identifier: &str, +) -> Result<Vec<Page>, reqwest::Error> { + let mut all_pages = Vec::new(); + let mut start_cursor: Option<String> = None; + + loop { + let mut request_body = serde_json::json!({}); + + if let Some(ref cursor) = start_cursor { + request_body["start_cursor"] = serde_json::json!(cursor); + } + + let response = http_client + .post(format!( + "https://api.notion.com/v1/databases/{database_identifier}/query" + )) + .header("Authorization", format!("Bearer {api_key}")) + .header("Notion-Version", NOTION_API_VERSION) + .json(&request_body) + .send()? + .json::<QueryResponse>()?; + let has_more = response.has_more; + + all_pages.extend(response.results); + + if !has_more { + break; + } + + start_cursor = all_pages.last().map(|last_page| last_page.id.clone()); + } + + Ok(all_pages) +} + +pub fn fetch_page_content( + http_client: &reqwest::blocking::Client, + api_key: &str, + page_identifier: &str, +) -> Result<String, reqwest::Error> { + let mut all_lines = Vec::new(); + let mut start_cursor: Option<String> = None; + + loop { + let mut request_url = format!( + "https://api.notion.com/v1/blocks/{page_identifier}/children?page_size=100" + ); + + if let Some(ref cursor) = start_cursor { + request_url.push_str(&format!("&start_cursor={cursor}")); + } + + let response = http_client + .get(&request_url) + .header("Authorization", format!("Bearer {api_key}")) + .header("Notion-Version", NOTION_API_VERSION) + .send()? + .json::<BlockChildrenResponse>()?; + let has_more = response.has_more; + + for block in &response.results { + let line = match block.kind.as_str() { + "heading_1" => format!("# {}", extract_block_plain_text(block)), + "heading_2" => format!("## {}", extract_block_plain_text(block)), + "heading_3" => format!("### {}", extract_block_plain_text(block)), + "bulleted_list_item" | "numbered_list_item" => { + format!("* {}", extract_block_plain_text(block)) + } + "quote" => format!("> {}", extract_block_plain_text(block)), + "code" => format!("```\n{}\n```", extract_block_plain_text(block)), + "divider" => "---".to_string(), + _ => extract_block_plain_text(block), + }; + + all_lines.push(line); + } + + if !has_more { + break; + } + + start_cursor = + response.results.last().map(|last_block| last_block.id.clone()); + } + + Ok(all_lines.join("\n")) +} |