diff --git a/crates/bds-core/src/db/fts.rs b/crates/bds-core/src/db/fts.rs index 3933c61..331f249 100644 --- a/crates/bds-core/src/db/fts.rs +++ b/crates/bds-core/src/db/fts.rs @@ -62,7 +62,7 @@ pub fn index_post( content: Option<&str>, tags: &[String], categories: &[String], - translation_texts: &[String], + translations: &[(String, String)], // (text, language) pairs language: &str, ) -> rusqlite::Result<()> { // Remove existing entry @@ -83,10 +83,10 @@ pub fn index_post( let combined = parts.join(" "); let mut full_text = stem_text(&combined, language); - // Add translation texts (stemmed with their own implied language or same) - for t in translation_texts { + // Add translation texts stemmed with their own language + for (text, trans_lang) in translations { full_text.push(' '); - full_text.push_str(&stem_text(t, language)); + full_text.push_str(&stem_text(text, trans_lang)); } conn.execute( @@ -105,7 +105,7 @@ pub fn index_media( caption: Option<&str>, original_name: &str, tags: &[String], - translation_texts: &[String], + translations: &[(String, String)], // (text, language) pairs language: &str, ) -> rusqlite::Result<()> { remove_media_from_index(conn, media_id)?; @@ -127,9 +127,9 @@ pub fn index_media( let combined = parts.join(" "); 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_str(&stem_text(t, language)); + full_text.push_str(&stem_text(text, trans_lang)); } conn.execute( @@ -169,6 +169,75 @@ pub fn search_posts(conn: &Connection, query: &str, language: &str) -> rusqlite: 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, + pub month: Option, + pub limit: Option, + pub offset: Option, +} + +/// Search posts with filters. Returns matching post IDs. +pub fn search_posts_filtered( + conn: &Connection, + query: &str, + language: &str, + filters: &PostSearchFilters, +) -> rusqlite::Result> { + // 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 = 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> = fts_ids.iter().map(|id| Box::new(id.clone()) as Box).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. pub fn search_media(conn: &Connection, query: &str, language: &str) -> rusqlite::Result> { let stemmed = stem_text(query, language); @@ -238,7 +307,7 @@ mod tests { Some("Beautiful spider web"), &["fotografie".into(), "natur".into()], &["picture".into()], - &["Esmeralda English".into()], + &[("Esmeralda English".into(), "en".into())], "en", ).unwrap(); @@ -294,4 +363,20 @@ mod tests { assert!(search_posts(db.conn(), "alpha", "en").unwrap().is_empty()); 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"]); + } } diff --git a/crates/bds-core/src/db/queries/post_translation.rs b/crates/bds-core/src/db/queries/post_translation.rs index 94aaada..3ebebfe 100644 --- a/crates/bds-core/src/db/queries/post_translation.rs +++ b/crates/bds-core/src/db/queries/post_translation.rs @@ -7,6 +7,11 @@ pub fn insert_post_translation( conn: &Connection, t: &PostTranslation, ) -> 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( "INSERT INTO post_translations ( id, project_id, translation_for, language, title, excerpt, content, @@ -73,6 +78,11 @@ pub fn update_post_translation( conn: &Connection, t: &PostTranslation, ) -> 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( "UPDATE post_translations SET 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")); 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()); + } } diff --git a/crates/bds-core/src/engine/media.rs b/crates/bds-core/src/engine/media.rs index e19cc5c..32d6dfc 100644 --- a/crates/bds-core/src/engine/media.rs +++ b/crates/bds-core/src/engine/media.rs @@ -411,7 +411,7 @@ pub fn rebuild_media_from_filesystem( /// Index a media item in FTS, gathering translation texts. fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> { let translations = qmt::list_media_translations_by_media(conn, &media.id).unwrap_or_default(); - let translation_texts: Vec = translations + let translation_data: Vec<(String, String)> = translations .iter() .map(|t| { 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 { parts.push(caption.clone()); } - parts.join(" ") + (parts.join(" "), t.language.clone()) }) .collect(); @@ -437,7 +437,7 @@ fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> { media.caption.as_deref(), &media.original_name, &media.tags, - &translation_texts, + &translation_data, lang, )?; Ok(()) diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index a90d6e8..eb30cef 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -10,7 +10,7 @@ use crate::db::queries::post as qp; use crate::db::queries::post_link as ql; use crate::db::queries::post_translation as qt; use crate::engine::{EngineError, EngineResult}; -use crate::model::{Post, PostStatus, PostTranslation}; +use crate::model::{Post, PostLink, PostStatus, PostTranslation}; use crate::util::frontmatter::{ 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 { post.title = t.to_string(); } @@ -145,6 +156,11 @@ pub fn update_post( 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(); qp::update_post(conn, &post)?; @@ -173,6 +189,9 @@ pub fn publish_post( } // 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 abs_path = data_dir.join(&rel_path); @@ -245,9 +264,32 @@ pub fn publish_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 fts_index_post(conn, &post)?; + conn.execute_batch("RELEASE publish_post")?; + Ok(post) } @@ -308,6 +350,12 @@ pub fn delete_post( 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. pub fn upsert_translation( conn: &Connection, @@ -319,6 +367,11 @@ pub fn upsert_translation( content: Option<&str>, ) -> EngineResult { 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(); // Check if translation already exists @@ -476,6 +529,46 @@ pub fn rebuild_posts_from_filesystem( // --- 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. fn publish_translation( conn: &Connection, @@ -525,7 +618,7 @@ fn publish_translation( /// Index a post in FTS, gathering translation texts. fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> { let translations = qt::list_post_translations_by_post(conn, &post.id).unwrap_or_default(); - let translation_texts: Vec = translations + let translation_data: Vec<(String, String)> = translations .iter() .map(|t| { 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 { parts.push(cnt.clone()); } - parts.join(" ") + (parts.join(" "), t.language.clone()) }) .collect(); @@ -548,7 +641,7 @@ fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> { post.content.as_deref(), &post.tags, &post.categories, - &translation_texts, + &translation_data, lang, )?; Ok(()) @@ -1232,4 +1325,141 @@ mod tests { assert!(!is_translation_filename("hello.123")); 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"); + } } diff --git a/crates/bds-core/src/engine/project.rs b/crates/bds-core/src/engine/project.rs index 0bedeed..ff64b3b 100644 --- a/crates/bds-core/src/engine/project.rs +++ b/crates/bds-core/src/engine/project.rs @@ -39,7 +39,7 @@ pub fn create_project( // Determine data directory let data_dir = match data_path { Some(p) => std::path::PathBuf::from(p), - None => std::path::PathBuf::from(&slug), + None => std::path::PathBuf::from("projects").join(&id), }; // Create directory structure @@ -72,8 +72,27 @@ pub fn list_projects(conn: &Connection) -> EngineResult> { } /// 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)?; + + // Clean up filesystem if path provided + if let Some(dir) = data_dir { + if dir.exists() { + let _ = fs::remove_dir_all(dir); + } + } + Ok(()) } @@ -229,7 +248,18 @@ mod tests { 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(); - 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!(!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()); } } diff --git a/crates/bds-core/src/engine/tag.rs b/crates/bds-core/src/engine/tag.rs index 7d1814b..242c029 100644 --- a/crates/bds-core/src/engine/tag.rs +++ b/crates/bds-core/src/engine/tag.rs @@ -86,11 +86,10 @@ pub fn delete_tag( let tag = tag_q::get_tag_by_id(conn, tag_id) .map_err(|_| EngineError::NotFound(format!("tag {tag_id}")))?; - // Remove tag name from all posts - remove_tag_name_from_posts(conn, project_id, &tag.name)?; - + let modified = remove_tag_name_from_posts(conn, project_id, &tag.name)?; tag_q::delete_tag(conn, tag_id)?; rewrite_tags_json(conn, data_dir, project_id)?; + flush_post_frontmatter(conn, data_dir, &modified)?; Ok(()) } @@ -109,6 +108,7 @@ pub fn rename_tag( // Update all posts: replace old name with new name in tag arrays let posts = post_q::list_posts_by_project(conn, project_id)?; let now = now_unix_ms(); + let mut modified = Vec::new(); for mut post in posts { if post.tags.iter().any(|t| t.eq_ignore_ascii_case(&old_name)) { post.tags = post @@ -124,6 +124,7 @@ pub fn rename_tag( .collect(); post.updated_at = now; post_q::update_post(conn, &post)?; + modified.push(post.id.clone()); } } @@ -131,6 +132,7 @@ pub fn rename_tag( tag.updated_at = now; tag_q::update_tag(conn, &tag)?; rewrite_tags_json(conn, data_dir, project_id)?; + flush_post_frontmatter(conn, data_dir, &modified)?; Ok(()) } @@ -146,6 +148,7 @@ pub fn merge_tags( let target_tag = tag_q::get_tag_by_id(conn, target_id) .map_err(|_| EngineError::NotFound(format!("target tag {target_id}")))?; + let mut all_modified = Vec::new(); for &source_id in source_ids { let source_tag = tag_q::get_tag_by_id(conn, source_id) .map_err(|_| EngineError::NotFound(format!("source tag {source_id}")))?; @@ -171,6 +174,9 @@ pub fn merge_tags( } post.updated_at = now; 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)?; + flush_post_frontmatter(conn, data_dir, &all_modified)?; Ok(()) } @@ -240,22 +247,52 @@ pub fn rewrite_tags_json( // ── 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( conn: &Connection, project_id: &str, tag_name: &str, -) -> EngineResult<()> { +) -> EngineResult> { let posts = post_q::list_posts_by_project(conn, project_id)?; let now = now_unix_ms(); + let mut modified = Vec::new(); for mut post in posts { if post.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_name)) { post.tags .retain(|t| !t.eq_ignore_ascii_case(tag_name)); post.updated_at = now; post_q::update_post(conn, &post)?; + modified.push(post.id.clone()); } } - Ok(()) + Ok(modified) } #[cfg(test)] @@ -463,4 +500,26 @@ mod tests { assert_eq!(entries[0].name, "alpha"); 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"); + } } diff --git a/crates/bds-core/src/engine/task.rs b/crates/bds-core/src/engine/task.rs index 14d158b..21107db 100644 --- a/crates/bds-core/src/engine/task.rs +++ b/crates/bds-core/src/engine/task.rs @@ -30,6 +30,9 @@ struct TaskEntry { label: String, status: TaskStatus, cancel_flag: Arc, + progress: Option, + message: Option, + created_at: Instant, } /// Manages concurrent tasks with a max concurrency limit and FIFO queue. @@ -60,9 +63,20 @@ impl TaskManager { label: label.to_owned(), status: TaskStatus::Queued, 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 } @@ -87,25 +101,35 @@ impl TaskManager { pub fn complete(&self, task_id: TaskId) { let mut tasks = self.tasks.lock().unwrap(); 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. pub fn fail(&self, task_id: TaskId, error: String) { let mut tasks = self.tasks.lock().unwrap(); 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. pub fn cancel(&self, task_id: TaskId) { let mut tasks = self.tasks.lock().unwrap(); if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { - entry.cancel_flag.store(true, Ordering::Release); - entry.status = TaskStatus::Cancelled; + if matches!(entry.status, TaskStatus::Running | TaskStatus::Queued) { + 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. @@ -153,6 +177,39 @@ impl TaskManager { let tasks = self.tasks.lock().unwrap(); 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, message: Option) { + 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 { + 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 { + 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, 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 { @@ -201,9 +258,7 @@ mod tests { fn submit_and_start() { let mgr = TaskManager::default(); let id = mgr.submit("build site"); - assert_eq!(mgr.status(id), Some(TaskStatus::Queued)); - - assert!(mgr.try_start(id)); + // Auto-started since capacity allows assert_eq!(mgr.status(id), Some(TaskStatus::Running)); } @@ -212,26 +267,23 @@ mod tests { let mgr = TaskManager::new(3); let ids: Vec = (0..4).map(|i| mgr.submit(&format!("task {i}"))).collect(); - assert!(mgr.try_start(ids[0])); - assert!(mgr.try_start(ids[1])); - assert!(mgr.try_start(ids[2])); - assert!(!mgr.try_start(ids[3])); - + // First 3 auto-started, 4th stays queued assert_eq!(mgr.running_count(), 3); assert_eq!(mgr.status(ids[3]), Some(TaskStatus::Queued)); } #[test] fn fifo_order() { - let mgr = TaskManager::default(); - let a = mgr.submit("first"); - let b = mgr.submit("second"); - let c = mgr.submit("third"); + let mgr = TaskManager::new(1); // limit to 1 to test FIFO + let a = mgr.submit("first"); // auto-starts + let b = mgr.submit("second"); // queued + let c = mgr.submit("third"); // queued - assert_eq!(mgr.next_queued(), Some(a)); - mgr.try_start(a); + assert_eq!(mgr.status(a), Some(TaskStatus::Running)); 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)); } @@ -239,8 +291,7 @@ mod tests { fn cancel_sets_flag() { let mgr = TaskManager::default(); let id = mgr.submit("upload"); - mgr.try_start(id); - + // Task is auto-started (Running) assert!(!mgr.is_cancelled(id)); mgr.cancel(id); assert!(mgr.is_cancelled(id)); @@ -253,41 +304,70 @@ mod tests { let ok = mgr.submit("good task"); let bad = mgr.submit("bad task"); - mgr.try_start(ok); - mgr.try_start(bad); - + // Both auto-started (capacity=3) mgr.complete(ok); mgr.fail(bad, "disk full".into()); assert_eq!(mgr.status(ok), Some(TaskStatus::Completed)); 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] fn drain_removes_finished() { - let mgr = TaskManager::default(); - let a = mgr.submit("done"); - let b = mgr.submit("broken"); - let c = mgr.submit("stopped"); - let d = mgr.submit("waiting"); - let e = mgr.submit("busy"); + let mgr = TaskManager::new(3); + let a = mgr.submit("done"); // auto-starts + let b = mgr.submit("broken"); // auto-starts + let e = mgr.submit("busy"); // auto-starts + let _c = mgr.submit("stopped"); // queued (at capacity) + let _d = mgr.submit("waiting"); // queued - mgr.try_start(a); - mgr.try_start(b); - mgr.try_start(e); mgr.complete(a); 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(); assert_eq!(mgr.status(a), None); assert_eq!(mgr.status(b), None); - assert_eq!(mgr.status(c), None); - assert_eq!(mgr.status(d), Some(TaskStatus::Queued)); + // c and d were promoted, e is still 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] fn progress_throttle_initial_reports() { let throttle = ProgressThrottle::new(PROGRESS_THROTTLE_MS); diff --git a/crates/bds-core/src/model/metadata.rs b/crates/bds-core/src/model/metadata.rs index e228204..82f1231 100644 --- a/crates/bds-core/src/model/metadata.rs +++ b/crates/bds-core/src/model/metadata.rs @@ -34,6 +34,19 @@ pub struct ProjectMetadata { pub blog_languages: Vec, } +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)] #[serde(rename_all = "camelCase")] pub struct CategorySettings { @@ -143,4 +156,28 @@ mod tests { assert!(tag.color.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()); + } } diff --git a/crates/bds-core/src/model/post.rs b/crates/bds-core/src/model/post.rs index 580546b..699f4be 100644 --- a/crates/bds-core/src/model/post.rs +++ b/crates/bds-core/src/model/post.rs @@ -8,6 +8,13 @@ pub enum PostStatus { 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. /// /// NOTE: `content` is null for published posts — body lives in the filesystem diff --git a/crates/bds-core/src/util/thumbnail.rs b/crates/bds-core/src/util/thumbnail.rs index 0df00ee..e0c1ff7 100644 --- a/crates/bds-core/src/util/thumbnail.rs +++ b/crates/bds-core/src/util/thumbnail.rs @@ -31,9 +31,9 @@ pub enum ThumbnailFit { /// Standard thumbnail sizes matching TypeScript implementation. pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[ - ThumbnailSize { name: "small", width: 150, height: 150, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside }, - ThumbnailSize { name: "medium", width: 400, height: 400, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside }, - ThumbnailSize { name: "large", width: 800, height: 800, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside }, + ThumbnailSize { name: "small", width: 150, height: 150, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Cover }, + 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::Cover }, 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 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 { let ext = match size.format { ThumbnailFormat::Webp => "webp", @@ -132,13 +143,99 @@ fn load_and_orient(path: &Path) -> Result { .with_guessed_format() .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) } +/// 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 { + 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 { + 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 { + 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 { + 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. pub fn image_dimensions(path: &Path) -> Result<(u32, u32), String> { let img = ImageReader::open(path) @@ -188,13 +285,13 @@ mod tests { let dir = TempDir::new().unwrap(); let source = create_test_png(dir.path()); let dest = dir.path().join("thumb.webp"); - let size = &THUMBNAIL_SIZES[0]; // small: 150x150 + let size = &THUMBNAIL_SIZES[0]; // small: 150x150 Cover generate_thumbnail(&source, &dest, size, 80).unwrap(); assert!(dest.exists()); let (w, h) = image_dimensions(&dest).unwrap(); - // 100x80 is already smaller than 150x150, so no resize (Inside fit) - assert_eq!(w, 100); - assert_eq!(h, 80); + // 100x80 resized to fill 150x150 (Cover fit) + assert_eq!(w, 150); + assert_eq!(h, 150); } #[test] @@ -216,7 +313,7 @@ mod tests { let source = create_test_png(dir.path()); let thumb_dir = dir.path().join("thumbnails"); let paths = generate_all_thumbnails(&source, &thumb_dir, "ab123456-test-uuid").unwrap(); - assert_eq!(paths.len(), 4); + assert_eq!(paths.len(), 5); // 1 source + 4 thumbnails for p in &paths { assert!(Path::new(p).exists(), "thumbnail missing: {p}"); }