use chrono::{DateTime, Utc}; use pulldown_cmark::{html, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::process::Command; use std::sync::Arc; use tokio::sync::RwLock; #[derive(Debug, Clone)] pub struct Post { pub name: String, pub title: String, #[allow(dead_code)] pub filename: String, #[allow(dead_code)] pub markdown_content: String, pub html_content: String, pub created_at: DateTime, pub modified_at: DateTime, } struct Entry { #[allow(dead_code)] short_name: String, file_path: String, created_at: DateTime, modified_at: DateTime, status: i32, } pub struct PostManager { root_dir: PathBuf, posts_dir_rel: String, posts: HashMap>>, no_cache: bool, } impl PostManager { pub fn new(root_dir: &str, no_cache: bool) -> Result { let root_dir = PathBuf::from(root_dir); let posts_dir_rel = "posts"; let mut manager = PostManager { root_dir, posts_dir_rel: posts_dir_rel.to_string(), posts: HashMap::new(), no_cache, }; manager.refresh_posts()?; Ok(manager) } pub fn get_root_dir(&self) -> &PathBuf { &self.root_dir } fn query_posts(&self) -> Result, String> { let output = Command::new("git") .arg("log") .arg("--raw") .arg("--no-merges") .arg("--pretty=%h - %ad - %s") .arg("--date=unix") .arg("--") .arg(self.posts_dir_rel.clone()) .current_dir(&self.root_dir) .output() .map_err(|e| format!("Failed to execute git whatchanged: {}", e))?; if !output.status.success() { return Err(format!( "Git whatchanged command failed: {}", String::from_utf8_lossy(&output.stderr) )); } let log_output = String::from_utf8_lossy(&output.stdout); let mut result: HashMap = HashMap::new(); let mut current_timestamp: Option> = None; for line in log_output.lines() { let line = line.trim(); // Skip empty lines if line.is_empty() { continue; } // Parse commit header line: "f4fcf0e - 1761305168 - New post" if !line.starts_with(':') { if let Some(dash_pos) = line.find(" - ") { let after_first_dash = &line[dash_pos + 3..]; if let Some(second_dash_pos) = after_first_dash.find(" - ") { let timestamp_str = after_first_dash[..second_dash_pos].trim(); if let Ok(timestamp) = timestamp_str.parse::() { current_timestamp = DateTime::from_timestamp(timestamp, 0); eprintln!("Timestamp: {:?} ({})", current_timestamp, timestamp); } } } continue; } // Parse file change line: ":000000 100644 0000000 6bdad65 A posts/hello-world-2.md" if line.starts_with(':') { eprintln!("Parsing line: {}", line); if let Some(timestamp) = current_timestamp { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() >= 6 { let status = parts[4]; // A (add), D (delete), M (modify) let file_path = parts[5]; let fn_ = file_path .strip_prefix("posts/") .and_then(|s| s.strip_suffix(".md")); if let Some(name) = fn_ { let entry = result.entry(name.to_string()).or_insert(Entry { short_name: name.to_string(), file_path: file_path.to_string(), created_at: timestamp, modified_at: timestamp, status: if status == "D" { -1 } else { 1 }, }); // Always use the oldest timestamp for posts creation dates entry.created_at = timestamp; } } } else { eprintln!("Invalid git log output, expected prior timestamp: {}", line); } } } Ok(result) } pub fn refresh_posts(&mut self) -> Result<(), String> { self.posts.clear(); // Get timestamps from git history let query_result = self.query_posts()?; for (name, entry) in query_result { if entry.status != 1 { continue; } let markdown_content = fs::read_to_string(&entry.file_path) .map_err(|e| format!("Failed to read post file: {}", e))?; let (html_content, title) = markdown_to_html(&markdown_content); let post = Post { name: name.clone(), title: title, filename: entry.file_path, markdown_content, html_content, created_at: entry.created_at, modified_at: entry.modified_at, }; eprintln!("Loaded post: {} ({})", name, entry.created_at); self.posts.insert(name, Arc::new(RwLock::new(post))); } println!("Loaded {} posts", self.posts.len()); Ok(()) } pub async fn get_post(&self, name: &str) -> Option { let post_lock = self.posts.get(name)?; // If no_cache is enabled, always regenerate if self.no_cache { self.refresh_post_cache(name, post_lock).await; } let post = post_lock.read().await; Some(post.clone()) } async fn refresh_post_cache(&self, name: &str, post_lock: &Arc>) { let mut post = post_lock.write().await; let filename = post.filename.clone(); if let Ok(markdown_content) = fs::read_to_string(&filename) { let (html_content, title) = markdown_to_html(&markdown_content); post.html_content = html_content; post.title = title; post.markdown_content = markdown_content; eprintln!("Refreshed post '{}'", name); } } // Get all posts, sorted by creation date pub async fn get_all_posts(&self) -> Vec { let mut posts = Vec::new(); for (name, post_lock) in &self.posts { // If no_cache is enabled, always regenerate if self.no_cache { self.refresh_post_cache(name, post_lock).await; } let post = post_lock.read().await; posts.push(post.clone()); } posts.sort_by(|a, b| b.created_at.cmp(&a.created_at)); posts } // Get the timstamp of when the blog was most recently updated // derived from latest post update pub async fn get_update_timestamp(&self) -> Result, String> { let posts = self.get_all_posts().await; let mut posts_sorted: Vec<&Post> = posts.iter().collect(); posts_sorted.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); Ok(posts_sorted .first() .ok_or("No posts found".to_string())? .created_at) } pub async fn get_posts_limited(&self, limit: usize) -> Vec { let mut posts = self.get_all_posts().await; posts.truncate(limit); posts } } fn markdown_title(markdown: &str) -> Option { let parser = Parser::new(markdown); let mut in_tag = false; for event in parser { match event { Event::Start(Tag::Heading { level: HeadingLevel::H1, .. }) => in_tag = true, Event::End(TagEnd::Heading(HeadingLevel::H1)) => in_tag = false, Event::Text(txt) => { if in_tag { return Some(txt.to_string()); } } _ => {}, } } None } fn markdown_to_html(markdown: &str) -> (String, String) { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); options.insert(Options::ENABLE_FOOTNOTES); options.insert(Options::ENABLE_TASKLISTS); options.insert(Options::ENABLE_SMART_PUNCTUATION); let title = markdown_title(markdown).unwrap_or("unknown".to_string()); let parser = Parser::new_ext(markdown, options); let mut html_output = String::new(); html::push_html(&mut html_output, parser); (html_output, title) }