Files
blog/src/post_manager.rs
2025-10-29 17:15:56 +08:00

230 lines
7.5 KiB
Rust

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<Utc>,
pub modified_at: DateTime<Utc>,
}
struct Entry {
#[allow(dead_code)]
short_name: String,
file_path: String,
created_at: DateTime<Utc>,
modified_at: DateTime<Utc>,
status: i32,
}
pub struct PostManager {
root_dir: PathBuf,
posts_dir_rel: String,
posts: HashMap<String, Post>,
}
impl PostManager {
pub fn new(root_dir: &str) -> Result<Self, String> {
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<HashMap<String, Entry>, 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<String, Entry> = HashMap::new();
let mut current_timestamp: Option<DateTime<Utc>> = 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::<i64>() {
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<DateTime<Utc>, 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<String> {
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)
}