feat: implementation of M1
This commit is contained in:
53
crates/bds-core/src/util/atomic_write.rs
Normal file
53
crates/bds-core/src/util/atomic_write.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
813
crates/bds-core/src/util/frontmatter.rs
Normal file
813
crates/bds-core/src/util/frontmatter.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
107
crates/bds-core/src/util/paths.rs
Normal file
107
crates/bds-core/src/util/paths.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
393
crates/bds-core/src/util/sidecar.rs
Normal file
393
crates/bds-core/src/util/sidecar.rs
Normal 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\\\"");
|
||||
}
|
||||
}
|
||||
241
crates/bds-core/src/util/thumbnail.rs
Normal file
241
crates/bds-core/src/util/thumbnail.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
86
crates/bds-core/src/util/timestamp.rs
Normal file
86
crates/bds-core/src/util/timestamp.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user