Initial blog setup
This commit is contained in:
264
src/post_manager.rs
Normal file
264
src/post_manager.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
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<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, Arc<RwLock<Post>>>,
|
||||
no_cache: bool,
|
||||
}
|
||||
|
||||
impl PostManager {
|
||||
pub fn new(root_dir: &str, no_cache: bool) -> 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(),
|
||||
no_cache,
|
||||
};
|
||||
|
||||
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, Arc::new(RwLock::new(post)));
|
||||
}
|
||||
|
||||
println!("Loaded {} posts", self.posts.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_post(&self, name: &str) -> Option<Post> {
|
||||
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<RwLock<Post>>) {
|
||||
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<Post> {
|
||||
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<DateTime<Utc>, 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<Post> {
|
||||
let mut posts = self.get_all_posts().await;
|
||||
posts.truncate(limit);
|
||||
posts
|
||||
}
|
||||
}
|
||||
|
||||
fn markdown_title(markdown: &str) -> Option<String> {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user