feat: implementation of M1

This commit is contained in:
2026-04-04 14:18:09 +02:00
parent fd744573dd
commit 2d07ac7866
44 changed files with 12197 additions and 30 deletions

View File

@@ -0,0 +1,53 @@
use std::fs;
use std::io::{self, Write};
use std::path::Path;
/// Write `content` to `path` atomically: write to a temp file in the same
/// directory, then rename. Creates parent directories if missing.
pub fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp_path = path.with_extension("tmp");
let mut file = fs::File::create(&tmp_path)?;
file.write_all(content)?;
file.sync_all()?;
fs::rename(&tmp_path, path)?;
Ok(())
}
/// Convenience wrapper for UTF-8 string content.
pub fn atomic_write_str(path: &Path, content: &str) -> io::Result<()> {
atomic_write(path, content.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn write_and_read_back() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.txt");
atomic_write_str(&path, "hello world").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "hello world");
}
#[test]
fn creates_parent_directories() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a").join("b").join("c.txt");
atomic_write_str(&path, "nested").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "nested");
}
#[test]
fn overwrites_existing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.txt");
atomic_write_str(&path, "v1").unwrap();
atomic_write_str(&path, "v2").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
}
}

View File

@@ -0,0 +1,813 @@
use crate::model::{Post, PostTranslation};
use crate::util::timestamp::{unix_ms_to_iso, iso_to_unix_ms};
/// Split content at `---` delimiters into (yaml, body).
/// Returns `None` if the content does not start with `---`.
pub fn split_frontmatter(input: &str) -> Option<(&str, &str)> {
let trimmed = input.trim_start_matches('\u{feff}'); // strip BOM
if !trimmed.starts_with("---") {
return None;
}
let after_first = &trimmed[3..];
let after_first = after_first.strip_prefix('\n').unwrap_or(after_first);
let end = after_first.find("\n---")?;
let yaml = &after_first[..end];
let rest = &after_first[end + 4..]; // skip "\n---"
let body = rest.strip_prefix('\n').unwrap_or(rest);
Some((yaml, body))
}
/// Split content at Python docstring `"""` delimiters (for legacy .py scripts).
/// The YAML frontmatter is inside: `"""\n---\n{yaml}\n---\n"""`
pub fn split_docstring_frontmatter(input: &str) -> Option<(&str, &str)> {
let trimmed = input.trim_start_matches('\u{feff}');
if !trimmed.starts_with("\"\"\"") {
return None;
}
let after_open = &trimmed[3..];
let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
// Find closing """
let close_pos = after_open.find("\"\"\"")?;
let inside = &after_open[..close_pos].trim();
// Inside should be ---\n{yaml}\n---
let yaml_content = split_frontmatter(inside)?;
let body_start = close_pos + 3;
let rest = &after_open[body_start..];
let body = rest.strip_prefix('\n').unwrap_or(rest);
Some((yaml_content.0, body))
}
/// Format frontmatter + body into a complete file string.
pub fn format_frontmatter(yaml: &str, body: &str) -> String {
format!("---\n{yaml}\n---\n{body}")
}
// --- Post Frontmatter ---
/// Parsed post frontmatter fields (camelCase for YAML compatibility).
#[derive(Debug, Clone)]
pub struct PostFrontmatter {
pub id: String,
pub title: String,
pub slug: String,
pub status: String,
pub created_at: i64,
pub updated_at: i64,
pub tags: Vec<String>,
pub categories: Vec<String>,
pub excerpt: Option<String>,
pub author: Option<String>,
pub language: Option<String>,
pub template_slug: Option<String>,
pub do_not_translate: bool,
pub published_at: Option<i64>,
}
impl PostFrontmatter {
/// Build from a Post model.
pub fn from_post(post: &Post) -> Self {
Self {
id: post.id.clone(),
title: post.title.clone(),
slug: post.slug.clone(),
status: serde_json::to_string(&post.status)
.unwrap_or_default()
.trim_matches('"')
.to_string(),
created_at: post.created_at,
updated_at: post.updated_at,
tags: post.tags.clone(),
categories: post.categories.clone(),
excerpt: post.excerpt.clone(),
author: post.author.clone(),
language: post.language.clone(),
template_slug: post.template_slug.clone(),
do_not_translate: post.do_not_translate,
published_at: post.published_at,
}
}
/// Serialize to YAML string (matching TypeScript gray-matter output).
pub fn to_yaml(&self) -> String {
let mut lines = Vec::new();
lines.push(format!("id: {}", self.id));
lines.push(format!("title: {}", yaml_string_value(&self.title)));
lines.push(format!("slug: {}", self.slug));
lines.push(format!("status: {}", self.status));
lines.push(format!("createdAt: '{}'", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(self.updated_at)));
// Tags as YAML list
if self.tags.is_empty() {
lines.push("tags: []".to_string());
} else {
lines.push("tags:".to_string());
for tag in &self.tags {
lines.push(format!(" - {}", yaml_string_value(tag)));
}
}
// Categories as YAML list
if self.categories.is_empty() {
lines.push("categories: []".to_string());
} else {
lines.push("categories:".to_string());
for cat in &self.categories {
lines.push(format!(" - {}", yaml_string_value(cat)));
}
}
// Conditional fields (only when truthy)
if let Some(ref excerpt) = self.excerpt {
if !excerpt.is_empty() {
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
}
}
if let Some(ref author) = self.author {
if !author.is_empty() {
lines.push(format!("author: {}", yaml_string_value(author)));
}
}
if let Some(ref language) = self.language {
if !language.is_empty() {
lines.push(format!("language: {language}"));
}
}
if self.do_not_translate {
lines.push("doNotTranslate: true".to_string());
}
if let Some(ref template_slug) = self.template_slug {
if !template_slug.is_empty() {
lines.push(format!("templateSlug: {template_slug}"));
}
}
if let Some(published_at) = self.published_at {
lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at)));
}
lines.join("\n")
}
/// Parse from a YAML string.
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc
.as_mapping()
.ok_or("frontmatter is not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
})
};
let get_string_list = |key: &str| -> Vec<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
};
let get_timestamp = |key: &str| -> Result<i64, String> {
let s = get_str(key).ok_or(format!("missing required field '{key}'"))?;
iso_to_unix_ms(&s)
};
Ok(Self {
id: get_str("id").ok_or("missing 'id'")?,
title: get_str("title").ok_or("missing 'title'")?,
slug: get_str("slug").ok_or("missing 'slug'")?,
status: get_str("status").ok_or("missing 'status'")?,
created_at: get_timestamp("createdAt")?,
updated_at: get_timestamp("updatedAt")?,
tags: get_string_list("tags"),
categories: get_string_list("categories"),
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
author: get_str("author").filter(|s| !s.is_empty()),
language: get_str("language").filter(|s| !s.is_empty()),
template_slug: get_str("templateSlug").filter(|s| !s.is_empty()),
do_not_translate: get_str("doNotTranslate")
.map(|s| s == "true")
.unwrap_or(false),
published_at: get_str("publishedAt")
.and_then(|s| iso_to_unix_ms(&s).ok()),
})
}
}
/// Write a complete post file (frontmatter + body).
pub fn write_post_file(post: &Post, body: &str) -> String {
let fm = PostFrontmatter::from_post(post);
format_frontmatter(&fm.to_yaml(), body)
}
/// Read a complete post file, returning frontmatter + body.
pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content)
.ok_or("no frontmatter delimiters found")?;
let fm = PostFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
}
// --- Translation Frontmatter ---
/// Parsed translation frontmatter.
#[derive(Debug, Clone)]
pub struct TranslationFrontmatter {
pub translation_for: String,
pub language: String,
pub title: String,
pub excerpt: Option<String>,
}
impl TranslationFrontmatter {
pub fn from_translation(t: &PostTranslation) -> Self {
Self {
translation_for: t.translation_for.clone(),
language: t.language.clone(),
title: t.title.clone(),
excerpt: t.excerpt.clone(),
}
}
pub fn to_yaml(&self) -> String {
let mut lines = Vec::new();
lines.push(format!("translationFor: {}", self.translation_for));
lines.push(format!("language: {}", self.language));
lines.push(format!("title: {}", yaml_string_value(&self.title)));
if let Some(ref excerpt) = self.excerpt {
if !excerpt.is_empty() {
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
}
}
lines.join("\n")
}
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc
.as_mapping()
.ok_or("frontmatter is not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
};
Ok(Self {
translation_for: get_str("translationFor").ok_or("missing 'translationFor'")?,
language: get_str("language").ok_or("missing 'language'")?,
title: get_str("title").ok_or("missing 'title'")?,
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
})
}
}
/// Write a complete translation file (frontmatter + body).
pub fn write_translation_file(translation: &PostTranslation, body: &str) -> String {
let fm = TranslationFrontmatter::from_translation(translation);
format_frontmatter(&fm.to_yaml(), body)
}
/// Read a complete translation file.
pub fn read_translation_file(content: &str) -> Result<(TranslationFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content)
.ok_or("no frontmatter delimiters found")?;
let fm = TranslationFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
}
// --- Template Frontmatter ---
/// Parsed template frontmatter (double-quoted strings, matching TypeScript output).
#[derive(Debug, Clone)]
pub struct TemplateFrontmatter {
pub id: String,
pub project_id: Option<String>,
pub slug: String,
pub title: String,
pub kind: String,
pub enabled: bool,
pub version: i32,
pub created_at: i64,
pub updated_at: i64,
}
impl TemplateFrontmatter {
/// Serialize to YAML with double-quoted strings (matching TypeScript).
pub fn to_yaml(&self) -> String {
let mut lines = Vec::new();
lines.push(format!("id: \"{}\"", self.id));
if let Some(ref pid) = self.project_id {
lines.push(format!("projectId: \"{pid}\""));
}
lines.push(format!("slug: \"{}\"", self.slug));
lines.push(format!("title: \"{}\"", self.title));
lines.push(format!("kind: \"{}\"", self.kind));
lines.push(format!("enabled: {}", self.enabled));
lines.push(format!("version: {}", self.version));
lines.push(format!("createdAt: \"{}\"", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: \"{}\"", unix_ms_to_iso(self.updated_at)));
lines.join("\n")
}
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
})
};
Ok(Self {
id: get_str("id").ok_or("missing 'id'")?,
project_id: get_str("projectId"),
slug: get_str("slug").ok_or("missing 'slug'")?,
title: get_str("title").ok_or("missing 'title'")?,
kind: get_str("kind").ok_or("missing 'kind'")?,
enabled: get_str("enabled").map(|s| s == "true").unwrap_or(true),
version: get_str("version")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1),
created_at: get_str("createdAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'createdAt'")?,
updated_at: get_str("updatedAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'updatedAt'")?,
})
}
}
/// Read a template file (frontmatter + body).
pub fn read_template_file(content: &str) -> Result<(TemplateFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content)
.ok_or("no frontmatter delimiters found")?;
let fm = TemplateFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
}
/// Write a template file (frontmatter + body).
pub fn write_template_file(fm: &TemplateFrontmatter, body: &str) -> String {
format_frontmatter(&fm.to_yaml(), body)
}
// --- Script Frontmatter ---
/// Parsed script frontmatter (double-quoted strings like templates, plus entrypoint).
#[derive(Debug, Clone)]
pub struct ScriptFrontmatter {
pub id: String,
pub project_id: Option<String>,
pub slug: String,
pub title: String,
pub kind: String,
pub entrypoint: String,
pub enabled: bool,
pub version: i32,
pub created_at: i64,
pub updated_at: i64,
}
impl ScriptFrontmatter {
/// Serialize to YAML with double-quoted strings.
pub fn to_yaml(&self) -> String {
let mut lines = Vec::new();
lines.push(format!("id: \"{}\"", self.id));
if let Some(ref pid) = self.project_id {
lines.push(format!("projectId: \"{pid}\""));
}
lines.push(format!("slug: \"{}\"", self.slug));
lines.push(format!("title: \"{}\"", self.title));
lines.push(format!("kind: \"{}\"", self.kind));
lines.push(format!("entrypoint: \"{}\"", self.entrypoint));
lines.push(format!("enabled: {}", self.enabled));
lines.push(format!("version: {}", self.version));
lines.push(format!("createdAt: \"{}\"", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: \"{}\"", unix_ms_to_iso(self.updated_at)));
lines.join("\n")
}
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
})
};
Ok(Self {
id: get_str("id").ok_or("missing 'id'")?,
project_id: get_str("projectId"),
slug: get_str("slug").ok_or("missing 'slug'")?,
title: get_str("title").ok_or("missing 'title'")?,
kind: get_str("kind").ok_or("missing 'kind'")?,
entrypoint: get_str("entrypoint").unwrap_or_else(|| "render".to_string()),
enabled: get_str("enabled").map(|s| s == "true").unwrap_or(true),
version: get_str("version")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1),
created_at: get_str("createdAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'createdAt'")?,
updated_at: get_str("updatedAt")
.and_then(|s| iso_to_unix_ms(&s).ok())
.ok_or("missing 'updatedAt'")?,
})
}
}
/// Read a script file. Supports both `---` (Lua) and `"""` (Python) delimiters.
pub fn read_script_file(content: &str) -> Result<(ScriptFrontmatter, String), String> {
// Try docstring format first (Python scripts)
if let Some((yaml, body)) = split_docstring_frontmatter(content) {
let fm = ScriptFrontmatter::from_yaml(yaml)?;
return Ok((fm, body.to_string()));
}
// Fall back to standard --- format (Lua scripts)
let (yaml, body) = split_frontmatter(content)
.ok_or("no frontmatter delimiters found")?;
let fm = ScriptFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
}
/// Write a script file (always Lua format with --- delimiters).
pub fn write_script_file(fm: &ScriptFrontmatter, body: &str) -> String {
format_frontmatter(&fm.to_yaml(), body)
}
// --- Helpers ---
/// Quote a YAML string value if it contains special characters.
/// Simple values (alphanumeric, hyphens, dots) are left unquoted.
/// Values with colons, quotes, special chars are single-quoted.
fn yaml_string_value(s: &str) -> String {
if s.is_empty() {
return "''".to_string();
}
// Check if the value needs quoting
let needs_quoting = s.contains(':')
|| s.contains('#')
|| s.contains('\'')
|| s.contains('"')
|| s.contains('\n')
|| s.contains('{')
|| s.contains('}')
|| s.contains('[')
|| s.contains(']')
|| s.contains(',')
|| s.contains('&')
|| s.contains('*')
|| s.contains('!')
|| s.contains('|')
|| s.contains('>')
|| s.contains('%')
|| s.contains('@')
|| s.contains('`')
|| s.starts_with(' ')
|| s.ends_with(' ')
|| s.starts_with('-')
|| s.starts_with('?')
|| s == "true"
|| s == "false"
|| s == "null"
|| s == "yes"
|| s == "no"
|| s == "on"
|| s == "off";
if needs_quoting {
// Use single quotes, escaping internal single quotes by doubling them
let escaped = s.replace('\'', "''");
format!("'{escaped}'")
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn fixture_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/compatibility-projects/rfc1437-sample")
}
#[test]
fn split_basic() {
let input = "---\nfoo: bar\n---\nbody text\n";
let (yaml, body) = split_frontmatter(input).unwrap();
assert_eq!(yaml, "foo: bar");
assert_eq!(body, "body text\n");
}
#[test]
fn split_no_frontmatter() {
assert!(split_frontmatter("no frontmatter here").is_none());
}
#[test]
fn split_docstring() {
let input = "\"\"\"\n---\nfoo: bar\n---\n\"\"\"\nbody\n";
let (yaml, body) = split_docstring_frontmatter(input).unwrap();
assert_eq!(yaml, "foo: bar");
assert_eq!(body, "body\n");
}
#[test]
fn parse_esmeralda() {
let path = fixture_dir().join("posts/2005/11/esmeralda.md");
let content = fs::read_to_string(path).unwrap();
let (fm, body) = read_post_file(&content).unwrap();
assert_eq!(fm.id, "40a83ab1-423d-4310-aac4-642d84675007");
assert_eq!(fm.title, "Esmeralda");
assert_eq!(fm.slug, "esmeralda");
assert_eq!(fm.status, "published");
assert_eq!(fm.tags, vec!["fotografie", "makro", "natur", "spinne", "tiere"]);
assert_eq!(fm.categories, vec!["picture"]);
assert_eq!(fm.language.as_deref(), Some("es"));
assert_eq!(fm.published_at, Some(1131883200000));
assert_eq!(fm.created_at, 1131883200000);
assert!(body.contains("Esmeralda"));
}
#[test]
fn parse_ghostty() {
let path = fixture_dir().join("posts/2026/03/ghostty.md");
let content = fs::read_to_string(path).unwrap();
let (fm, _body) = read_post_file(&content).unwrap();
assert_eq!(fm.id, "6745981d-da41-4cfd-80ec-95ad339acf6f");
assert_eq!(fm.title, "Ghostty");
assert_eq!(fm.slug, "ghostty");
assert_eq!(fm.tags, vec!["programmierung", "sysadmin", "mac-os-x"]);
assert_eq!(fm.categories, vec!["aside"]);
assert!(fm.language.is_none());
}
#[test]
fn parse_cmux() {
let path = fixture_dir().join("posts/2026/03/cmux-das-terminal-fur-multitasking.md");
let content = fs::read_to_string(path).unwrap();
let (fm, _body) = read_post_file(&content).unwrap();
assert_eq!(fm.slug, "cmux-das-terminal-fur-multitasking");
assert!(fm.title.contains("cmux"));
}
#[test]
fn parse_esmeralda_en_translation() {
let path = fixture_dir().join("posts/2005/11/esmeralda.en.md");
let content = fs::read_to_string(path).unwrap();
let (fm, body) = read_translation_file(&content).unwrap();
assert_eq!(fm.translation_for, "40a83ab1-423d-4310-aac4-642d84675007");
assert_eq!(fm.language, "en");
assert_eq!(fm.title, "Esmeralda");
assert!(body.contains("Esmeralda"));
}
#[test]
fn parse_esmeralda_de_translation() {
let path = fixture_dir().join("posts/2005/11/esmeralda.de.md");
let content = fs::read_to_string(path).unwrap();
let (fm, _body) = read_translation_file(&content).unwrap();
assert_eq!(fm.language, "de");
}
#[test]
fn roundtrip_post_frontmatter() {
let path = fixture_dir().join("posts/2005/11/esmeralda.md");
let content = fs::read_to_string(path).unwrap();
let (fm, body) = read_post_file(&content).unwrap();
// Write back out
let yaml = fm.to_yaml();
let output = format_frontmatter(&yaml, &body);
// Parse again
let (fm2, body2) = read_post_file(&output).unwrap();
assert_eq!(fm.id, fm2.id);
assert_eq!(fm.title, fm2.title);
assert_eq!(fm.slug, fm2.slug);
assert_eq!(fm.status, fm2.status);
assert_eq!(fm.created_at, fm2.created_at);
assert_eq!(fm.updated_at, fm2.updated_at);
assert_eq!(fm.tags, fm2.tags);
assert_eq!(fm.categories, fm2.categories);
assert_eq!(fm.language, fm2.language);
assert_eq!(fm.published_at, fm2.published_at);
assert_eq!(body, body2);
}
#[test]
fn golden_output_esmeralda() {
let path = fixture_dir().join("posts/2005/11/esmeralda.md");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_post_file(&expected).unwrap();
let yaml = fm.to_yaml();
let actual = format_frontmatter(&yaml, &body);
assert_eq!(actual, expected, "golden output mismatch for esmeralda.md");
}
#[test]
fn golden_output_ghostty() {
let path = fixture_dir().join("posts/2026/03/ghostty.md");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_post_file(&expected).unwrap();
let yaml = fm.to_yaml();
let actual = format_frontmatter(&yaml, &body);
assert_eq!(actual, expected, "golden output mismatch for ghostty.md");
}
#[test]
fn golden_output_translation() {
let path = fixture_dir().join("posts/2005/11/esmeralda.en.md");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_translation_file(&expected).unwrap();
let yaml = fm.to_yaml();
let actual = format_frontmatter(&yaml, &body);
assert_eq!(actual, expected, "golden output mismatch for esmeralda.en.md");
}
#[test]
fn yaml_quoting() {
assert_eq!(yaml_string_value("simple"), "simple");
assert_eq!(yaml_string_value("has: colon"), "'has: colon'");
assert_eq!(yaml_string_value("true"), "'true'");
assert_eq!(yaml_string_value(""), "''");
}
#[test]
fn conditional_fields_omitted_when_empty() {
let fm = PostFrontmatter {
id: "test-id".into(),
title: "Test".into(),
slug: "test".into(),
status: "draft".into(),
created_at: 1131883200000,
updated_at: 1131883200000,
tags: vec![],
categories: vec![],
excerpt: None,
author: None,
language: None,
template_slug: None,
do_not_translate: false,
published_at: None,
};
let yaml = fm.to_yaml();
assert!(!yaml.contains("excerpt"));
assert!(!yaml.contains("author"));
assert!(!yaml.contains("language"));
assert!(!yaml.contains("doNotTranslate"));
assert!(!yaml.contains("templateSlug"));
assert!(!yaml.contains("publishedAt"));
}
#[test]
fn do_not_translate_written_when_true() {
let fm = PostFrontmatter {
id: "test-id".into(),
title: "Test".into(),
slug: "test".into(),
status: "draft".into(),
created_at: 1131883200000,
updated_at: 1131883200000,
tags: vec![],
categories: vec![],
excerpt: None,
author: None,
language: None,
template_slug: None,
do_not_translate: true,
published_at: None,
};
let yaml = fm.to_yaml();
assert!(yaml.contains("doNotTranslate: true"));
}
// --- Template frontmatter tests ---
#[test]
fn parse_fixture_template() {
let path = fixture_dir().join("templates/testvorlage.liquid");
let content = fs::read_to_string(path).unwrap();
let (fm, body) = read_template_file(&content).unwrap();
assert_eq!(fm.id, "38704737-b7e7-4dd4-b010-9208bcf80ef6");
assert_eq!(fm.project_id.as_deref(), Some("1979237c-034d-41f6-99a0-f35eb57b3f6c"));
assert_eq!(fm.slug, "testvorlage");
assert_eq!(fm.title, "Testvorlage");
assert_eq!(fm.kind, "post");
assert!(fm.enabled);
assert_eq!(fm.version, 3);
assert!(body.contains("<div>"));
}
#[test]
fn golden_output_template() {
let path = fixture_dir().join("templates/testvorlage.liquid");
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_template_file(&expected).unwrap();
let actual = write_template_file(&fm, &body);
assert_eq!(actual, expected, "golden output mismatch for testvorlage.liquid");
}
// --- Script frontmatter tests ---
#[test]
fn parse_fixture_script_bgg() {
let path = fixture_dir().join("scripts/bgg_link.py");
let content = fs::read_to_string(path).unwrap();
let (fm, body) = read_script_file(&content).unwrap();
assert_eq!(fm.id, "2b393cae-84b9-426f-b4cf-4902aea79d7d");
assert_eq!(fm.slug, "bgg_link");
assert_eq!(fm.title, "bgg link");
assert_eq!(fm.kind, "transform");
assert_eq!(fm.entrypoint, "normalize_blogmark");
assert!(fm.enabled);
assert_eq!(fm.version, 12);
assert!(body.contains("def normalize_blogmark"));
}
#[test]
fn parse_fixture_script_test() {
let path = fixture_dir().join("scripts/test_script.py");
let content = fs::read_to_string(path).unwrap();
let (fm, body) = read_script_file(&content).unwrap();
assert_eq!(fm.slug, "test_script");
assert_eq!(fm.kind, "utility");
assert_eq!(fm.entrypoint, "main");
assert!(body.contains("print"));
}
#[test]
fn template_frontmatter_roundtrip() {
let fm = TemplateFrontmatter {
id: "test-uuid".into(),
project_id: Some("proj-uuid".into()),
slug: "my-template".into(),
title: "My Template".into(),
kind: "post".into(),
enabled: true,
version: 1,
created_at: 1131883200000,
updated_at: 1131883200000,
};
let yaml = fm.to_yaml();
let parsed = TemplateFrontmatter::from_yaml(&yaml).unwrap();
assert_eq!(parsed.id, fm.id);
assert_eq!(parsed.slug, fm.slug);
assert_eq!(parsed.kind, fm.kind);
assert_eq!(parsed.version, fm.version);
}
#[test]
fn script_frontmatter_roundtrip() {
let fm = ScriptFrontmatter {
id: "test-uuid".into(),
project_id: None,
slug: "my-script".into(),
title: "My Script".into(),
kind: "utility".into(),
entrypoint: "main".into(),
enabled: true,
version: 2,
created_at: 1131883200000,
updated_at: 1131883200000,
};
let yaml = fm.to_yaml();
let parsed = ScriptFrontmatter::from_yaml(&yaml).unwrap();
assert_eq!(parsed.id, fm.id);
assert_eq!(parsed.entrypoint, "main");
assert_eq!(parsed.version, 2);
}
}

