use std::collections::HashMap; use std::fs; use std::path::Path; use crate::db::DbConnection as Connection; use crate::engine::EngineResult; use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry}; use crate::model::{DomainEntity, NotificationAction, Project, PublishingPreferences}; use crate::util::atomic_write_str; // ── project.json ──────────────────────────────────────────────────── /// Read and parse meta/project.json. pub fn read_project_json(data_dir: &Path) -> EngineResult { let path = data_dir.join("meta").join("project.json"); let content = fs::read_to_string(&path)?; let meta: ProjectMetadata = serde_json::from_str(&content)?; Ok(meta) } /// Serialize with pretty JSON, atomic write to meta/project.json. pub fn write_project_json(data_dir: &Path, meta: &ProjectMetadata) -> EngineResult<()> { let path = data_dir.join("meta").join("project.json"); let json = serde_json::to_string_pretty(meta)?; atomic_write_str(&path, &json)?; Ok(()) } /// Persist the shared project row and its filesystem metadata, then publish /// the normal project-updated domain event. pub fn update_project_metadata( conn: &Connection, data_dir: &Path, project: &Project, metadata: &ProjectMetadata, ) -> EngineResult { if project.id.is_empty() { return Err(crate::engine::EngineError::Validation( "project id is required".to_string(), )); } metadata .validate() .map_err(crate::engine::EngineError::Validation)?; let mut persisted_project = project.clone(); persisted_project.name = metadata.name.clone(); persisted_project.description = metadata.description.clone(); persisted_project.updated_at = crate::util::now_unix_ms(); let category_metadata = read_category_meta_json(data_dir)?; write_project_json(data_dir, metadata)?; write_category_meta_json(data_dir, &category_metadata)?; crate::db::queries::project::update_project(conn, &persisted_project)?; crate::engine::domain_events::entity_changed( &project.id, DomainEntity::Project, &project.id, NotificationAction::Updated, ); Ok(metadata.clone()) } /// Copy the project fields persisted in both representations from project.json /// into the machine-local project row. pub fn sync_project_from_file( conn: &Connection, data_dir: &Path, project_id: &str, ) -> EngineResult<()> { let metadata = read_project_json(data_dir)?; metadata .validate() .map_err(crate::engine::EngineError::Validation)?; let mut project = crate::db::queries::project::get_project_by_id(conn, project_id)?; project.name = metadata.name; project.description = metadata.description; project.updated_at = crate::util::now_unix_ms(); crate::db::queries::project::update_project(conn, &project)?; Ok(()) } // ── categories.json ───────────────────────────────────────────────── /// Read meta/categories.json as a sorted array of strings. pub fn read_categories_json(data_dir: &Path) -> EngineResult> { let path = data_dir.join("meta").join("categories.json"); let content = fs::read_to_string(&path)?; let cats: Vec = serde_json::from_str(&content)?; Ok(cats) } /// Sort categories, then atomic write to meta/categories.json. pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineResult<()> { let mut sorted = categories.to_vec(); sorted.sort_by_key(|a| a.to_lowercase()); let path = data_dir.join("meta").join("categories.json"); let json = serde_json::to_string_pretty(&sorted)?; atomic_write_str(&path, &json)?; Ok(()) } // ── category-meta.json ────────────────────────────────────────────── /// Read meta/category-meta.json. pub fn read_category_meta_json(data_dir: &Path) -> EngineResult> { let path = data_dir.join("meta").join("category-meta.json"); let content = fs::read_to_string(&path)?; let meta: HashMap = serde_json::from_str(&content)?; Ok(meta) } /// Atomic write to meta/category-meta.json. pub fn write_category_meta_json( data_dir: &Path, meta: &HashMap, ) -> EngineResult<()> { let path = data_dir.join("meta").join("category-meta.json"); let json = serde_json::to_string_pretty(meta)?; atomic_write_str(&path, &json)?; Ok(()) } // ── publishing.json ───────────────────────────────────────────────── /// Read meta/publishing.json. pub fn read_publishing_json(data_dir: &Path) -> EngineResult { let path = data_dir.join("meta").join("publishing.json"); let content = fs::read_to_string(&path)?; let prefs: PublishingPreferences = serde_json::from_str(&content)?; Ok(prefs) } /// Atomic write to meta/publishing.json. pub fn write_publishing_json(data_dir: &Path, prefs: &PublishingPreferences) -> EngineResult<()> { let path = data_dir.join("meta").join("publishing.json"); let json = serde_json::to_string_pretty(prefs)?; atomic_write_str(&path, &json)?; Ok(()) } // ── tags.json ─────────────────────────────────────────────────────── /// Read meta/tags.json. pub fn read_tags_json(data_dir: &Path) -> EngineResult> { let path = data_dir.join("meta").join("tags.json"); let content = fs::read_to_string(&path)?; let tags: Vec = serde_json::from_str(&content)?; Ok(tags) } /// Sort by name case-insensitive, then atomic write to meta/tags.json. pub fn write_tags_json(data_dir: &Path, tags: &[TagEntry]) -> EngineResult<()> { let mut sorted = tags.to_vec(); sorted.sort_by_key(|a| a.name.to_lowercase()); let path = data_dir.join("meta").join("tags.json"); let json = serde_json::to_string_pretty(&sorted)?; atomic_write_str(&path, &json)?; Ok(()) } // ── category helpers ──────────────────────────────────────────────── /// Add a category to categories.json and initialize it in category-meta.json. pub fn add_category(data_dir: &Path, category: &str) -> EngineResult<()> { let mut cats = read_categories_json(data_dir)?; if !cats.iter().any(|c| c.eq_ignore_ascii_case(category)) { cats.push(category.to_string()); write_categories_json(data_dir, &cats)?; } let mut meta = read_category_meta_json(data_dir)?; if !meta.contains_key(category) { meta.insert( category.to_string(), CategorySettings { title: None, render_in_lists: true, show_title: true, post_template_slug: None, list_template_slug: None, }, ); write_category_meta_json(data_dir, &meta)?; } Ok(()) } /// Remove a category from both categories.json and category-meta.json. pub fn remove_category(data_dir: &Path, category: &str) -> EngineResult<()> { let mut cats = read_categories_json(data_dir)?; cats.retain(|c| !c.eq_ignore_ascii_case(category)); write_categories_json(data_dir, &cats)?; let mut meta = read_category_meta_json(data_dir)?; meta.remove(category); write_category_meta_json(data_dir, &meta)?; Ok(()) } // ── startup sync ──────────────────────────────────────────────────── /// Per metadata.allium StartupSync: loads metadata from filesystem, creating /// default files if missing. Called on project activation. pub fn startup_sync(data_dir: &Path) -> EngineResult<()> { let meta_dir = data_dir.join("meta"); fs::create_dir_all(&meta_dir)?; // Ensure project.json exists if !meta_dir.join("project.json").exists() { let default_meta = ProjectMetadata { name: "My Blog".to_string(), description: None, public_url: None, main_language: None, default_author: None, max_posts_per_page: 50, image_import_concurrency: 4, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, blog_languages: Vec::new(), }; write_project_json(data_dir, &default_meta)?; } // Ensure categories.json exists with defaults if !meta_dir.join("categories.json").exists() { let defaults = vec![ "article".to_string(), "aside".to_string(), "page".to_string(), "picture".to_string(), ]; write_categories_json(data_dir, &defaults)?; } // Ensure category-meta.json exists if !meta_dir.join("category-meta.json").exists() { let empty: HashMap = HashMap::new(); write_category_meta_json(data_dir, &empty)?; } // Ensure publishing.json exists if !meta_dir.join("publishing.json").exists() { atomic_write_str(&meta_dir.join("publishing.json"), "{}")?; } // Ensure tags.json exists if !meta_dir.join("tags.json").exists() { write_tags_json(data_dir, &[])?; } // Ensure menu.opml exists if !meta_dir.join("menu.opml").exists() { let opml = crate::engine::menu::default_menu_opml(); atomic_write_str(&meta_dir.join("menu.opml"), &opml)?; } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::model::SshMode; use tempfile::TempDir; fn setup() -> TempDir { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join("meta")).unwrap(); dir } // ── project.json ──────────────────────────────────────────────── #[test] fn project_json_roundtrip() { let dir = setup(); let meta = ProjectMetadata { name: "Test".into(), description: Some("A blog".into()), public_url: None, main_language: Some("en".into()), default_author: None, max_posts_per_page: 25, image_import_concurrency: 4, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, blog_languages: vec!["en".into()], }; write_project_json(dir.path(), &meta).unwrap(); let read = read_project_json(dir.path()).unwrap(); assert_eq!(read.name, "Test"); assert_eq!(read.max_posts_per_page, 25); assert_eq!(read.description.as_deref(), Some("A blog")); } // ── categories.json ───────────────────────────────────────────── #[test] fn categories_json_sorted() { let dir = setup(); let cats = vec!["picture".into(), "article".into(), "aside".into()]; write_categories_json(dir.path(), &cats).unwrap(); let read = read_categories_json(dir.path()).unwrap(); assert_eq!(read, vec!["article", "aside", "picture"]); } // ── category-meta.json ────────────────────────────────────────── #[test] fn category_meta_json_roundtrip() { let dir = setup(); let mut meta = HashMap::new(); meta.insert( "article".to_string(), CategorySettings { title: None, render_in_lists: true, show_title: true, post_template_slug: None, list_template_slug: None, }, ); write_category_meta_json(dir.path(), &meta).unwrap(); let read = read_category_meta_json(dir.path()).unwrap(); assert!(read.contains_key("article")); assert!(read["article"].render_in_lists); } #[test] fn category_meta_json_preserves_bds2_title() { let dir = setup(); let mut meta = HashMap::new(); meta.insert( "news".to_string(), CategorySettings { title: Some("News Archive".to_string()), render_in_lists: false, show_title: true, post_template_slug: Some("article".to_string()), list_template_slug: Some("listing".to_string()), }, ); write_category_meta_json(dir.path(), &meta).unwrap(); let content = std::fs::read_to_string(dir.path().join("meta/category-meta.json")).unwrap(); let json: serde_json::Value = serde_json::from_str(&content).unwrap(); assert_eq!(json["news"]["title"], "News Archive"); assert_eq!(json["news"]["renderInLists"], false); assert_eq!(json["news"]["showTitle"], true); let read = read_category_meta_json(dir.path()).unwrap(); assert_eq!(read["news"].title.as_deref(), Some("News Archive")); } // ── publishing.json ───────────────────────────────────────────── #[test] fn publishing_json_roundtrip() { let dir = setup(); let prefs = PublishingPreferences { ssh_host: Some("example.com".into()), ssh_user: Some("deploy".into()), ssh_remote_path: Some("/var/www".into()), ssh_mode: SshMode::Rsync, }; write_publishing_json(dir.path(), &prefs).unwrap(); let read = read_publishing_json(dir.path()).unwrap(); assert_eq!(read.ssh_host.as_deref(), Some("example.com")); assert_eq!(read.ssh_mode, SshMode::Rsync); } // ── tags.json ─────────────────────────────────────────────────── #[test] fn tags_json_sorted_case_insensitive() { let dir = setup(); let tags = vec![ TagEntry { name: "Zebra".into(), color: None, post_template_slug: None, }, TagEntry { name: "alpha".into(), color: Some("#00ff00".into()), post_template_slug: None, }, ]; write_tags_json(dir.path(), &tags).unwrap(); let read = read_tags_json(dir.path()).unwrap(); assert_eq!(read[0].name, "alpha"); assert_eq!(read[1].name, "Zebra"); } // ── add / remove category ─────────────────────────────────────── #[test] fn add_category_creates_entries() { let dir = setup(); // Seed files write_categories_json(dir.path(), &["article".into()]).unwrap(); write_category_meta_json(dir.path(), &HashMap::new()).unwrap(); add_category(dir.path(), "page").unwrap(); let cats = read_categories_json(dir.path()).unwrap(); assert!(cats.contains(&"page".to_string())); let meta = read_category_meta_json(dir.path()).unwrap(); assert!(meta.contains_key("page")); } #[test] fn add_category_idempotent() { let dir = setup(); write_categories_json(dir.path(), &["article".into()]).unwrap(); write_category_meta_json(dir.path(), &HashMap::new()).unwrap(); add_category(dir.path(), "article").unwrap(); let cats = read_categories_json(dir.path()).unwrap(); assert_eq!(cats.iter().filter(|c| *c == "article").count(), 1); } #[test] fn remove_category_deletes_entries() { let dir = setup(); write_categories_json(dir.path(), &["article".into(), "page".into()]).unwrap(); let mut meta = HashMap::new(); meta.insert( "article".to_string(), CategorySettings { title: None, render_in_lists: true, show_title: true, post_template_slug: None, list_template_slug: None, }, ); write_category_meta_json(dir.path(), &meta).unwrap(); remove_category(dir.path(), "article").unwrap(); let cats = read_categories_json(dir.path()).unwrap(); assert!(!cats.contains(&"article".to_string())); assert!(cats.contains(&"page".to_string())); let meta = read_category_meta_json(dir.path()).unwrap(); assert!(!meta.contains_key("article")); } }