X-Git-Url: https://git.r.bdr.sh/rbdr/blog/blobdiff_plain/2998247083406f914b3647cedd19abf5507bf2c6..9c537e473ac117eadad86b68d21f3aaf8d8937c4:/src/archiver/mod.rs diff --git a/src/archiver/mod.rs b/src/archiver/mod.rs index e69de29..f809b8c 100644 --- a/src/archiver/mod.rs +++ b/src/archiver/mod.rs @@ -0,0 +1,269 @@ +mod gemini; +mod gopher; +mod raw; + +use crate::template::{Context, Value}; +use std::collections::HashMap; +use std::fs::read_dir; +use std::io::Result; +use std::path::{Path, PathBuf}; +use time::{format_description::FormatItem, macros::format_description, OffsetDateTime}; + +const DATE_FORMAT: &[FormatItem<'_>] = format_description!("[year]-[month]-[day]"); + +struct ArchiveEntry { + id: String, + slug: String, +} + +type Archiver = fn(&Path, &Path, &Path, &Context) -> Result<()>; + +impl ArchiveEntry { + pub fn to_template_context(archive_entries: &[ArchiveEntry]) -> Context { + let mut context = HashMap::new(); + + let archive_entries_collection = archive_entries + .iter() + .map(ArchiveEntry::to_template_value) + .collect(); + + context.insert( + "archive_length".to_string(), + Value::Unsigned(archive_entries.len().try_into().unwrap()), + ); + context.insert( + "posts".to_string(), + Value::Collection(archive_entries_collection), + ); + + context + } + + pub fn to_template_value(&self) -> Context { + let mut context = HashMap::new(); + + context.insert("id".to_string(), Value::String(self.id.clone())); + + context.insert("slug".to_string(), Value::String(self.slug.clone())); + + if let Some(title) = self.title() { + context.insert("title".to_string(), Value::String(title)); + } + + context + } + + fn title(&self) -> Option { + let date = OffsetDateTime::from_unix_timestamp_nanos( + (self.id.parse::().ok()? * 1_000_000).into(), + ) + .ok()?; + let short_date = date.format(&DATE_FORMAT).ok()?; + let title = self.slug.replace('-', " "); + Some(format!("{short_date} {title}")) + } +} + +fn read_archive(archive_directory: &PathBuf) -> Vec { + let mut archive_entries = Vec::new(); + if let Ok(entries) = read_dir(archive_directory) { + for entry in entries.filter_map(Result::ok) { + let entry_path = entry.path(); + let post_id = entry.file_name(); + if let Ok(entry_type) = entry.file_type() { + if entry_type.is_dir() { + if let Ok(candidates) = read_dir(&entry_path) { + for candidate in candidates.filter_map(Result::ok) { + let candidate_path = candidate.path(); + match candidate_path.extension() { + Some(extension) => { + if extension == "gmi" { + if let Some(slug) = candidate_path.file_stem() { + if let (Some(post_id), Some(slug)) = + (post_id.to_str(), slug.to_str()) + { + archive_entries.push(ArchiveEntry { + id: post_id.to_string(), + slug: slug.to_string(), + }); + } + } + } + } + _ => continue, + } + } + } + } + } + } + } + + archive_entries.sort_by(|a, b| b.id.cmp(&a.id)); + archive_entries +} + +pub fn archive( + archive_directory: &PathBuf, + template_directory: &Path, + output_directory: &Path, +) -> Result<()> { + let archivers = available_archivers(); + let archive_entries = read_archive(archive_directory); + let context = ArchiveEntry::to_template_context(&archive_entries); + for archiver in archivers { + archiver( + archive_directory, + template_directory, + output_directory, + &context, + )?; + } + Ok(()) +} + +fn available_archivers() -> Vec { + vec![raw::archive, gemini::archive, gopher::archive] +} + +#[cfg(test)] +mod tests { + use std::fs::create_dir_all; + + use super::*; + use crate::template::Value; + + use test_utilities::*; + + // Archive template values + + #[test] + fn test_creates_context_with_empty_archive_entries() { + let context = ArchiveEntry::to_template_context(&[]); + + assert_eq!(context["archive_length"], Value::Unsigned(0)); + if let Value::Collection(posts) = &context["posts"] { + assert!(posts.is_empty()); + } else { + panic!("The posts context was not the right type."); + } + } + + #[test] + fn test_creates_context_with_post_slice() { + let archive_entry = ArchiveEntry { + id: "you-know-what-is-cool".to_string(), + slug: "the-archiving-shelves-with-a-spinning-lever-to-move-them".to_string(), + }; + + let context = ArchiveEntry::to_template_context(&[archive_entry]); + + assert_eq!(context["archive_length"], Value::Unsigned(1)); + if let Value::Collection(posts) = &context["posts"] { + if let Some(post) = posts.first() { + assert_eq!( + post["id"], + Value::String("you-know-what-is-cool".to_string()) + ); + } else { + panic!("The template context had no posts"); + } + } else { + panic!("The posts context was not the right type."); + } + } + + #[test] + fn test_converts_archive_entry_without_title_to_context() { + let archive_entry = ArchiveEntry { + id: "they-always-show-them-in-spy-films".to_string(), + slug: "or-like-universities".to_string(), + }; + + let context = archive_entry.to_template_value(); + + assert_eq!( + context["id"], + Value::String("they-always-show-them-in-spy-films".to_string()) + ); + assert_eq!( + context["slug"], + Value::String("or-like-universities".to_string()) + ); + assert!(!context.contains_key("title")); + } + + #[test] + fn test_converts_archive_entry_with_title_to_context() { + let archive_entry = ArchiveEntry { + id: "1736035200000".to_string(), + slug: "need-a-warehouse".to_string(), + }; + + let context = archive_entry.to_template_value(); + + assert_eq!(context["id"], Value::String("1736035200000".to_string())); + assert_eq!( + context["slug"], + Value::String("need-a-warehouse".to_string()) + ); + assert_eq!( + context["title"], + Value::String("2025-01-05 need a warehouse".to_string()) + ); + } + + // Archive Finder + #[test] + fn test_finds() { + let test_dir = setup_test_dir(); + let archive_dir = test_dir.join("archive"); + let template_dir = test_dir.join("templates"); + let output_dir = test_dir.join("output"); + + let first_post_dir = archive_dir.join("1736035200000"); + create_dir_all(&first_post_dir).expect("Could not first create post test directory"); + create_test_file( + &first_post_dir.join("my-very-cool-post.gmi"), + "is full of flowers", + ); + + let second_post_dir = archive_dir.join("1736121600000"); + create_dir_all(&second_post_dir).expect("Could not create second post test directory"); + create_test_file( + &second_post_dir.join("my-very-sad-post.gmi"), + "is full of regrets", + ); + + let bad_file_dir = archive_dir.join("1736121600001"); + create_dir_all(&bad_file_dir).expect("Could not create bad file test directory"); + create_test_file(&bad_file_dir.join("my-very-bad-file"), "is full of lies"); + + create_dir_all(&template_dir).expect("Could not create template test directory"); + create_dir_all(&output_dir).expect("Could not create output test directory"); + + archive(&archive_dir, &template_dir, &output_dir).expect("Archive failed"); + + assert_file_contains( + &output_dir.join("index.gmi"), + "\n=> ./1736035200000/my-very-cool-post.gmi 2025-01-05 my very cool post", + ); + assert_file_contains( + &output_dir.join("index.gmi"), + "\n=> ./1736121600000/my-very-sad-post.gmi 2025-01-06 my very sad post", + ); + assert_file_contents( + &output_dir.join("1736035200000/my-very-cool-post.gmi"), + "is full of flowers", + ); + assert_file_contents( + &output_dir.join("1736121600000/my-very-sad-post.gmi"), + "is full of regrets", + ); + assert!(&output_dir.join("1736121600001/my-very-bad-file").exists()); + assert_file_does_not_contain( + &output_dir.join("index.gmi"), + "1736121600001/my-very-bad-file", + ); + } +}