View File

@@ -1,5 +1,14 @@
mod slug;
mod checksum;
pub mod timestamp;
pub mod atomic_write;
pub mod paths;
pub mod frontmatter;
pub mod sidecar;
pub mod thumbnail;
pub use slug::{slugify, ensure_unique};
pub use checksum::content_hash;
pub use timestamp::{unix_ms_to_iso, iso_to_unix_ms, year_month_from_unix_ms, year_month_day_from_unix_ms, now_unix_ms};
pub use atomic_write::{atomic_write, atomic_write_str};
pub use paths::*;

View File

@@ -0,0 +1,107 @@
use super::timestamp::year_month_from_unix_ms;
/// Post file path: `posts/YYYY/MM/{slug}.md` from `created_at` unix ms.
pub fn post_file_path(created_at_ms: i64, slug: &str) -> String {
let (y, m) = year_month_from_unix_ms(created_at_ms);
format!("posts/{y}/{m}/{slug}.md")
}
/// Translation file path: `posts/YYYY/MM/{slug}.{lang}.md` from canonical
/// post's `created_at`.
pub fn translation_file_path(canonical_created_at_ms: i64, canonical_slug: &str, language: &str) -> String {
let (y, m) = year_month_from_unix_ms(canonical_created_at_ms);
format!("posts/{y}/{m}/{canonical_slug}.{language}.md")
}
/// Media directory path: `media/YYYY/MM/` from `created_at`.
pub fn media_dir_path(created_at_ms: i64) -> String {
let (y, m) = year_month_from_unix_ms(created_at_ms);
format!("media/{y}/{m}/")
}
/// Media sidecar path: `{binary_file_path}.meta`.
pub fn media_sidecar_path(file_path: &str) -> String {
format!("{file_path}.meta")
}
/// Media translation sidecar path: `{binary_file_path}.{lang}.meta`.
pub fn media_translation_sidecar_path(file_path: &str, language: &str) -> String {
format!("{file_path}.{language}.meta")
}
/// Thumbnail path: `thumbnails/{id[0..2]}/{id}-{size}.{ext}`.
pub fn thumbnail_path(id: &str, size: &str, ext: &str) -> String {
let prefix = &id[..2.min(id.len())];
format!("thumbnails/{prefix}/{id}-{size}.{ext}")
}
/// Template file path: `templates/{slug}.liquid`.
pub fn template_file_path(slug: &str) -> String {
format!("templates/{slug}.liquid")
}
/// Script file path: `scripts/{slug}.lua`.
pub fn script_file_path(slug: &str) -> String {
format!("scripts/{slug}.lua")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn post_path_from_fixture() {
// esmeralda created_at: 2005-11-13T12:00:00.000Z = 1131883200000
assert_eq!(
post_file_path(1131883200000, "esmeralda"),
"posts/2005/11/esmeralda.md"
);
}
#[test]
fn post_path_march_2026() {
// ghostty created_at: 2026-03-13
let ms = 1773583200000_i64; // approx 2026-03-13
let path = post_file_path(ms, "ghostty");
assert!(path.starts_with("posts/2026/03/"));
assert!(path.ends_with("ghostty.md"));
}
#[test]
fn translation_path() {
assert_eq!(
translation_file_path(1131883200000, "esmeralda", "en"),
"posts/2005/11/esmeralda.en.md"
);
}
#[test]
fn media_paths() {
assert_eq!(
media_sidecar_path("media/2005/11/eb0cf9d7.jpg"),
"media/2005/11/eb0cf9d7.jpg.meta"
);
assert_eq!(
media_translation_sidecar_path("media/2005/11/eb0cf9d7.jpg", "fr"),
"media/2005/11/eb0cf9d7.jpg.fr.meta"
);
}
#[test]
fn thumbnail_paths() {
assert_eq!(
thumbnail_path("eb0cf9d7-6fbd-4b74-9be3-759d6e16f240", "small", "webp"),
"thumbnails/eb/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240-small.webp"
);
assert_eq!(
thumbnail_path("eb0cf9d7-6fbd-4b74-9be3-759d6e16f240", "ai", "jpg"),
"thumbnails/eb/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240-ai.jpg"
);
}
#[test]
fn template_and_script_paths() {
assert_eq!(template_file_path("testvorlage"), "templates/testvorlage.liquid");
assert_eq!(script_file_path("bgg_link"), "scripts/bgg_link.lua");
}
}

