chore: source formatting and spec allignment

This commit is contained in:
2026-07-18 14:20:23 +02:00
parent a594b99e90
commit 16a210c0ad
119 changed files with 8868 additions and 5250 deletions

View File

@@ -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 ---

View File

@@ -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,
};

View File

@@ -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");
}
}

View File

@@ -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]

View File

@@ -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");
}
}

View File

@@ -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,
}
}

View File

@@ -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");
}
}