aboutsummaryrefslogtreecommitdiff
path: root/src/modules
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-11 23:43:28 -0800
committerFuwn <[email protected]>2026-02-11 23:43:31 -0800
commit17475e06c8822c854dcfa1335f44957b6a3eb629 (patch)
tree4e85234cf29e54ef747f1dc01ad2c523f18bd692 /src/modules
parentchore: Update CI references to updated Rust toolchain channel (diff)
downloadlocus-17475e06c8822c854dcfa1335f44957b6a3eb629.tar.xz
locus-17475e06c8822c854dcfa1335f44957b6a3eb629.zip
feat: Replace static blog system with Notion-backed dynamic content
Diffstat (limited to 'src/modules')
-rw-r--r--src/modules/blog.rs2
-rw-r--r--src/modules/blog/config.rs65
-rw-r--r--src/modules/blog/module.rs656
3 files changed, 339 insertions, 384 deletions
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"),
+ }
+ }
}