View File

@@ -0,0 +1,393 @@
use crate::model::Media;
use crate::util::timestamp::{unix_ms_to_iso, iso_to_unix_ms};
/// Parsed media sidecar fields.
#[derive(Debug, Clone)]
pub struct MediaSidecar {
pub id: String,
pub original_name: String,
pub mime_type: String,
pub size: i64,
pub width: Option<i32>,
pub height: Option<i32>,
pub title: Option<String>,
pub alt: Option<String>,
pub caption: Option<String>,
pub author: Option<String>,
pub language: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub tags: Vec<String>,
pub linked_post_ids: Vec<String>,
}
/// Parsed media translation sidecar fields.
#[derive(Debug, Clone)]
pub struct MediaTranslationSidecar {
pub translation_for: String,
pub language: String,
pub title: Option<String>,
pub alt: Option<String>,
pub caption: Option<String>,
}
impl MediaSidecar {
pub fn from_media(media: &Media, linked_post_ids: &[String]) -> Self {
Self {
id: media.id.clone(),
original_name: media.original_name.clone(),
mime_type: media.mime_type.clone(),
size: media.size,
width: media.width,
height: media.height,
title: media.title.clone(),
alt: media.alt.clone(),
caption: media.caption.clone(),
author: media.author.clone(),
language: media.language.clone(),
created_at: media.created_at,
updated_at: media.updated_at,
tags: media.tags.clone(),
linked_post_ids: linked_post_ids.to_vec(),
}
}
/// Serialize to the hand-built YAML-like format matching TypeScript output.
pub fn to_string(&self) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("---".into());
lines.push(format!("id: {}", self.id));
lines.push(format!("originalName: \"{}\"", escape_double_quotes(&self.original_name)));
lines.push(format!("mimeType: {}", self.mime_type));
lines.push(format!("size: {}", self.size));
if let Some(w) = self.width {
lines.push(format!("width: {w}"));
}
if let Some(h) = self.height {
lines.push(format!("height: {h}"));
}
if let Some(ref title) = self.title {
if !title.is_empty() {
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
}
}
if let Some(ref alt) = self.alt {
if !alt.is_empty() {
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
}
}
if let Some(ref caption) = self.caption {
if !caption.is_empty() {
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
}
}
if let Some(ref author) = self.author {
if !author.is_empty() {
lines.push(format!("author: \"{}\"", escape_double_quotes(author)));
}
}
if let Some(ref language) = self.language {
if !language.is_empty() {
lines.push(format!("language: {language}"));
}
}
lines.push(format!("createdAt: {}", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: {}", unix_ms_to_iso(self.updated_at)));
// Tags: inline JSON array
if self.tags.is_empty() {
lines.push("tags: []".into());
} else {
let quoted: Vec<String> = self.tags.iter().map(|t| format!("\"{}\"", escape_double_quotes(t))).collect();
lines.push(format!("tags: [{}]", quoted.join(", ")));
}
// linkedPostIds: only if non-empty
if !self.linked_post_ids.is_empty() {
let quoted: Vec<String> = self.linked_post_ids.iter().map(|id| format!("\"{id}\"")).collect();
lines.push(format!("linkedPostIds: [{}]", quoted.join(", ")));
}
lines.push("---".into());
lines.join("\n")
}
}
impl MediaTranslationSidecar {
/// Serialize to the hand-built format.
pub fn to_string(&self) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("---".into());
lines.push(format!("translationFor: {}", self.translation_for));
lines.push(format!("language: {}", self.language));
if let Some(ref title) = self.title {
if !title.is_empty() {
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
}
}
if let Some(ref alt) = self.alt {
if !alt.is_empty() {
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
}
}
if let Some(ref caption) = self.caption {
if !caption.is_empty() {
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
}
}
lines.push("---".into());
lines.join("\n")
}
}
/// Parse a canonical media sidecar.
pub fn read_sidecar(content: &str) -> Result<MediaSidecar, String> {
// Strip --- delimiters
let inner = content.trim();
let inner = inner.strip_prefix("---").ok_or("missing opening ---")?;
let inner = inner.trim_start_matches('\n');
let inner = inner.strip_suffix("---").ok_or("missing closing ---")?;
let inner = inner.trim_end_matches('\n');
let mut id = None;
let mut original_name = None;
let mut mime_type = None;
let mut size = None;
let mut width = None;
let mut height = None;
let mut title = None;
let mut alt = None;
let mut caption = None;
let mut author = None;
let mut language = None;
let mut created_at = None;
let mut updated_at = None;
let mut tags = Vec::new();
let mut linked_post_ids = Vec::new();
for line in inner.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once(": ") {
let value = value.trim();
match key.trim() {
"id" => id = Some(value.to_string()),
"originalName" => original_name = Some(unquote_double(value)),
"mimeType" => mime_type = Some(value.to_string()),
"size" => size = value.parse::<i64>().ok(),
"width" => width = value.parse::<i32>().ok(),
"height" => height = value.parse::<i32>().ok(),
"title" => title = Some(unquote_double(value)),
"alt" => alt = Some(unquote_double(value)),
"caption" => caption = Some(unquote_double(value)),
"author" => author = Some(unquote_double(value)),
"language" => language = Some(value.to_string()),
"createdAt" => created_at = iso_to_unix_ms(value).ok(),
"updatedAt" => updated_at = iso_to_unix_ms(value).ok(),
"tags" => tags = parse_inline_json_array(value),
"linkedPostIds" => linked_post_ids = parse_inline_json_array(value),
_ => {} // ignore unknown fields
}
}
}
Ok(MediaSidecar {
id: id.ok_or("missing 'id'")?,
original_name: original_name.ok_or("missing 'originalName'")?,
mime_type: mime_type.ok_or("missing 'mimeType'")?,
size: size.ok_or("missing 'size'")?,
width,
height,
title,
alt,
caption,
author,
language,
created_at: created_at.ok_or("missing 'createdAt'")?,
updated_at: updated_at.ok_or("missing 'updatedAt'")?,
tags,
linked_post_ids,
})
}
/// Parse a media translation sidecar.
pub fn read_translation_sidecar(content: &str) -> Result<MediaTranslationSidecar, String> {
let inner = content.trim();
let inner = inner.strip_prefix("---").ok_or("missing opening ---")?;
let inner = inner.trim_start_matches('\n');
let inner = inner.strip_suffix("---").ok_or("missing closing ---")?;
let inner = inner.trim_end_matches('\n');
let mut translation_for = None;
let mut language = None;
let mut title = None;
let mut alt = None;
let mut caption = None;
for line in inner.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once(": ") {
let value = value.trim();
match key.trim() {
"translationFor" => translation_for = Some(value.to_string()),
"language" => language = Some(value.to_string()),
"title" => title = Some(unquote_double(value)),
"alt" => alt = Some(unquote_double(value)),
"caption" => caption = Some(unquote_double(value)),
_ => {}
}
}
}
Ok(MediaTranslationSidecar {
translation_for: translation_for.ok_or("missing 'translationFor'")?,
language: language.ok_or("missing 'language'")?,
title,
alt,
caption,
})
}
// --- Helpers ---
fn escape_double_quotes(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn unquote_double(s: &str) -> String {
let s = s.trim();
if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
let inner = &s[1..s.len() - 1];
inner.replace("\\\"", "\"").replace("\\\\", "\\")
} else {
s.to_string()
}
}
/// Parse inline JSON-like array: `["a", "b"]` or `[]`.
fn parse_inline_json_array(s: &str) -> Vec<String> {
let s = s.trim();
if s == "[]" {
return Vec::new();
}
// Try serde_json first
if let Ok(arr) = serde_json::from_str::<Vec<String>>(s) {
return arr;
}
// Fallback: strip brackets and split by comma
let inner = s.trim_start_matches('[').trim_end_matches(']');
inner
.split(',')
.map(|item| unquote_double(item.trim()))
.filter(|s| !s.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn fixture_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/compatibility-projects/rfc1437-sample")
}
#[test]
fn parse_fixture_sidecar() {
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let content = fs::read_to_string(path).unwrap();
let sc = read_sidecar(&content).unwrap();
assert_eq!(sc.id, "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240");
assert_eq!(sc.original_name, "CRW_1121.jpg");
assert_eq!(sc.mime_type, "image/jpeg");
assert_eq!(sc.size, 706358);
assert_eq!(sc.width, Some(1800));
assert_eq!(sc.height, Some(1200));
assert_eq!(sc.title.as_deref(), Some("Esmeralda"));
assert!(sc.alt.as_ref().unwrap().contains("Spinnenfrau"));
assert!(sc.caption.as_ref().unwrap().contains("Handwerkskunst"));
assert!(sc.tags.is_empty());
assert_eq!(sc.linked_post_ids, vec!["40a83ab1-423d-4310-aac4-642d84675007"]);
}
#[test]
fn golden_output_sidecar() {
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let expected = fs::read_to_string(&path).unwrap();
let sc = read_sidecar(&expected).unwrap();
let actual = sc.to_string();
// Compare trimmed (fixture may or may not have trailing newline)
assert_eq!(actual.trim(), expected.trim(), "golden output mismatch for media sidecar");
}
#[test]
fn roundtrip_sidecar() {
let sc = MediaSidecar {
id: "test-uuid".into(),
original_name: "photo.jpg".into(),
mime_type: "image/jpeg".into(),
size: 12345,
width: Some(800),
height: Some(600),
title: Some("My Photo".into()),
alt: Some("A nice photo".into()),
caption: None,
author: Some("Hugo".into()),
language: None,
created_at: 1131883200000,
updated_at: 1131883200000,
tags: vec!["nature".into(), "photo".into()],
linked_post_ids: vec![],
};
let output = sc.to_string();
let parsed = read_sidecar(&output).unwrap();
assert_eq!(parsed.id, sc.id);
assert_eq!(parsed.original_name, sc.original_name);
assert_eq!(parsed.size, sc.size);
assert_eq!(parsed.title, sc.title);
assert_eq!(parsed.alt, sc.alt);
assert!(parsed.caption.is_none());
assert_eq!(parsed.tags, sc.tags);
}
#[test]
fn translation_sidecar_roundtrip() {
let ts = MediaTranslationSidecar {
translation_for: "uuid-123".into(),
language: "fr".into(),
title: Some("Mon titre".into()),
alt: Some("Description".into()),
caption: None,
};
let output = ts.to_string();
let parsed = read_translation_sidecar(&output).unwrap();
assert_eq!(parsed.translation_for, "uuid-123");
assert_eq!(parsed.language, "fr");
assert_eq!(parsed.title.as_deref(), Some("Mon titre"));
assert_eq!(parsed.alt.as_deref(), Some("Description"));
assert!(parsed.caption.is_none());
}
#[test]
fn inline_json_array_parsing() {
assert_eq!(parse_inline_json_array("[]"), Vec::<String>::new());
assert_eq!(
parse_inline_json_array("[\"a\", \"b\"]"),
vec!["a", "b"]
);
}
#[test]
fn escape_and_unquote() {
assert_eq!(unquote_double("\"hello\""), "hello");
assert_eq!(unquote_double("plain"), "plain");
assert_eq!(escape_double_quotes("say \"hi\""), "say \\\"hi\\\"");
}
}

