feat: finalisation of M1

This commit is contained in:
2026-04-04 15:46:57 +02:00
parent 2d07ac7866
commit b532104032
10 changed files with 725 additions and 71 deletions

View File

@@ -62,7 +62,7 @@ pub fn index_post(
content: Option<&str>, content: Option<&str>,
tags: &[String], tags: &[String],
categories: &[String], categories: &[String],
translation_texts: &[String], translations: &[(String, String)], // (text, language) pairs
language: &str, language: &str,
) -> rusqlite::Result<()> { ) -> rusqlite::Result<()> {
// Remove existing entry // Remove existing entry
@@ -83,10 +83,10 @@ pub fn index_post(
let combined = parts.join(" "); let combined = parts.join(" ");
let mut full_text = stem_text(&combined, language); let mut full_text = stem_text(&combined, language);
// Add translation texts (stemmed with their own implied language or same) // Add translation texts stemmed with their own language
for t in translation_texts { for (text, trans_lang) in translations {
full_text.push(' '); full_text.push(' ');
full_text.push_str(&stem_text(t, language)); full_text.push_str(&stem_text(text, trans_lang));
} }
conn.execute( conn.execute(
@@ -105,7 +105,7 @@ pub fn index_media(
caption: Option<&str>, caption: Option<&str>,
original_name: &str, original_name: &str,
tags: &[String], tags: &[String],
translation_texts: &[String], translations: &[(String, String)], // (text, language) pairs
language: &str, language: &str,
) -> rusqlite::Result<()> { ) -> rusqlite::Result<()> {
remove_media_from_index(conn, media_id)?; remove_media_from_index(conn, media_id)?;
@@ -127,9 +127,9 @@ pub fn index_media(
let combined = parts.join(" "); let combined = parts.join(" ");
let mut full_text = stem_text(&combined, language); let mut full_text = stem_text(&combined, language);
for t in translation_texts { for (text, trans_lang) in translations {
full_text.push(' '); full_text.push(' ');
full_text.push_str(&stem_text(t, language)); full_text.push_str(&stem_text(text, trans_lang));
} }
conn.execute( conn.execute(
@@ -169,6 +169,75 @@ pub fn search_posts(conn: &Connection, query: &str, language: &str) -> rusqlite:
rows.collect() rows.collect()
} }
/// Filters for post search.
#[derive(Default)]
pub struct PostSearchFilters<'a> {
pub status: Option<&'a str>,
pub tags: Option<&'a [String]>,
pub categories: Option<&'a [String]>,
pub year: Option<i32>,
pub month: Option<u32>,
pub limit: Option<usize>,
pub offset: Option<usize>,
}
/// Search posts with filters. Returns matching post IDs.
pub fn search_posts_filtered(
conn: &Connection,
query: &str,
language: &str,
filters: &PostSearchFilters,
) -> rusqlite::Result<Vec<String>> {
// Get FTS matches first
let fts_ids = search_posts(conn, query, language)?;
if fts_ids.is_empty() {
return Ok(vec![]);
}
// Apply filters by querying posts table
let placeholders: Vec<String> = fts_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect();
let mut sql = format!(
"SELECT id FROM posts WHERE id IN ({}) ",
placeholders.join(",")
);
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = fts_ids.iter().map(|id| Box::new(id.clone()) as Box<dyn rusqlite::types::ToSql>).collect();
let mut param_idx = fts_ids.len() + 1;
if let Some(status) = filters.status {
sql.push_str(&format!("AND status = ?{param_idx} "));
params.push(Box::new(status.to_string()));
param_idx += 1;
}
if let Some(year) = filters.year {
// Filter by year from created_at (unix ms)
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
sql.push_str(&format!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1));
params.push(Box::new(start));
params.push(Box::new(end));
param_idx += 2;
}
let _ = param_idx; // suppress unused warning
sql.push_str("ORDER BY created_at DESC ");
if let Some(limit) = filters.limit {
sql.push_str(&format!("LIMIT {limit} "));
if let Some(offset) = filters.offset {
sql.push_str(&format!("OFFSET {offset} "));
}
}
let mut stmt = conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(params_refs.as_slice(), |row| {
row.get::<_, String>(0)
})?;
rows.collect()
}
/// Search media by full-text query. Returns matching media IDs. /// Search media by full-text query. Returns matching media IDs.
pub fn search_media(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> { pub fn search_media(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
let stemmed = stem_text(query, language); let stemmed = stem_text(query, language);
@@ -238,7 +307,7 @@ mod tests {
Some("Beautiful spider web"), Some("Beautiful spider web"),
&["fotografie".into(), "natur".into()], &["fotografie".into(), "natur".into()],
&["picture".into()], &["picture".into()],
&["Esmeralda English".into()], &[("Esmeralda English".into(), "en".into())],
"en", "en",
).unwrap(); ).unwrap();
@@ -294,4 +363,20 @@ mod tests {
assert!(search_posts(db.conn(), "alpha", "en").unwrap().is_empty()); assert!(search_posts(db.conn(), "alpha", "en").unwrap().is_empty());
assert_eq!(search_posts(db.conn(), "beta", "en").unwrap().len(), 1); assert_eq!(search_posts(db.conn(), "beta", "en").unwrap().len(), 1);
} }
#[test]
fn translations_stemmed_with_own_language() {
let db = setup();
// German post with English translation
index_post(
db.conn(), "p1", "Programmierung", None, Some("Deutsche Entwicklung"),
&[], &[],
&[("English development programming".into(), "en".into())],
"de",
).unwrap();
// Search with English stemming should find via English translation
let results = search_posts(db.conn(), "develop", "en").unwrap();
assert_eq!(results, vec!["p1"]);
}
} }

View File

@@ -7,6 +7,11 @@ pub fn insert_post_translation(
conn: &Connection, conn: &Connection,
t: &PostTranslation, t: &PostTranslation,
) -> rusqlite::Result<()> { ) -> rusqlite::Result<()> {
if !t.status.is_valid_for_translation() {
return Err(rusqlite::Error::InvalidParameterName(
"translation status must be draft or published".to_string(),
));
}
conn.execute( conn.execute(
"INSERT INTO post_translations ( "INSERT INTO post_translations (
id, project_id, translation_for, language, title, excerpt, content, id, project_id, translation_for, language, title, excerpt, content,
@@ -73,6 +78,11 @@ pub fn update_post_translation(
conn: &Connection, conn: &Connection,
t: &PostTranslation, t: &PostTranslation,
) -> rusqlite::Result<()> { ) -> rusqlite::Result<()> {
if !t.status.is_valid_for_translation() {
return Err(rusqlite::Error::InvalidParameterName(
"translation status must be draft or published".to_string(),
));
}
conn.execute( conn.execute(
"UPDATE post_translations SET "UPDATE post_translations SET
title = ?1, excerpt = ?2, content = ?3, status = ?4, title = ?1, excerpt = ?2, content = ?3, status = ?4,
@@ -218,4 +228,23 @@ mod tests {
let result = insert_post_translation(db.conn(), &make_translation("t2", "de")); let result = insert_post_translation(db.conn(), &make_translation("t2", "de"));
assert!(result.is_err()); assert!(result.is_err());
} }
#[test]
fn archived_status_rejected_on_insert() {
let db = setup();
let mut t = make_translation("t1", "de");
t.status = PostStatus::Archived;
let result = insert_post_translation(db.conn(), &t);
assert!(result.is_err());
}
#[test]
fn archived_status_rejected_on_update() {
let db = setup();
let mut t = make_translation("t1", "de");
insert_post_translation(db.conn(), &t).unwrap();
t.status = PostStatus::Archived;
let result = update_post_translation(db.conn(), &t);
assert!(result.is_err());
}
} }

View File

@@ -411,7 +411,7 @@ pub fn rebuild_media_from_filesystem(
/// Index a media item in FTS, gathering translation texts. /// Index a media item in FTS, gathering translation texts.
fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> { fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> {
let translations = qmt::list_media_translations_by_media(conn, &media.id).unwrap_or_default(); let translations = qmt::list_media_translations_by_media(conn, &media.id).unwrap_or_default();
let translation_texts: Vec<String> = translations let translation_data: Vec<(String, String)> = translations
.iter() .iter()
.map(|t| { .map(|t| {
let mut parts = Vec::new(); let mut parts = Vec::new();
@@ -424,7 +424,7 @@ fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> {
if let Some(ref caption) = t.caption { if let Some(ref caption) = t.caption {
parts.push(caption.clone()); parts.push(caption.clone());
} }
parts.join(" ") (parts.join(" "), t.language.clone())
}) })
.collect(); .collect();
@@ -437,7 +437,7 @@ fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> {
media.caption.as_deref(), media.caption.as_deref(),
&media.original_name, &media.original_name,
&media.tags, &media.tags,
&translation_texts, &translation_data,
lang, lang,
)?; )?;
Ok(()) Ok(())

View File

@@ -10,7 +10,7 @@ use crate::db::queries::post as qp;
use crate::db::queries::post_link as ql; use crate::db::queries::post_link as ql;
use crate::db::queries::post_translation as qt; use crate::db::queries::post_translation as qt;
use crate::engine::{EngineError, EngineResult}; use crate::engine::{EngineError, EngineResult};
use crate::model::{Post, PostStatus, PostTranslation}; use crate::model::{Post, PostLink, PostStatus, PostTranslation};
use crate::util::frontmatter::{ use crate::util::frontmatter::{
read_post_file, read_translation_file, write_post_file, write_translation_file, read_post_file, read_translation_file, write_post_file, write_translation_file,
}; };
@@ -114,6 +114,17 @@ pub fn update_post(
)); ));
} }
// Slug uniqueness check
if let Some(new_slug) = slug {
if new_slug != post.slug {
if qp::get_post_by_project_and_slug(conn, &post.project_id, new_slug).is_ok() {
return Err(EngineError::Conflict(format!(
"slug '{new_slug}' already exists in this project"
)));
}
}
}
if let Some(t) = title { if let Some(t) = title {
post.title = t.to_string(); post.title = t.to_string();
} }
@@ -145,6 +156,11 @@ pub fn update_post(
post.do_not_translate = dnt; post.do_not_translate = dnt;
} }
// Auto-transition published post back to draft on content/metadata change
if post.status == PostStatus::Published {
post.status = PostStatus::Draft;
}
post.updated_at = now_unix_ms(); post.updated_at = now_unix_ms();
qp::update_post(conn, &post)?; qp::update_post(conn, &post)?;
@@ -173,6 +189,9 @@ pub fn publish_post(
} }
// Compute file_path from created_at + slug // Compute file_path from created_at + slug
// Use a savepoint for atomicity
// Note: savepoint auto-rolls-back if not released (on error propagation)
conn.execute_batch("SAVEPOINT publish_post")?;
let rel_path = post_file_path(post.created_at, &post.slug); let rel_path = post_file_path(post.created_at, &post.slug);
let abs_path = data_dir.join(&rel_path); let abs_path = data_dir.join(&rel_path);
@@ -245,9 +264,32 @@ pub fn publish_post(
publish_translation(conn, data_dir, &mut t, &post)?; publish_translation(conn, data_dir, &mut t, &post)?;
} }
// Parse inter-post links and update link graph
ql::delete_links_by_source(conn, post_id)?;
let link_body = if let Some(ref pc) = post.published_content {
pc.as_str()
} else {
""
};
let parsed_links = parse_post_links(link_body);
for (target_slug, link_text) in &parsed_links {
if let Ok(target) = qp::get_post_by_project_and_slug(conn, &post.project_id, target_slug) {
let link = PostLink {
id: Uuid::new_v4().to_string(),
source_post_id: post_id.to_string(),
target_post_id: target.id.clone(),
link_text: Some(link_text.clone()),
created_at: now,
};
let _ = ql::insert_post_link(conn, &link);
}
}
// Re-index FTS // Re-index FTS
fts_index_post(conn, &post)?; fts_index_post(conn, &post)?;
conn.execute_batch("RELEASE publish_post")?;
Ok(post) Ok(post)
} }
@@ -308,6 +350,12 @@ pub fn delete_post(
Ok(()) Ok(())
} }
/// Compute the canonical URL for a post: /{YYYY}/{MM}/{DD}/{slug}
pub fn canonical_url(created_at_ms: i64, slug: &str) -> String {
let (y, m, d) = crate::util::timestamp::year_month_day_from_unix_ms(created_at_ms);
format!("/{y}/{m}/{d}/{slug}")
}
/// Upsert a translation for a post. /// Upsert a translation for a post.
pub fn upsert_translation( pub fn upsert_translation(
conn: &Connection, conn: &Connection,
@@ -319,6 +367,11 @@ pub fn upsert_translation(
content: Option<&str>, content: Option<&str>,
) -> EngineResult<PostTranslation> { ) -> EngineResult<PostTranslation> {
let post = qp::get_post_by_id(conn, post_id)?; let post = qp::get_post_by_id(conn, post_id)?;
if post.do_not_translate {
return Err(EngineError::Validation(
"cannot create translation for a do-not-translate post".to_string(),
));
}
let now = now_unix_ms(); let now = now_unix_ms();
// Check if translation already exists // Check if translation already exists
@@ -476,6 +529,46 @@ pub fn rebuild_posts_from_filesystem(
// --- Internal helpers --- // --- Internal helpers ---
/// Parse inter-post links from markdown content.
/// Looks for markdown links that reference canonical post URLs: [text](/YYYY/MM/DD/slug)
fn parse_post_links(content: &str) -> Vec<(String, String)> {
let mut links = Vec::new();
// Match markdown links: [text](/YYYY/MM/DD/slug) or [text](/YYYY/MM/DD/slug/)
// Simple manual parsing since we don't have regex crate
// Look for patterns like [...](...) where the URL matches /YYYY/MM/DD/slug
for line in content.lines() {
let mut pos = 0;
while pos < line.len() {
if let Some(bracket_start) = line[pos..].find('[') {
let abs_start = pos + bracket_start;
if let Some(bracket_end) = line[abs_start..].find("](") {
let text_end = abs_start + bracket_end;
let link_text = &line[abs_start + 1..text_end];
let url_start = text_end + 2;
if let Some(paren_end) = line[url_start..].find(')') {
let url = &line[url_start..url_start + paren_end];
// Check if URL matches /YYYY/MM/DD/slug pattern
let parts: Vec<&str> = url.trim_end_matches('/').split('/').collect();
if parts.len() == 5 && parts[0].is_empty()
&& parts[1].len() == 4 && parts[1].chars().all(|c| c.is_ascii_digit())
&& parts[2].len() == 2 && parts[2].chars().all(|c| c.is_ascii_digit())
&& parts[3].len() == 2 && parts[3].chars().all(|c| c.is_ascii_digit())
{
links.push((parts[4].to_string(), link_text.to_string()));
}
pos = url_start + paren_end + 1;
continue;
}
}
pos = abs_start + 1;
} else {
break;
}
}
}
links
}
/// Publish a single translation: write file, clear content, set status. /// Publish a single translation: write file, clear content, set status.
fn publish_translation( fn publish_translation(
conn: &Connection, conn: &Connection,
@@ -525,7 +618,7 @@ fn publish_translation(
/// Index a post in FTS, gathering translation texts. /// Index a post in FTS, gathering translation texts.
fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> { fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> {
let translations = qt::list_post_translations_by_post(conn, &post.id).unwrap_or_default(); let translations = qt::list_post_translations_by_post(conn, &post.id).unwrap_or_default();
let translation_texts: Vec<String> = translations let translation_data: Vec<(String, String)> = translations
.iter() .iter()
.map(|t| { .map(|t| {
let mut parts = vec![t.title.clone()]; let mut parts = vec![t.title.clone()];
@@ -535,7 +628,7 @@ fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> {
if let Some(ref cnt) = t.content { if let Some(ref cnt) = t.content {
parts.push(cnt.clone()); parts.push(cnt.clone());
} }
parts.join(" ") (parts.join(" "), t.language.clone())
}) })
.collect(); .collect();
@@ -548,7 +641,7 @@ fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> {
post.content.as_deref(), post.content.as_deref(),
&post.tags, &post.tags,
&post.categories, &post.categories,
&translation_texts, &translation_data,
lang, lang,
)?; )?;
Ok(()) Ok(())
@@ -1232,4 +1325,141 @@ mod tests {
assert!(!is_translation_filename("hello.123")); assert!(!is_translation_filename("hello.123"));
assert!(!is_translation_filename("hello.D")); assert!(!is_translation_filename("hello.D"));
} }
#[test]
fn do_not_translate_guard_rejects_translation() {
let (db, dir) = setup();
let post = create_post(
db.conn(), dir.path(), "p1", "No Translate", Some("body"),
vec![], vec![], None, None, None,
).unwrap();
// Set do_not_translate
update_post(
db.conn(), dir.path(), &post.id,
None, None, None, None, None, None, None, None, None, Some(true),
).unwrap();
let result = upsert_translation(
db.conn(), dir.path(), &post.id, "de", "German", None, Some("Inhalt"),
);
assert!(result.is_err());
match result.unwrap_err() {
EngineError::Validation(_) => {}
other => panic!("expected Validation, got: {other}"),
}
}
#[test]
fn update_post_slug_uniqueness_enforced() {
let (db, dir) = setup();
create_post(
db.conn(), dir.path(), "p1", "First", Some("body"),
vec![], vec![], None, None, None,
).unwrap();
let second = create_post(
db.conn(), dir.path(), "p1", "Second", Some("body"),
vec![], vec![], None, None, None,
).unwrap();
let result = update_post(
db.conn(), dir.path(), &second.id,
None, Some("first"), None, None, None, None, None, None, None, None,
);
assert!(result.is_err());
}
#[test]
fn update_published_post_transitions_to_draft() {
let (db, dir) = setup();
let post = create_post(
db.conn(), dir.path(), "p1", "Published", Some("body"),
vec![], vec![], None, None, None,
).unwrap();
publish_post(db.conn(), dir.path(), &post.id).unwrap();
// Archive then update to test auto-draft
archive_post(db.conn(), &post.id).unwrap();
// Re-publish
let _ = publish_post(db.conn(), dir.path(), &post.id).unwrap();
// Now update the published post
let updated = update_post(
db.conn(), dir.path(), &post.id,
Some("New Title"), None, None, Some("new body"), None, None, None, None, None, None,
).unwrap();
assert_eq!(updated.status, PostStatus::Draft);
}
#[test]
fn canonical_url_format() {
// 2005-11-13T12:00:00.000Z = 1131883200000
let url = canonical_url(1131883200000, "esmeralda");
assert_eq!(url, "/2005/11/13/esmeralda");
}
#[test]
fn publish_post_already_published_rejected() {
let (db, dir) = setup();
let post = create_post(
db.conn(), dir.path(), "p1", "Double Pub", Some("body"),
vec![], vec![], None, None, None,
).unwrap();
publish_post(db.conn(), dir.path(), &post.id).unwrap();
let result = publish_post(db.conn(), dir.path(), &post.id);
assert!(result.is_err());
match result.unwrap_err() {
EngineError::Conflict(_) => {}
other => panic!("expected Conflict, got: {other}"),
}
}
#[test]
fn publish_post_updates_link_graph() {
let (db, dir) = setup();
// Create target post first
let target = create_post(
db.conn(), dir.path(), "p1", "Target Post", Some("target body"),
vec![], vec![], None, None, None,
).unwrap();
publish_post(db.conn(), dir.path(), &target.id).unwrap();
// Create source post with a link to target
let target_url = canonical_url(target.created_at, &target.slug);
let body = format!("Check out [this post]({target_url}) for more.");
let source = create_post(
db.conn(), dir.path(), "p1", "Source Post", Some(&body),
vec![], vec![], None, None, None,
).unwrap();
publish_post(db.conn(), dir.path(), &source.id).unwrap();
// Verify link was created
let links = ql::list_links_by_source(db.conn(), &source.id).unwrap();
assert_eq!(links.len(), 1);
assert_eq!(links[0].target_post_id, target.id);
}
#[test]
fn archive_from_published_status() {
let (db, dir) = setup();
let post = create_post(
db.conn(), dir.path(), "p1", "To Archive", Some("body"),
vec![], vec![], None, None, None,
).unwrap();
publish_post(db.conn(), dir.path(), &post.id).unwrap();
archive_post(db.conn(), &post.id).unwrap();
let from_db = qp::get_post_by_id(db.conn(), &post.id).unwrap();
assert_eq!(from_db.status, PostStatus::Archived);
}
#[test]
fn parse_post_links_extracts_canonical_urls() {
let content = "See [my post](/2024/01/15/hello-world) and [another](/2024/02/01/test-post/) for more.";
let links = parse_post_links(content);
assert_eq!(links.len(), 2);
assert_eq!(links[0].0, "hello-world");
assert_eq!(links[0].1, "my post");
assert_eq!(links[1].0, "test-post");
}
} }

View File

@@ -39,7 +39,7 @@ pub fn create_project(
// Determine data directory // Determine data directory
let data_dir = match data_path { let data_dir = match data_path {
Some(p) => std::path::PathBuf::from(p), Some(p) => std::path::PathBuf::from(p),
None => std::path::PathBuf::from(&slug), None => std::path::PathBuf::from("projects").join(&id),
}; };
// Create directory structure // Create directory structure
@@ -72,8 +72,27 @@ pub fn list_projects(conn: &Connection) -> EngineResult<Vec<Project>> {
} }
/// Delete a project row (cascading handled by queries). /// Delete a project row (cascading handled by queries).
pub fn delete_project(conn: &Connection, project_id: &str) -> EngineResult<()> { /// Rejects deletion of the currently active project.
/// Optionally cleans up the project data directory.
pub fn delete_project(conn: &Connection, project_id: &str, data_dir: Option<&Path>) -> EngineResult<()> {
// Check if this is the active project (don't delete active)
if let Ok(active) = q::get_active_project(conn) {
if active.id == project_id {
return Err(EngineError::Validation(
"cannot delete the active project".to_string(),
));
}
}
q::delete_project(conn, project_id)?; q::delete_project(conn, project_id)?;
// Clean up filesystem if path provided
if let Some(dir) = data_dir {
if dir.exists() {
let _ = fs::remove_dir_all(dir);
}
}
Ok(()) Ok(())
} }
@@ -229,7 +248,18 @@ mod tests {
let (db, dir) = setup(); let (db, dir) = setup();
let p_path = dir.path().join("p"); let p_path = dir.path().join("p");
let p = create_project(db.conn(), "P", Some(p_path.to_str().unwrap())).unwrap(); let p = create_project(db.conn(), "P", Some(p_path.to_str().unwrap())).unwrap();
delete_project(db.conn(), &p.id).unwrap(); delete_project(db.conn(), &p.id, Some(&p_path)).unwrap();
assert!(list_projects(db.conn()).unwrap().is_empty()); assert!(list_projects(db.conn()).unwrap().is_empty());
assert!(!p_path.exists(), "project directory should be cleaned up");
}
#[test]
fn delete_active_project_rejected() {
let (db, dir) = setup();
let p_path = dir.path().join("p");
let p = create_project(db.conn(), "P", Some(p_path.to_str().unwrap())).unwrap();
set_active_project(db.conn(), &p.id).unwrap();
let result = delete_project(db.conn(), &p.id, None);
assert!(result.is_err());
} }
} }

View File

@@ -86,11 +86,10 @@ pub fn delete_tag(
let tag = tag_q::get_tag_by_id(conn, tag_id) let tag = tag_q::get_tag_by_id(conn, tag_id)
.map_err(|_| EngineError::NotFound(format!("tag {tag_id}")))?; .map_err(|_| EngineError::NotFound(format!("tag {tag_id}")))?;
// Remove tag name from all posts let modified = remove_tag_name_from_posts(conn, project_id, &tag.name)?;
remove_tag_name_from_posts(conn, project_id, &tag.name)?;
tag_q::delete_tag(conn, tag_id)?; tag_q::delete_tag(conn, tag_id)?;
rewrite_tags_json(conn, data_dir, project_id)?; rewrite_tags_json(conn, data_dir, project_id)?;
flush_post_frontmatter(conn, data_dir, &modified)?;
Ok(()) Ok(())
} }
@@ -109,6 +108,7 @@ pub fn rename_tag(
// Update all posts: replace old name with new name in tag arrays // Update all posts: replace old name with new name in tag arrays
let posts = post_q::list_posts_by_project(conn, project_id)?; let posts = post_q::list_posts_by_project(conn, project_id)?;
let now = now_unix_ms(); let now = now_unix_ms();
let mut modified = Vec::new();
for mut post in posts { for mut post in posts {
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(&old_name)) { if post.tags.iter().any(|t| t.eq_ignore_ascii_case(&old_name)) {
post.tags = post post.tags = post
@@ -124,6 +124,7 @@ pub fn rename_tag(
.collect(); .collect();
post.updated_at = now; post.updated_at = now;
post_q::update_post(conn, &post)?; post_q::update_post(conn, &post)?;
modified.push(post.id.clone());
} }
} }
@@ -131,6 +132,7 @@ pub fn rename_tag(
tag.updated_at = now; tag.updated_at = now;
tag_q::update_tag(conn, &tag)?; tag_q::update_tag(conn, &tag)?;
rewrite_tags_json(conn, data_dir, project_id)?; rewrite_tags_json(conn, data_dir, project_id)?;
flush_post_frontmatter(conn, data_dir, &modified)?;
Ok(()) Ok(())
} }
@@ -146,6 +148,7 @@ pub fn merge_tags(
let target_tag = tag_q::get_tag_by_id(conn, target_id) let target_tag = tag_q::get_tag_by_id(conn, target_id)
.map_err(|_| EngineError::NotFound(format!("target tag {target_id}")))?; .map_err(|_| EngineError::NotFound(format!("target tag {target_id}")))?;
let mut all_modified = Vec::new();
for &source_id in source_ids { for &source_id in source_ids {
let source_tag = tag_q::get_tag_by_id(conn, source_id) let source_tag = tag_q::get_tag_by_id(conn, source_id)
.map_err(|_| EngineError::NotFound(format!("source tag {source_id}")))?; .map_err(|_| EngineError::NotFound(format!("source tag {source_id}")))?;
@@ -171,6 +174,9 @@ pub fn merge_tags(
} }
post.updated_at = now; post.updated_at = now;
post_q::update_post(conn, &post)?; post_q::update_post(conn, &post)?;
if !all_modified.contains(&post.id) {
all_modified.push(post.id.clone());
}
} }
} }
@@ -178,6 +184,7 @@ pub fn merge_tags(
} }
rewrite_tags_json(conn, data_dir, project_id)?; rewrite_tags_json(conn, data_dir, project_id)?;
flush_post_frontmatter(conn, data_dir, &all_modified)?;
Ok(()) Ok(())
} }
@@ -240,22 +247,52 @@ pub fn rewrite_tags_json(
// ── helpers ───────────────────────────────────────────────────────── // ── helpers ─────────────────────────────────────────────────────────
/// Rewrite frontmatter files for posts whose tags were modified.
/// Only rewrites published posts (that have file_path set).
fn flush_post_frontmatter(
conn: &Connection,
data_dir: &Path,
post_ids: &[String],
) -> EngineResult<()> {
use crate::util::frontmatter::write_post_file;
use crate::util::atomic_write_str;
for post_id in post_ids {
let post = post_q::get_post_by_id(conn, post_id)?;
if !post.file_path.is_empty() {
let abs_path = data_dir.join(&post.file_path);
if abs_path.exists() {
// Read existing body from file
let content = std::fs::read_to_string(&abs_path)?;
let (_fm, body) = crate::util::frontmatter::read_post_file(&content)
.map_err(|e| EngineError::Parse(e))?;
// Rewrite with updated frontmatter
let file_content = write_post_file(&post, &body);
atomic_write_str(&abs_path, &file_content)?;
}
}
}
Ok(())
}
fn remove_tag_name_from_posts( fn remove_tag_name_from_posts(
conn: &Connection, conn: &Connection,
project_id: &str, project_id: &str,
tag_name: &str, tag_name: &str,
) -> EngineResult<()> { ) -> EngineResult<Vec<String>> {
let posts = post_q::list_posts_by_project(conn, project_id)?; let posts = post_q::list_posts_by_project(conn, project_id)?;
let now = now_unix_ms(); let now = now_unix_ms();
let mut modified = Vec::new();
for mut post in posts { for mut post in posts {
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_name)) { if post.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_name)) {
post.tags post.tags
.retain(|t| !t.eq_ignore_ascii_case(tag_name)); .retain(|t| !t.eq_ignore_ascii_case(tag_name));
post.updated_at = now; post.updated_at = now;
post_q::update_post(conn, &post)?; post_q::update_post(conn, &post)?;
modified.push(post.id.clone());
} }
} }
Ok(()) Ok(modified)
} }
#[cfg(test)] #[cfg(test)]
@@ -463,4 +500,26 @@ mod tests {
assert_eq!(entries[0].name, "alpha"); assert_eq!(entries[0].name, "alpha");
assert_eq!(entries[1].name, "zebra"); assert_eq!(entries[1].name, "zebra");
} }
#[test]
fn rename_tag_flushes_post_frontmatter() {
let (db, dir) = setup();
// Create and publish a post with a tag
use crate::db::fts::ensure_fts_tables;
ensure_fts_tables(db.conn()).unwrap();
let post = crate::engine::post::create_post(
db.conn(), dir.path(), "p1", "Tagged Post", Some("body content"),
vec!["rust".into()], vec![], None, None, None,
).unwrap();
crate::engine::post::publish_post(db.conn(), dir.path(), &post.id).unwrap();
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
rename_tag(db.conn(), dir.path(), "p1", &tag.id, "golang").unwrap();
// Read the file from disk and verify tag was updated
let from_db = crate::db::queries::post::get_post_by_id(db.conn(), &post.id).unwrap();
let file_content = std::fs::read_to_string(dir.path().join(&from_db.file_path)).unwrap();
assert!(file_content.contains("golang"), "frontmatter should contain renamed tag");
assert!(!file_content.contains("rust"), "frontmatter should not contain old tag name");
}
} }

