use chrono::{DateTime, Utc}; use pulldown_cmark::{html, Event, HeadingLevel, Options, Parser, Tag}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::process::Command; #[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, } impl PostManager { pub fn new(root_dir: &str) -> 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(), }; 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, post); } println!("Loaded {} posts", self.posts.len()); Ok(()) } pub fn get_post(&self, name: &str) -> Option<&Post> { self.posts.get(name) } // Get all posts, sorted by creation date pub fn get_all_posts(&self) -> Vec<&Post> { let mut posts: Vec<&Post> = self.posts.values().collect(); 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 fn get_update_timestamp(&self) -> Result, String> { let mut posts: Vec<&Post> = self.posts.values().collect(); posts.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); Ok(posts .first() .ok_or("No posts found".to_string())? .created_at) } pub fn get_posts_limited(&self, limit: usize) -> Vec<&Post> { let mut posts = self.get_all_posts(); posts.truncate(limit); posts } } fn markdown_title(markdown: &str) -> Option { let parser = Parser::new(markdown); for event in parser { if let Event::Start(tag) = event { if let Tag::Heading { level, id, .. } = tag { if level == HeadingLevel::H1 { if let Some(str) = id { return Some(str.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) }