View File

@@ -0,0 +1,241 @@
use image::imageops::FilterType;
use image::{DynamicImage, GenericImageView, ImageFormat, ImageReader};
use std::fs;
use std::io::Cursor;
use std::path::Path;
/// Thumbnail size configuration.
pub struct ThumbnailSize {
pub name: &'static str,
pub width: u32,
pub height: u32,
pub format: ThumbnailFormat,
pub fit: ThumbnailFit,
}
#[derive(Clone, Copy, PartialEq)]
pub enum ThumbnailFormat {
Webp,
Jpeg,
}
#[derive(Clone, Copy, PartialEq)]
pub enum ThumbnailFit {
/// Scale to fit inside, no enlargement.
Inside,
/// Scale to fill, crop center (cover).
Cover,
/// Scale to fit, letterbox with black background.
Contain,
}
/// Standard thumbnail sizes matching TypeScript implementation.
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
ThumbnailSize { name: "small", width: 150, height: 150, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "medium", width: 400, height: 400, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "large", width: 800, height: 800, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "ai", width: 448, height: 448, format: ThumbnailFormat::Jpeg, fit: ThumbnailFit::Contain },
];
/// Generate a single thumbnail from a source image file.
pub fn generate_thumbnail(
source: &Path,
dest: &Path,
size: &ThumbnailSize,
quality: u8,
) -> Result<(), String> {
let img = load_and_orient(source)?;
let resized = match size.fit {
ThumbnailFit::Inside => {
let (orig_w, orig_h) = img.dimensions();
// Don't enlarge
if orig_w <= size.width && orig_h <= size.height {
img.clone()
} else {
img.resize(size.width, size.height, FilterType::Lanczos3)
}
}
ThumbnailFit::Cover => {
img.resize_to_fill(size.width, size.height, FilterType::Lanczos3)
}
ThumbnailFit::Contain => {
// Resize to fit, then place on black background
let resized = img.resize(size.width, size.height, FilterType::Lanczos3);
let (rw, rh) = resized.dimensions();
let mut canvas = DynamicImage::new_rgb8(size.width, size.height);
let offset_x = (size.width - rw) / 2;
let offset_y = (size.height - rh) / 2;
image::imageops::overlay(&mut canvas, &resized, offset_x as i64, offset_y as i64);
canvas
}
};
// Ensure parent directory exists
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| format!("create dir: {e}"))?;
}
match size.format {
ThumbnailFormat::Webp => {
// Encode as WebP via image crate
let mut buf = Cursor::new(Vec::new());
resized
.write_to(&mut buf, ImageFormat::WebP)
.map_err(|e| format!("WebP encode: {e}"))?;
let _ = quality; // image crate WebP encoder doesn't expose quality directly
fs::write(dest, buf.into_inner()).map_err(|e| format!("write: {e}"))?;
}
ThumbnailFormat::Jpeg => {
let rgb = resized.to_rgb8();
let mut buf = Cursor::new(Vec::new());
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality);
encoder
.encode_image(&rgb)
.map_err(|e| format!("JPEG encode: {e}"))?;
fs::write(dest, buf.into_inner()).map_err(|e| format!("write: {e}"))?;
}
}
Ok(())
}
/// Generate all standard thumbnails for a media item.
pub fn generate_all_thumbnails(
source: &Path,
thumbnails_dir: &Path,
media_id: &str,
) -> Result<Vec<String>, String> {
let mut paths = Vec::new();
let prefix = &media_id[..2.min(media_id.len())];
for size in THUMBNAIL_SIZES {
let ext = match size.format {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
};
let dest = thumbnails_dir
.join(prefix)
.join(format!("{media_id}-{}.{ext}", size.name));
let quality = if size.format == ThumbnailFormat::Jpeg { 85 } else { 80 };
generate_thumbnail(source, &dest, size, quality)?;
paths.push(dest.to_string_lossy().to_string());
}
Ok(paths)
}
/// Load an image and apply EXIF orientation.
fn load_and_orient(path: &Path) -> Result<DynamicImage, String> {
let reader = ImageReader::open(path)
.map_err(|e| format!("open image: {e}"))?
.with_guessed_format()
.map_err(|e| format!("guess format: {e}"))?;
let img = reader.decode().map_err(|e| format!("decode image: {e}"))?;
// EXIF orientation is handled by the image crate for JPEG automatically
// when reading. For other formats, no EXIF orientation exists.
Ok(img)
}
/// Extract image dimensions from a file.
pub fn image_dimensions(path: &Path) -> Result<(u32, u32), String> {
let img = ImageReader::open(path)
.map_err(|e| format!("open: {e}"))?
.with_guessed_format()
.map_err(|e| format!("format: {e}"))?
.decode()
.map_err(|e| format!("decode: {e}"))?;
Ok(img.dimensions())
}
/// Detect MIME type from file extension.
pub fn mime_from_extension(ext: &str) -> &'static str {
match ext.to_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"tiff" | "tif" => "image/tiff",
"bmp" => "image/bmp",
"heic" => "image/heic",
"heif" => "image/heif",
"svg" => "image/svg+xml",
"pdf" => "application/pdf",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_png(dir: &Path) -> std::path::PathBuf {
let path = dir.join("test.png");
// Create a small 100x80 red PNG
let img = DynamicImage::new_rgb8(100, 80);
img.save(&path).unwrap();
path
}
#[test]
fn generate_small_thumbnail() {
let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path());
let dest = dir.path().join("thumb.webp");
let size = &THUMBNAIL_SIZES[0]; // small: 150x150
generate_thumbnail(&source, &dest, size, 80).unwrap();
assert!(dest.exists());
let (w, h) = image_dimensions(&dest).unwrap();
// 100x80 is already smaller than 150x150, so no resize (Inside fit)
assert_eq!(w, 100);
assert_eq!(h, 80);
}
#[test]
fn generate_ai_thumbnail_contain() {
let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path());
let dest = dir.path().join("thumb-ai.jpg");
let size = &THUMBNAIL_SIZES[3]; // ai: 448x448 contain
generate_thumbnail(&source, &dest, size, 85).unwrap();
assert!(dest.exists());
let (w, h) = image_dimensions(&dest).unwrap();
assert_eq!(w, 448);
assert_eq!(h, 448);
}
#[test]
fn generate_all() {
let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path());
let thumb_dir = dir.path().join("thumbnails");
let paths = generate_all_thumbnails(&source, &thumb_dir, "ab123456-test-uuid").unwrap();
assert_eq!(paths.len(), 4);
for p in &paths {
assert!(Path::new(p).exists(), "thumbnail missing: {p}");
}
}
#[test]
fn mime_detection() {
assert_eq!(mime_from_extension("jpg"), "image/jpeg");
assert_eq!(mime_from_extension("PNG"), "image/png");
assert_eq!(mime_from_extension("webp"), "image/webp");
assert_eq!(mime_from_extension("xyz"), "application/octet-stream");
}
#[test]
fn dimensions_extraction() {
let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path());
let (w, h) = image_dimensions(&source).unwrap();
assert_eq!(w, 100);
assert_eq!(h, 80);
}
}

