chore: source formatting and spec allignment
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
use crate::model::{Post, PostTranslation};
|
||||
use crate::util::timestamp::{unix_ms_to_iso, iso_to_unix_ms};
|
||||
use crate::util::timestamp::{iso_to_unix_ms, unix_ms_to_iso};
|
||||
|
||||
/// Split content at `---` delimiters into (yaml, body).
|
||||
/// Returns `None` if the content does not start with `---`.
|
||||
@@ -118,28 +118,28 @@ impl PostFrontmatter {
|
||||
}
|
||||
|
||||
// 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 excerpt) = self.excerpt
|
||||
&& !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 author) = self.author
|
||||
&& !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 let Some(ref language) = self.language
|
||||
&& !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(ref template_slug) = self.template_slug
|
||||
&& !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)));
|
||||
@@ -157,7 +157,7 @@ impl PostFrontmatter {
|
||||
.ok_or("frontmatter is not a YAML mapping")?;
|
||||
|
||||
let get_str = |key: &str| -> Option<String> {
|
||||
map.get(&serde_yaml::Value::String(key.to_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()),
|
||||
@@ -167,7 +167,7 @@ impl PostFrontmatter {
|
||||
};
|
||||
|
||||
let get_string_list = |key: &str| -> Vec<String> {
|
||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
||||
map.get(serde_yaml::Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_sequence())
|
||||
.map(|seq| {
|
||||
seq.iter()
|
||||
@@ -198,8 +198,7 @@ impl PostFrontmatter {
|
||||
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()),
|
||||
published_at: get_str("publishedAt").and_then(|s| iso_to_unix_ms(&s).ok()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -212,8 +211,7 @@ pub fn write_post_file(post: &Post, body: &str) -> String {
|
||||
|
||||
/// 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 (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
|
||||
let fm = PostFrontmatter::from_yaml(yaml)?;
|
||||
Ok((fm, body.to_string()))
|
||||
}
|
||||
@@ -223,31 +221,61 @@ pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String
|
||||
/// Parsed translation frontmatter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TranslationFrontmatter {
|
||||
pub id: Option<String>,
|
||||
pub translation_for: String,
|
||||
pub language: String,
|
||||
pub title: String,
|
||||
pub excerpt: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub created_at: Option<i64>,
|
||||
pub updated_at: Option<i64>,
|
||||
pub published_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl TranslationFrontmatter {
|
||||
pub fn from_translation(t: &PostTranslation) -> Self {
|
||||
Self {
|
||||
id: Some(t.id.clone()),
|
||||
translation_for: t.translation_for.clone(),
|
||||
language: t.language.clone(),
|
||||
title: t.title.clone(),
|
||||
excerpt: t.excerpt.clone(),
|
||||
status: Some(
|
||||
serde_json::to_string(&t.status)
|
||||
.unwrap_or_default()
|
||||
.trim_matches('"')
|
||||
.to_string(),
|
||||
),
|
||||
created_at: Some(t.created_at),
|
||||
updated_at: Some(t.updated_at),
|
||||
published_at: t.published_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_yaml(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
if let Some(id) = &self.id {
|
||||
lines.push(format!("id: {id}"));
|
||||
}
|
||||
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)));
|
||||
}
|
||||
if let Some(ref excerpt) = self.excerpt
|
||||
&& !excerpt.is_empty()
|
||||
{
|
||||
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
|
||||
}
|
||||
if let Some(status) = &self.status {
|
||||
lines.push(format!("status: {status}"));
|
||||
}
|
||||
if let Some(created_at) = self.created_at {
|
||||
lines.push(format!("createdAt: '{}'", unix_ms_to_iso(created_at)));
|
||||
}
|
||||
if let Some(updated_at) = self.updated_at {
|
||||
lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(updated_at)));
|
||||
}
|
||||
if let Some(published_at) = self.published_at {
|
||||
lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at)));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
@@ -260,16 +288,21 @@ impl TranslationFrontmatter {
|
||||
.ok_or("frontmatter is not a YAML mapping")?;
|
||||
|
||||
let get_str = |key: &str| -> Option<String> {
|
||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
||||
map.get(serde_yaml::Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
id: get_str("id"),
|
||||
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()),
|
||||
status: get_str("status"),
|
||||
created_at: get_str("createdAt").and_then(|value| iso_to_unix_ms(&value).ok()),
|
||||
updated_at: get_str("updatedAt").and_then(|value| iso_to_unix_ms(&value).ok()),
|
||||
published_at: get_str("publishedAt").and_then(|value| iso_to_unix_ms(&value).ok()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -282,8 +315,7 @@ pub fn write_translation_file(translation: &PostTranslation, body: &str) -> Stri
|
||||
|
||||
/// 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 (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
|
||||
let fm = TranslationFrontmatter::from_yaml(yaml)?;
|
||||
Ok((fm, body.to_string()))
|
||||
}
|
||||
@@ -317,8 +349,14 @@ impl TemplateFrontmatter {
|
||||
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.push(format!(
|
||||
"createdAt: \"{}\"",
|
||||
unix_ms_to_iso(self.created_at)
|
||||
));
|
||||
lines.push(format!(
|
||||
"updatedAt: \"{}\"",
|
||||
unix_ms_to_iso(self.updated_at)
|
||||
));
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -328,7 +366,7 @@ impl TemplateFrontmatter {
|
||||
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()))
|
||||
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()),
|
||||
@@ -359,8 +397,7 @@ impl TemplateFrontmatter {
|
||||
|
||||
/// 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 (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
|
||||
let fm = TemplateFrontmatter::from_yaml(yaml)?;
|
||||
Ok((fm, body.to_string()))
|
||||
}
|
||||
@@ -401,8 +438,14 @@ impl ScriptFrontmatter {
|
||||
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.push(format!(
|
||||
"createdAt: \"{}\"",
|
||||
unix_ms_to_iso(self.created_at)
|
||||
));
|
||||
lines.push(format!(
|
||||
"updatedAt: \"{}\"",
|
||||
unix_ms_to_iso(self.updated_at)
|
||||
));
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -412,7 +455,7 @@ impl ScriptFrontmatter {
|
||||
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()))
|
||||
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()),
|
||||
@@ -450,8 +493,7 @@ pub fn read_script_file(content: &str) -> Result<(ScriptFrontmatter, String), St
|
||||
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 (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
|
||||
let fm = ScriptFrontmatter::from_yaml(yaml)?;
|
||||
Ok((fm, body.to_string()))
|
||||
}
|
||||
@@ -513,6 +555,7 @@ fn yaml_string_value(s: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::PostStatus;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -551,7 +594,10 @@ mod tests {
|
||||
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.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));
|
||||
@@ -655,7 +701,36 @@ mod tests {
|
||||
|
||||
let yaml = fm.to_yaml();
|
||||
let actual = format_frontmatter(&yaml, &body);
|
||||
assert_eq!(actual, expected, "golden output mismatch for esmeralda.en.md");
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"golden output mismatch for esmeralda.en.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_translation_output_carries_full_metadata() {
|
||||
let translation = PostTranslation {
|
||||
id: "translation-1".into(),
|
||||
project_id: "project-1".into(),
|
||||
translation_for: "post-1".into(),
|
||||
language: "de".into(),
|
||||
title: "Titel".into(),
|
||||
excerpt: None,
|
||||
content: None,
|
||||
status: PostStatus::Published,
|
||||
file_path: "posts/2026/07/post.de.md".into(),
|
||||
checksum: None,
|
||||
created_at: 1_751_328_000_000,
|
||||
updated_at: 1_751_414_400_000,
|
||||
published_at: Some(1_751_414_400_000),
|
||||
};
|
||||
|
||||
let output = write_translation_file(&translation, "Inhalt");
|
||||
assert!(output.contains("id: translation-1"));
|
||||
assert!(output.contains("status: published"));
|
||||
assert!(output.contains("createdAt:"));
|
||||
assert!(output.contains("updatedAt:"));
|
||||
assert!(output.contains("publishedAt:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -723,7 +798,10 @@ mod tests {
|
||||
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.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");
|
||||
@@ -738,7 +816,10 @@ mod tests {
|
||||
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");
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"golden output mismatch for testvorlage.liquid"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Script frontmatter tests ---
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
mod slug;
|
||||
mod checksum;
|
||||
pub mod timestamp;
|
||||
pub mod atomic_write;
|
||||
pub mod paths;
|
||||
mod checksum;
|
||||
pub mod frontmatter;
|
||||
pub mod paths;
|
||||
pub mod sidecar;
|
||||
mod slug;
|
||||
pub mod thumbnail;
|
||||
pub mod timestamp;
|
||||
|
||||
pub use slug::{slugify, ensure_unique};
|
||||
pub use checksum::{content_hash, file_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 checksum::{content_hash, file_hash};
|
||||
pub use paths::*;
|
||||
pub use slug::{ensure_unique, slugify};
|
||||
pub use timestamp::{
|
||||
calendar_range_unix_ms, iso_to_unix_ms, now_unix_ms, unix_ms_to_iso,
|
||||
year_month_day_from_unix_ms, year_month_from_unix_ms,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,11 @@ pub fn post_file_path(created_at_ms: i64, slug: &str) -> String {
|
||||
|
||||
/// 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 {
|
||||
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")
|
||||
}
|
||||
@@ -101,7 +105,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn template_and_script_paths() {
|
||||
assert_eq!(template_file_path("testvorlage"), "templates/testvorlage.liquid");
|
||||
assert_eq!(
|
||||
template_file_path("testvorlage"),
|
||||
"templates/testvorlage.liquid"
|
||||
);
|
||||
assert_eq!(script_file_path("bgg_link"), "scripts/bgg_link.lua");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::fmt::{self, Display};
|
||||
|
||||
use crate::model::Media;
|
||||
use crate::util::timestamp::{unix_ms_to_iso, iso_to_unix_ms};
|
||||
use crate::util::timestamp::{iso_to_unix_ms, unix_ms_to_iso};
|
||||
|
||||
/// Parsed media sidecar fields.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -53,11 +55,14 @@ impl MediaSidecar {
|
||||
}
|
||||
|
||||
/// Serialize to the hand-built YAML-like format matching TypeScript output.
|
||||
pub fn to_string(&self) -> String {
|
||||
fn serialize(&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!(
|
||||
"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 {
|
||||
@@ -66,30 +71,30 @@ impl MediaSidecar {
|
||||
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 title) = self.title
|
||||
&& !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 alt) = self.alt
|
||||
&& !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 caption) = self.caption
|
||||
&& !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 author) = self.author
|
||||
&& !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}"));
|
||||
}
|
||||
if let Some(ref language) = self.language
|
||||
&& !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)));
|
||||
@@ -98,13 +103,21 @@ impl MediaSidecar {
|
||||
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();
|
||||
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();
|
||||
let quoted: Vec<String> = self
|
||||
.linked_post_ids
|
||||
.iter()
|
||||
.map(|id| format!("\"{id}\""))
|
||||
.collect();
|
||||
lines.push(format!("linkedPostIds: [{}]", quoted.join(", ")));
|
||||
}
|
||||
|
||||
@@ -113,33 +126,45 @@ impl MediaSidecar {
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for MediaSidecar {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.serialize())
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaTranslationSidecar {
|
||||
/// Serialize to the hand-built format.
|
||||
pub fn to_string(&self) -> String {
|
||||
fn serialize(&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 title) = self.title
|
||||
&& !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 alt) = self.alt
|
||||
&& !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 caption) = self.caption
|
||||
&& !caption.is_empty()
|
||||
{
|
||||
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
|
||||
}
|
||||
lines.push("---".into());
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for MediaTranslationSidecar {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.serialize())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a canonical media sidecar.
|
||||
pub fn read_sidecar(content: &str) -> Result<MediaSidecar, String> {
|
||||
// Strip --- delimiters
|
||||
@@ -301,7 +326,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_fixture_sidecar() {
|
||||
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
|
||||
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");
|
||||
@@ -314,17 +340,25 @@ mod tests {
|
||||
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"]);
|
||||
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 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");
|
||||
assert_eq!(
|
||||
actual.trim(),
|
||||
expected.trim(),
|
||||
"golden output mismatch for media sidecar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -378,10 +412,7 @@ mod tests {
|
||||
#[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"]
|
||||
);
|
||||
assert_eq!(parse_inline_json_array("[\"a\", \"b\"]"), vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use deunicode::deunicode;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Pre-process German characters to match TypeScript `transliteration` npm output.
|
||||
/// deunicode maps ä→a, ö→o, ü→u but TypeScript produces ä→ae, ö→oe, ü→ue.
|
||||
@@ -50,7 +49,7 @@ pub fn slugify(input: &str) -> String {
|
||||
}
|
||||
|
||||
/// Ensure a slug is unique within a project, using the spec's algorithm:
|
||||
/// tries base, then {slug}-2 .. {slug}-999, then {slug}-{timestamp}.
|
||||
/// tries base, then unbounded numeric suffixes `{slug}-2`, `{slug}-3`, and so on.
|
||||
///
|
||||
/// `exists` is a predicate that returns true if the candidate slug is already taken.
|
||||
pub fn ensure_unique<F>(base: &str, exists: F) -> String
|
||||
@@ -60,17 +59,13 @@ where
|
||||
if !exists(base) {
|
||||
return base.to_string();
|
||||
}
|
||||
for n in 2..=999 {
|
||||
for n in 2_u64.. {
|
||||
let candidate = format!("{base}-{n}");
|
||||
if !exists(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
format!("{base}-{ts}")
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -121,7 +116,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ensure_unique_sequential_taken() {
|
||||
let slug = ensure_unique("hello", |s| s == "hello" || s == "hello-2" || s == "hello-3");
|
||||
let slug = ensure_unique("hello", |s| {
|
||||
s == "hello" || s == "hello-2" || s == "hello-3"
|
||||
});
|
||||
assert_eq!(slug, "hello-4");
|
||||
}
|
||||
|
||||
@@ -178,20 +175,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_unique_all_999_taken() {
|
||||
fn ensure_unique_continues_after_999() {
|
||||
let slug = ensure_unique("x", |s| {
|
||||
if s == "x" { return true; }
|
||||
if let Some(suffix) = s.strip_prefix("x-") {
|
||||
if let Ok(n) = suffix.parse::<u32>() {
|
||||
return n <= 999;
|
||||
}
|
||||
if s == "x" {
|
||||
return true;
|
||||
}
|
||||
if let Some(suffix) = s.strip_prefix("x-")
|
||||
&& let Ok(n) = suffix.parse::<u32>()
|
||||
{
|
||||
return n <= 999;
|
||||
}
|
||||
false
|
||||
});
|
||||
// Should fall back to timestamp-based slug
|
||||
assert!(slug.starts_with("x-"));
|
||||
let suffix = slug.strip_prefix("x-").unwrap();
|
||||
let ts: u64 = suffix.parse().expect("should be a timestamp");
|
||||
assert!(ts > 1_000_000_000);
|
||||
assert_eq!(slug, "x-1000");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,34 @@ pub enum ThumbnailFit {
|
||||
/// Standard thumbnail sizes matching spec: small/medium/large are
|
||||
/// width-constrained aspect-preserving; AI is letterboxed on black.
|
||||
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
|
||||
ThumbnailSize { name: "small", width: 150, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
|
||||
ThumbnailSize { name: "medium", width: 400, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
|
||||
ThumbnailSize { name: "large", width: 800, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
|
||||
ThumbnailSize { name: "ai", width: 448, height: 448, format: ThumbnailFormat::Jpeg, fit: ThumbnailFit::Contain },
|
||||
ThumbnailSize {
|
||||
name: "small",
|
||||
width: 150,
|
||||
height: u32::MAX,
|
||||
format: ThumbnailFormat::Webp,
|
||||
fit: ThumbnailFit::Inside,
|
||||
},
|
||||
ThumbnailSize {
|
||||
name: "medium",
|
||||
width: 400,
|
||||
height: u32::MAX,
|
||||
format: ThumbnailFormat::Webp,
|
||||
fit: ThumbnailFit::Inside,
|
||||
},
|
||||
ThumbnailSize {
|
||||
name: "large",
|
||||
width: 800,
|
||||
height: u32::MAX,
|
||||
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.
|
||||
@@ -57,9 +81,7 @@ pub fn generate_thumbnail(
|
||||
img.resize(size.width, size.height, FilterType::Lanczos3)
|
||||
}
|
||||
}
|
||||
ThumbnailFit::Cover => {
|
||||
img.resize_to_fill(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);
|
||||
@@ -118,7 +140,11 @@ pub fn generate_all_thumbnails(
|
||||
let dest = thumbnails_dir
|
||||
.join(prefix)
|
||||
.join(format!("{media_id}-{}.{ext}", size.name));
|
||||
let quality = if size.format == ThumbnailFormat::Jpeg { 85 } else { 80 };
|
||||
let quality = if size.format == ThumbnailFormat::Jpeg {
|
||||
85
|
||||
} else {
|
||||
80
|
||||
};
|
||||
generate_thumbnail(source, &dest, size, quality)?;
|
||||
paths.push(dest.to_string_lossy().to_string());
|
||||
}
|
||||
@@ -136,10 +162,10 @@ fn load_and_orient(path: &Path) -> Result<DynamicImage, String> {
|
||||
let mut img = reader.decode().map_err(|e| format!("decode image: {e}"))?;
|
||||
|
||||
// Try to read EXIF orientation from JPEG files
|
||||
if let Ok(data) = fs::read(path) {
|
||||
if let Some(orientation) = read_exif_orientation(&data) {
|
||||
img = apply_orientation(img, orientation);
|
||||
}
|
||||
if let Ok(data) = fs::read(path)
|
||||
&& let Some(orientation) = read_exif_orientation(&data)
|
||||
{
|
||||
img = apply_orientation(img, orientation);
|
||||
}
|
||||
|
||||
Ok(img)
|
||||
@@ -180,7 +206,9 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
|
||||
}
|
||||
let is_le = data[tiff_start] == b'I' && data[tiff_start + 1] == b'I';
|
||||
let read_u16 = |offset: usize| -> Option<u16> {
|
||||
if offset + 2 > data.len() { return None; }
|
||||
if offset + 2 > data.len() {
|
||||
return None;
|
||||
}
|
||||
if is_le {
|
||||
Some(u16::from_le_bytes([data[offset], data[offset + 1]]))
|
||||
} else {
|
||||
@@ -188,11 +216,23 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
|
||||
}
|
||||
};
|
||||
let read_u32 = |offset: usize| -> Option<u32> {
|
||||
if offset + 4 > data.len() { return None; }
|
||||
if offset + 4 > data.len() {
|
||||
return None;
|
||||
}
|
||||
if is_le {
|
||||
Some(u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]))
|
||||
Some(u32::from_le_bytes([
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2],
|
||||
data[offset + 3],
|
||||
]))
|
||||
} else {
|
||||
Some(u32::from_be_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]))
|
||||
Some(u32::from_be_bytes([
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2],
|
||||
data[offset + 3],
|
||||
]))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,7 +242,9 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
|
||||
|
||||
for i in 0..entry_count {
|
||||
let entry_pos = ifd_pos + 2 + i * 12;
|
||||
if entry_pos + 12 > data.len() { break; }
|
||||
if entry_pos + 12 > data.len() {
|
||||
break;
|
||||
}
|
||||
let tag = read_u16(entry_pos)?;
|
||||
if tag == 0x0112 {
|
||||
// Orientation tag
|
||||
@@ -214,14 +256,14 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
|
||||
|
||||
fn apply_orientation(img: DynamicImage, orientation: u16) -> DynamicImage {
|
||||
match orientation {
|
||||
1 => img, // Normal
|
||||
2 => img.fliph(), // Mirrored horizontal
|
||||
3 => img.rotate180(), // Rotated 180
|
||||
4 => img.flipv(), // Mirrored vertical
|
||||
5 => img.rotate90().fliph(), // Mirrored horizontal + 270 CW
|
||||
6 => img.rotate90(), // Rotated 90 CW
|
||||
7 => img.rotate270().fliph(), // Mirrored horizontal + 90 CW
|
||||
8 => img.rotate270(), // Rotated 270 CW
|
||||
1 => img, // Normal
|
||||
2 => img.fliph(), // Mirrored horizontal
|
||||
3 => img.rotate180(), // Rotated 180
|
||||
4 => img.flipv(), // Mirrored vertical
|
||||
5 => img.rotate90().fliph(), // Mirrored horizontal + 270 CW
|
||||
6 => img.rotate90(), // Rotated 90 CW
|
||||
7 => img.rotate270().fliph(), // Mirrored horizontal + 90 CW
|
||||
8 => img.rotate270(), // Rotated 270 CW
|
||||
_ => img,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use chrono::{DateTime, Utc, TimeZone};
|
||||
use chrono::{DateTime, NaiveDate, TimeZone, Utc};
|
||||
|
||||
/// Convert Unix milliseconds to ISO 8601 string (e.g., `2005-11-13T12:00:00.000Z`).
|
||||
pub fn unix_ms_to_iso(ms: i64) -> String {
|
||||
@@ -35,6 +35,24 @@ pub fn now_unix_ms() -> i64 {
|
||||
Utc::now().timestamp_millis()
|
||||
}
|
||||
|
||||
/// Return the inclusive start and exclusive end of a calendar year or month.
|
||||
pub fn calendar_range_unix_ms(year: i32, month: Option<u32>) -> Option<(i64, i64)> {
|
||||
let start_month = month.unwrap_or(1);
|
||||
let (end_year, end_month) = match month {
|
||||
Some(12) | None => (year.checked_add(1)?, 1),
|
||||
Some(month) => (year, month.checked_add(1)?),
|
||||
};
|
||||
let start = NaiveDate::from_ymd_opt(year, start_month, 1)?
|
||||
.and_hms_opt(0, 0, 0)?
|
||||
.and_utc()
|
||||
.timestamp_millis();
|
||||
let end = NaiveDate::from_ymd_opt(end_year, end_month, 1)?
|
||||
.and_hms_opt(0, 0, 0)?
|
||||
.and_utc()
|
||||
.timestamp_millis();
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -83,4 +101,16 @@ mod tests {
|
||||
fn invalid_iso_returns_error() {
|
||||
assert!(iso_to_unix_ms("not a date").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_range_rejects_invalid_month() {
|
||||
assert_eq!(calendar_range_unix_ms(2026, Some(13)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_range_covers_requested_month() {
|
||||
let (start, end) = calendar_range_unix_ms(2026, Some(7)).unwrap();
|
||||
assert_eq!(unix_ms_to_iso(start), "2026-07-01T00:00:00.000Z");
|
||||
assert_eq!(unix_ms_to_iso(end), "2026-08-01T00:00:00.000Z");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user