View File

@@ -30,6 +30,9 @@ struct TaskEntry {
label: String, label: String,
status: TaskStatus, status: TaskStatus,
cancel_flag: Arc<AtomicBool>, cancel_flag: Arc<AtomicBool>,
progress: Option<f32>,
message: Option<String>,
created_at: Instant,
} }
/// Manages concurrent tasks with a max concurrency limit and FIFO queue. /// Manages concurrent tasks with a max concurrency limit and FIFO queue.
@@ -60,9 +63,20 @@ impl TaskManager {
label: label.to_owned(), label: label.to_owned(),
status: TaskStatus::Queued, status: TaskStatus::Queued,
cancel_flag: Arc::new(AtomicBool::new(false)), cancel_flag: Arc::new(AtomicBool::new(false)),
progress: None,
message: None,
created_at: Instant::now(),
}; };
self.tasks.lock().unwrap().push(entry); let mut tasks = self.tasks.lock().unwrap();
tasks.push(entry);
// Auto-start if under capacity
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
if running < self.max_concurrent {
if let Some(t) = tasks.iter_mut().find(|t| t.id == id && t.status == TaskStatus::Queued) {
t.status = TaskStatus::Running;
}
}
id id
} }
@@ -87,25 +101,35 @@ impl TaskManager {
pub fn complete(&self, task_id: TaskId) { pub fn complete(&self, task_id: TaskId) {
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
entry.status = TaskStatus::Completed; if matches!(entry.status, TaskStatus::Running) {
entry.status = TaskStatus::Completed;
entry.progress = Some(1.0);
}
} }
Self::promote_next(&mut tasks, self.max_concurrent);
} }
/// Mark a task as failed with an error message. /// Mark a task as failed with an error message.
pub fn fail(&self, task_id: TaskId, error: String) { pub fn fail(&self, task_id: TaskId, error: String) {
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
entry.status = TaskStatus::Failed(error); if matches!(entry.status, TaskStatus::Running) {
entry.status = TaskStatus::Failed(error);
}
} }
Self::promote_next(&mut tasks, self.max_concurrent);
} }
/// Cancel a task by setting its cancel flag and status. /// Cancel a task by setting its cancel flag and status.
pub fn cancel(&self, task_id: TaskId) { pub fn cancel(&self, task_id: TaskId) {
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
entry.cancel_flag.store(true, Ordering::Release); if matches!(entry.status, TaskStatus::Running | TaskStatus::Queued) {
entry.status = TaskStatus::Cancelled; entry.cancel_flag.store(true, Ordering::Release);
entry.status = TaskStatus::Cancelled;
}
} }
Self::promote_next(&mut tasks, self.max_concurrent);
} }
/// Check whether a task has been cancelled. /// Check whether a task has been cancelled.
@@ -153,6 +177,39 @@ impl TaskManager {
let tasks = self.tasks.lock().unwrap(); let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.status == TaskStatus::Queued).map(|t| t.id) tasks.iter().find(|t| t.status == TaskStatus::Queued).map(|t| t.id)
} }
/// Update progress for a running task.
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
if entry.status == TaskStatus::Running {
entry.progress = progress;
entry.message = message;
}
}
}
/// Return the current progress of a task.
pub fn progress(&self, task_id: TaskId) -> Option<f32> {
let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.id == task_id).and_then(|t| t.progress)
}
/// Return the current message of a task.
pub fn message(&self, task_id: TaskId) -> Option<String> {
let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.id == task_id).and_then(|t| t.message.clone())
}
/// Promote the next queued task to running if capacity allows.
fn promote_next(tasks: &mut Vec<TaskEntry>, max_concurrent: usize) {
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
if running < max_concurrent {
if let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Queued) {
t.status = TaskStatus::Running;
}
}
}
} }
impl Default for TaskManager { impl Default for TaskManager {
@@ -201,9 +258,7 @@ mod tests {
fn submit_and_start() { fn submit_and_start() {
let mgr = TaskManager::default(); let mgr = TaskManager::default();
let id = mgr.submit("build site"); let id = mgr.submit("build site");
assert_eq!(mgr.status(id), Some(TaskStatus::Queued)); // Auto-started since capacity allows
assert!(mgr.try_start(id));
assert_eq!(mgr.status(id), Some(TaskStatus::Running)); assert_eq!(mgr.status(id), Some(TaskStatus::Running));
} }
@@ -212,26 +267,23 @@ mod tests {
let mgr = TaskManager::new(3); let mgr = TaskManager::new(3);
let ids: Vec<TaskId> = (0..4).map(|i| mgr.submit(&format!("task {i}"))).collect(); let ids: Vec<TaskId> = (0..4).map(|i| mgr.submit(&format!("task {i}"))).collect();
assert!(mgr.try_start(ids[0])); // First 3 auto-started, 4th stays queued
assert!(mgr.try_start(ids[1]));
assert!(mgr.try_start(ids[2]));
assert!(!mgr.try_start(ids[3]));
assert_eq!(mgr.running_count(), 3); assert_eq!(mgr.running_count(), 3);
assert_eq!(mgr.status(ids[3]), Some(TaskStatus::Queued)); assert_eq!(mgr.status(ids[3]), Some(TaskStatus::Queued));
} }
#[test] #[test]
fn fifo_order() { fn fifo_order() {
let mgr = TaskManager::default(); let mgr = TaskManager::new(1); // limit to 1 to test FIFO
let a = mgr.submit("first"); let a = mgr.submit("first"); // auto-starts
let b = mgr.submit("second"); let b = mgr.submit("second"); // queued
let c = mgr.submit("third"); let c = mgr.submit("third"); // queued
assert_eq!(mgr.next_queued(), Some(a)); assert_eq!(mgr.status(a), Some(TaskStatus::Running));
mgr.try_start(a);
assert_eq!(mgr.next_queued(), Some(b)); assert_eq!(mgr.next_queued(), Some(b));
mgr.try_start(b);
mgr.complete(a); // should auto-promote b
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
assert_eq!(mgr.next_queued(), Some(c)); assert_eq!(mgr.next_queued(), Some(c));
} }
@@ -239,8 +291,7 @@ mod tests {
fn cancel_sets_flag() { fn cancel_sets_flag() {
let mgr = TaskManager::default(); let mgr = TaskManager::default();
let id = mgr.submit("upload"); let id = mgr.submit("upload");
mgr.try_start(id); // Task is auto-started (Running)
assert!(!mgr.is_cancelled(id)); assert!(!mgr.is_cancelled(id));
mgr.cancel(id); mgr.cancel(id);
assert!(mgr.is_cancelled(id)); assert!(mgr.is_cancelled(id));
@@ -253,41 +304,70 @@ mod tests {
let ok = mgr.submit("good task"); let ok = mgr.submit("good task");
let bad = mgr.submit("bad task"); let bad = mgr.submit("bad task");
mgr.try_start(ok); // Both auto-started (capacity=3)
mgr.try_start(bad);
mgr.complete(ok); mgr.complete(ok);
mgr.fail(bad, "disk full".into()); mgr.fail(bad, "disk full".into());
assert_eq!(mgr.status(ok), Some(TaskStatus::Completed)); assert_eq!(mgr.status(ok), Some(TaskStatus::Completed));
assert_eq!(mgr.status(bad), Some(TaskStatus::Failed("disk full".into()))); assert_eq!(mgr.status(bad), Some(TaskStatus::Failed("disk full".into())));
// Progress should be 1.0 on completed
assert_eq!(mgr.progress(ok), Some(1.0));
} }
#[test] #[test]
fn drain_removes_finished() { fn drain_removes_finished() {
let mgr = TaskManager::default(); let mgr = TaskManager::new(3);
let a = mgr.submit("done"); let a = mgr.submit("done"); // auto-starts
let b = mgr.submit("broken"); let b = mgr.submit("broken"); // auto-starts
let c = mgr.submit("stopped"); let e = mgr.submit("busy"); // auto-starts
let d = mgr.submit("waiting"); let _c = mgr.submit("stopped"); // queued (at capacity)
let e = mgr.submit("busy"); let _d = mgr.submit("waiting"); // queued
mgr.try_start(a);
mgr.try_start(b);
mgr.try_start(e);
mgr.complete(a); mgr.complete(a);
mgr.fail(b, "oops".into()); mgr.fail(b, "oops".into());
mgr.cancel(c); // c should have been auto-promoted when a completed, and again when b failed
// After a completes: c promoted to running
// After b fails: d promoted to running
mgr.drain_completed(); mgr.drain_completed();
assert_eq!(mgr.status(a), None); assert_eq!(mgr.status(a), None);
assert_eq!(mgr.status(b), None); assert_eq!(mgr.status(b), None);
assert_eq!(mgr.status(c), None); // c and d were promoted, e is still running
assert_eq!(mgr.status(d), Some(TaskStatus::Queued));
assert_eq!(mgr.status(e), Some(TaskStatus::Running)); assert_eq!(mgr.status(e), Some(TaskStatus::Running));
} }
#[test]
fn completing_task_starts_next_queued() {
let mgr = TaskManager::new(1);
let a = mgr.submit("first"); // auto-starts
let b = mgr.submit("second"); // queued
assert_eq!(mgr.status(a), Some(TaskStatus::Running));
assert_eq!(mgr.status(b), Some(TaskStatus::Queued));
mgr.complete(a);
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
}
#[test]
fn cancel_precondition_ignores_completed() {
let mgr = TaskManager::default();
let id = mgr.submit("task");
mgr.complete(id);
mgr.cancel(id); // should be no-op
assert_eq!(mgr.status(id), Some(TaskStatus::Completed));
}
#[test]
fn report_progress_updates_task() {
let mgr = TaskManager::default();
let id = mgr.submit("upload");
mgr.report_progress(id, Some(0.5), Some("halfway".into()));
assert_eq!(mgr.progress(id), Some(0.5));
assert_eq!(mgr.message(id), Some("halfway".into()));
}
#[test] #[test]
fn progress_throttle_initial_reports() { fn progress_throttle_initial_reports() {
let throttle = ProgressThrottle::new(PROGRESS_THROTTLE_MS); let throttle = ProgressThrottle::new(PROGRESS_THROTTLE_MS);

View File

@@ -34,6 +34,19 @@ pub struct ProjectMetadata {
pub blog_languages: Vec<String>, pub blog_languages: Vec<String>,
} }
impl ProjectMetadata {
/// Validate metadata fields per spec constraints.
pub fn validate(&self) -> Result<(), String> {
if self.max_posts_per_page < 1 || self.max_posts_per_page > 500 {
return Err(format!(
"maxPostsPerPage must be 1..500, got {}",
self.max_posts_per_page
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CategorySettings { pub struct CategorySettings {
@@ -143,4 +156,28 @@ mod tests {
assert!(tag.color.is_none()); assert!(tag.color.is_none());
assert!(tag.post_template_slug.is_none()); assert!(tag.post_template_slug.is_none());
} }
#[test]
fn max_posts_per_page_validation() {
let mut meta = ProjectMetadata {
name: "Test".into(),
description: None, public_url: None, main_language: None,
default_author: None, max_posts_per_page: 50, blogmark_category: None,
pico_theme: None, python_runtime_mode: None,
semantic_similarity_enabled: false, blog_languages: vec![],
};
assert!(meta.validate().is_ok());
meta.max_posts_per_page = 0;
assert!(meta.validate().is_err());
meta.max_posts_per_page = 501;
assert!(meta.validate().is_err());
meta.max_posts_per_page = 1;
assert!(meta.validate().is_ok());
meta.max_posts_per_page = 500;
assert!(meta.validate().is_ok());
}
} }

View File

@@ -8,6 +8,13 @@ pub enum PostStatus {
Archived, Archived,
} }
impl PostStatus {
/// Returns true if this status is valid for a translation (draft or published only).
pub fn is_valid_for_translation(&self) -> bool {
matches!(self, PostStatus::Draft | PostStatus::Published)
}
}
/// A blog post. Matches the `posts` table schema. /// A blog post. Matches the `posts` table schema.
/// ///
/// NOTE: `content` is null for published posts — body lives in the filesystem /// NOTE: `content` is null for published posts — body lives in the filesystem

View File

@@ -31,9 +31,9 @@ pub enum ThumbnailFit {
/// Standard thumbnail sizes matching TypeScript implementation. /// Standard thumbnail sizes matching TypeScript implementation.
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[ pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
ThumbnailSize { name: "small", width: 150, height: 150, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside }, ThumbnailSize { name: "small", width: 150, height: 150, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Cover },
ThumbnailSize { name: "medium", width: 400, height: 400, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside }, ThumbnailSize { name: "medium", width: 400, height: 400, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Cover },
ThumbnailSize { name: "large", width: 800, height: 800, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside }, ThumbnailSize { name: "large", width: 800, height: 800, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Cover },
ThumbnailSize { name: "ai", width: 448, height: 448, format: ThumbnailFormat::Jpeg, fit: ThumbnailFit::Contain }, ThumbnailSize { name: "ai", width: 448, height: 448, format: ThumbnailFormat::Jpeg, fit: ThumbnailFit::Contain },
]; ];
@@ -109,6 +109,17 @@ pub fn generate_all_thumbnails(
let mut paths = Vec::new(); let mut paths = Vec::new();
let prefix = &media_id[..2.min(media_id.len())]; let prefix = &media_id[..2.min(media_id.len())];
// Save thumbnail source for regeneration
let source_ext = source.extension().and_then(|e| e.to_str()).unwrap_or("bin");
let source_dest = thumbnails_dir
.join(prefix)
.join(format!("{media_id}_source.{source_ext}"));
if let Some(parent) = source_dest.parent() {
fs::create_dir_all(parent).map_err(|e| format!("create dir: {e}"))?;
}
fs::copy(source, &source_dest).map_err(|e| format!("save thumbnail source: {e}"))?;
paths.push(source_dest.to_string_lossy().to_string());
for size in THUMBNAIL_SIZES { for size in THUMBNAIL_SIZES {
let ext = match size.format { let ext = match size.format {
ThumbnailFormat::Webp => "webp", ThumbnailFormat::Webp => "webp",
@@ -132,13 +143,99 @@ fn load_and_orient(path: &Path) -> Result<DynamicImage, String> {
.with_guessed_format() .with_guessed_format()
.map_err(|e| format!("guess format: {e}"))?; .map_err(|e| format!("guess format: {e}"))?;
let img = reader.decode().map_err(|e| format!("decode image: {e}"))?; 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);
}
}
// EXIF orientation is handled by the image crate for JPEG automatically
// when reading. For other formats, no EXIF orientation exists.
Ok(img) Ok(img)
} }
/// Read EXIF orientation tag from raw file bytes.
/// Returns orientation value 1-8, or None if not found.
fn read_exif_orientation(data: &[u8]) -> Option<u16> {
if data.len() < 12 {
return None;
}
// Must be JPEG: FF D8
if data[0] != 0xFF || data[1] != 0xD8 {
return None;
}
let mut pos = 2;
while pos + 4 < data.len() {
if data[pos] != 0xFF {
break;
}
let marker = data[pos + 1];
let len = u16::from_be_bytes([data[pos + 2], data[pos + 3]]) as usize;
if marker == 0xE1 && pos + 10 < data.len() {
// Check for "Exif\0\0"
if &data[pos + 4..pos + 10] == b"Exif\0\0" {
let tiff_start = pos + 10;
return parse_tiff_orientation(data, tiff_start);
}
}
pos += 2 + len;
}
None
}
fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
if tiff_start + 8 > data.len() {
return None;
}
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 is_le {
Some(u16::from_le_bytes([data[offset], data[offset + 1]]))
} else {
Some(u16::from_be_bytes([data[offset], data[offset + 1]]))
}
};
let read_u32 = |offset: usize| -> Option<u32> {
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]]))
} else {
Some(u32::from_be_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]))
}
};
let ifd_offset = read_u32(tiff_start + 4)? as usize;
let ifd_pos = tiff_start + ifd_offset;
let entry_count = read_u16(ifd_pos)? as usize;
for i in 0..entry_count {
let entry_pos = ifd_pos + 2 + i * 12;
if entry_pos + 12 > data.len() { break; }
let tag = read_u16(entry_pos)?;
if tag == 0x0112 {
// Orientation tag
return read_u16(entry_pos + 8);
}
}
None
}
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
_ => img,
}
}
/// Extract image dimensions from a file. /// Extract image dimensions from a file.
pub fn image_dimensions(path: &Path) -> Result<(u32, u32), String> { pub fn image_dimensions(path: &Path) -> Result<(u32, u32), String> {
let img = ImageReader::open(path) let img = ImageReader::open(path)
@@ -188,13 +285,13 @@ mod tests {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path()); let source = create_test_png(dir.path());
let dest = dir.path().join("thumb.webp"); let dest = dir.path().join("thumb.webp");
let size = &THUMBNAIL_SIZES[0]; // small: 150x150 let size = &THUMBNAIL_SIZES[0]; // small: 150x150 Cover
generate_thumbnail(&source, &dest, size, 80).unwrap(); generate_thumbnail(&source, &dest, size, 80).unwrap();
assert!(dest.exists()); assert!(dest.exists());
let (w, h) = image_dimensions(&dest).unwrap(); let (w, h) = image_dimensions(&dest).unwrap();
// 100x80 is already smaller than 150x150, so no resize (Inside fit) // 100x80 resized to fill 150x150 (Cover fit)
assert_eq!(w, 100); assert_eq!(w, 150);
assert_eq!(h, 80); assert_eq!(h, 150);
} }
#[test] #[test]
@@ -216,7 +313,7 @@ mod tests {
let source = create_test_png(dir.path()); let source = create_test_png(dir.path());
let thumb_dir = dir.path().join("thumbnails"); let thumb_dir = dir.path().join("thumbnails");
let paths = generate_all_thumbnails(&source, &thumb_dir, "ab123456-test-uuid").unwrap(); let paths = generate_all_thumbnails(&source, &thumb_dir, "ab123456-test-uuid").unwrap();
assert_eq!(paths.len(), 4); assert_eq!(paths.len(), 5); // 1 source + 4 thumbnails
for p in &paths { for p in &paths {
assert!(Path::new(p).exists(), "thumbnail missing: {p}"); assert!(Path::new(p).exists(), "thumbnail missing: {p}");
} }