View File

@@ -0,0 +1,86 @@
use chrono::{DateTime, Utc, TimeZone};
/// Convert Unix milliseconds to ISO 8601 string (e.g., `2005-11-13T12:00:00.000Z`).
pub fn unix_ms_to_iso(ms: i64) -> String {
let dt = Utc.timestamp_millis_opt(ms).single().unwrap_or_default();
dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
/// Parse an ISO 8601 string back to Unix milliseconds.
pub fn iso_to_unix_ms(iso: &str) -> Result<i64, String> {
let dt = iso
.parse::<DateTime<Utc>>()
.map_err(|e| format!("invalid ISO 8601 timestamp '{iso}': {e}"))?;
Ok(dt.timestamp_millis())
}
/// Extract zero-padded (YYYY, MM) from Unix milliseconds.
pub fn year_month_from_unix_ms(ms: i64) -> (String, String) {
let dt = Utc.timestamp_millis_opt(ms).single().unwrap_or_default();
(dt.format("%Y").to_string(), dt.format("%m").to_string())
}
/// Extract zero-padded (YYYY, MM, DD) from Unix milliseconds.
pub fn year_month_day_from_unix_ms(ms: i64) -> (String, String, String) {
let dt = Utc.timestamp_millis_opt(ms).single().unwrap_or_default();
(
dt.format("%Y").to_string(),
dt.format("%m").to_string(),
dt.format("%d").to_string(),
)
}
/// Current time as Unix milliseconds.
pub fn now_unix_ms() -> i64 {
Utc::now().timestamp_millis()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_conversion() {
let ms: i64 = 1131883200000; // 2005-11-13T12:00:00.000Z
let iso = unix_ms_to_iso(ms);
assert_eq!(iso, "2005-11-13T12:00:00.000Z");
assert_eq!(iso_to_unix_ms(&iso).unwrap(), ms);
}
#[test]
fn roundtrip_with_real_millis() {
let ms: i64 = 1741376097243; // some real timestamp with millis
let iso = unix_ms_to_iso(ms);
assert!(iso.ends_with("Z"));
assert_eq!(iso_to_unix_ms(&iso).unwrap(), ms);
}
#[test]
fn year_month_extraction() {
let ms: i64 = 1131883200000;
let (y, m) = year_month_from_unix_ms(ms);
assert_eq!(y, "2005");
assert_eq!(m, "11");
}
#[test]
fn year_month_day_extraction() {
let ms: i64 = 1131883200000;
let (y, m, d) = year_month_day_from_unix_ms(ms);
assert_eq!(y, "2005");
assert_eq!(m, "11");
assert_eq!(d, "13");
}
#[test]
fn now_is_recent() {
let ms = now_unix_ms();
// Should be after 2024-01-01
assert!(ms > 1704067200000);
}
#[test]
fn invalid_iso_returns_error() {
assert!(iso_to_unix_ms("not a date").is_err());
}
}