diff --git a/README.md b/README.md index 3de7ce6..05cf618 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The project is under active development. Core blogging workflows are broadly ava - Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors. - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. - Post, Liquid template, and Lua script editing with dedicated syntax highlighting, explicit syntax-check feedback, change-aware published/draft lifecycle, normalized collision-safe template/script slug changes, reference-safe template renames, and publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync. -- SQLite and filesystem persistence with frontmatter, relationship-safe media sidecars, rebuild, metadata diff/repair, stale post-path cleanup on republish, bDS2-compatible checksums and NFD slug generation, and FTS5 search. +- SQLite and filesystem persistence with frontmatter, relationship-safe media sidecars, rebuild, bidirectional metadata diff/repair including project categories and publishing preferences, stale post-path cleanup on republish, bDS2-compatible checksums and NFD slug generation, and FTS5 search. - Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace. - Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links. - A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence. diff --git a/crates/bds-core/src/db/queries/setting.rs b/crates/bds-core/src/db/queries/setting.rs index b6c08a5..6f77326 100644 --- a/crates/bds-core/src/db/queries/setting.rs +++ b/crates/bds-core/src/db/queries/setting.rs @@ -46,6 +46,12 @@ pub fn list_all_settings(conn: &DbConnection) -> QueryResult> { }) } +pub fn delete_settings_by_prefix(conn: &DbConnection, prefix: &str) -> QueryResult { + conn.with(|c| { + diesel::delete(settings::table.filter(settings::key.like(format!("{prefix}%")))).execute(c) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -93,4 +99,18 @@ mod tests { let db = setup(); assert!(get_setting_by_key(db.conn(), "nope").is_err()); } + + #[test] + fn delete_by_prefix_is_project_scoped() { + let db = setup(); + set_setting_value(db.conn(), "project:p1:categories", "[]", 1).unwrap(); + set_setting_value(db.conn(), "project:p2:categories", "[]", 1).unwrap(); + + assert_eq!( + delete_settings_by_prefix(db.conn(), "project:p1:").unwrap(), + 1 + ); + assert!(get_setting_by_key(db.conn(), "project:p1:categories").is_err()); + assert!(get_setting_by_key(db.conn(), "project:p2:categories").is_ok()); + } } diff --git a/crates/bds-core/src/engine/meta.rs b/crates/bds-core/src/engine/meta.rs index ad722e6..09d5941 100644 --- a/crates/bds-core/src/engine/meta.rs +++ b/crates/bds-core/src/engine/meta.rs @@ -2,12 +2,98 @@ use std::collections::HashMap; use std::fs; use std::path::Path; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + use crate::db::DbConnection as Connection; -use crate::engine::EngineResult; +use crate::engine::{EngineError, EngineResult}; use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry}; use crate::model::{DomainEntity, NotificationAction, Project, PublishingPreferences}; use crate::util::atomic_write_str; +const PROJECT_SETTING_SUFFIX: &str = "project"; +const CATEGORIES_SETTING_SUFFIX: &str = "categories"; +const CATEGORY_META_SETTING_SUFFIX: &str = "category_meta"; +const PUBLISHING_SETTING_SUFFIX: &str = "publishing"; + +#[derive(Debug, Serialize, Deserialize)] +struct CategoriesSnapshot { + categories: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CategoryMetaSnapshot { + categories: HashMap, +} + +fn metadata_setting_key(project_id: &str, suffix: &str) -> String { + format!("project:{project_id}:{suffix}") +} + +fn persist_snapshot( + conn: &Connection, + project_id: &str, + suffix: &str, + value: &T, +) -> EngineResult<()> { + crate::db::queries::setting::set_setting_value( + conn, + &metadata_setting_key(project_id, suffix), + &serde_json::to_string(value)?, + crate::util::now_unix_ms(), + )?; + Ok(()) +} + +fn load_snapshot( + conn: &Connection, + project_id: &str, + suffix: &str, +) -> EngineResult> { + match crate::db::queries::setting::get_setting_by_key( + conn, + &metadata_setting_key(project_id, suffix), + ) { + Ok(setting) => Ok(Some(serde_json::from_str(&setting.value)?)), + Err(diesel::result::Error::NotFound) => Ok(None), + Err(error) => Err(EngineError::Db(error)), + } +} + +pub fn read_project_metadata_snapshot( + conn: &Connection, + project_id: &str, +) -> EngineResult> { + load_snapshot(conn, project_id, PROJECT_SETTING_SUFFIX) +} + +pub fn read_categories_snapshot( + conn: &Connection, + project_id: &str, +) -> EngineResult>> { + Ok( + load_snapshot::(conn, project_id, CATEGORIES_SETTING_SUFFIX)? + .map(|snapshot| snapshot.categories), + ) +} + +pub fn read_category_meta_snapshot( + conn: &Connection, + project_id: &str, +) -> EngineResult>> { + Ok( + load_snapshot::(conn, project_id, CATEGORY_META_SETTING_SUFFIX)? + .map(|snapshot| snapshot.categories), + ) +} + +pub fn read_publishing_snapshot( + conn: &Connection, + project_id: &str, +) -> EngineResult> { + load_snapshot(conn, project_id, PUBLISHING_SETTING_SUFFIX) +} + // ── project.json ──────────────────────────────────────────────────── /// Read and parse meta/project.json. @@ -49,6 +135,7 @@ pub fn update_project_metadata( persisted_project.updated_at = crate::util::now_unix_ms(); let category_metadata = read_category_meta_json(data_dir)?; + persist_snapshot(conn, &project.id, PROJECT_SETTING_SUFFIX, metadata)?; write_project_json(data_dir, metadata)?; write_category_meta_json(data_dir, &category_metadata)?; crate::db::queries::project::update_project(conn, &persisted_project)?; @@ -61,25 +148,119 @@ pub fn update_project_metadata( 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( +/// Replace all database metadata snapshots with the portable filesystem state. +pub fn sync_metadata_from_filesystem( 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)?; + let project = read_project_json(data_dir)?; + project.validate().map_err(EngineError::Validation)?; + let categories = read_categories_json(data_dir)?; + let category_meta = read_category_meta_json(data_dir)?; + let publishing = read_publishing_json(data_dir)?; + + conn.begin_savepoint()?; + let result = (|| { + let mut project_row = crate::db::queries::project::get_project_by_id(conn, project_id)?; + project_row.name = project.name.clone(); + project_row.description = project.description.clone(); + project_row.updated_at = crate::util::now_unix_ms(); + crate::db::queries::project::update_project(conn, &project_row)?; + persist_snapshot(conn, project_id, PROJECT_SETTING_SUFFIX, &project)?; + persist_snapshot( + conn, + project_id, + CATEGORIES_SETTING_SUFFIX, + &CategoriesSnapshot { categories }, + )?; + persist_snapshot( + conn, + project_id, + CATEGORY_META_SETTING_SUFFIX, + &CategoryMetaSnapshot { + categories: category_meta, + }, + )?; + persist_snapshot(conn, project_id, PUBLISHING_SETTING_SUFFIX, &publishing) + })(); + match result { + Ok(()) => conn.release_savepoint().map_err(Into::into), + Err(error) => { + let _ = conn.rollback_savepoint(); + Err(error) + } + } +} + +/// Seed database snapshots for pre-snapshot projects without overwriting +/// deliberate database/file divergence on later activations. +pub fn initialize_metadata_snapshots( + conn: &Connection, + data_dir: &Path, + project_id: &str, +) -> EngineResult<()> { + if read_project_metadata_snapshot(conn, project_id)?.is_none() { + let value = read_project_json(data_dir)?; + persist_snapshot(conn, project_id, PROJECT_SETTING_SUFFIX, &value)?; + } + if read_categories_snapshot(conn, project_id)?.is_none() { + let categories = read_categories_json(data_dir)?; + persist_snapshot( + conn, + project_id, + CATEGORIES_SETTING_SUFFIX, + &CategoriesSnapshot { categories }, + )?; + } + if read_category_meta_snapshot(conn, project_id)?.is_none() { + let categories = read_category_meta_json(data_dir)?; + persist_snapshot( + conn, + project_id, + CATEGORY_META_SETTING_SUFFIX, + &CategoryMetaSnapshot { categories }, + )?; + } + if read_publishing_snapshot(conn, project_id)?.is_none() { + let value = read_publishing_json(data_dir)?; + persist_snapshot(conn, project_id, PUBLISHING_SETTING_SUFFIX, &value)?; + } Ok(()) } +pub fn flush_metadata_to_filesystem( + conn: &Connection, + data_dir: &Path, + project_id: &str, +) -> EngineResult<()> { + let project = read_project_metadata_snapshot(conn, project_id)?.ok_or_else(|| { + EngineError::NotFound(format!( + "project metadata snapshot for project {project_id}" + )) + })?; + let categories = read_categories_snapshot(conn, project_id)?.ok_or_else(|| { + EngineError::NotFound(format!( + "categories metadata snapshot for project {project_id}" + )) + })?; + let category_meta = read_category_meta_snapshot(conn, project_id)?.ok_or_else(|| { + EngineError::NotFound(format!( + "category metadata snapshot for project {project_id}" + )) + })?; + let publishing = read_publishing_snapshot(conn, project_id)?.ok_or_else(|| { + EngineError::NotFound(format!( + "publishing metadata snapshot for project {project_id}" + )) + })?; + + write_project_json(data_dir, &project)?; + write_categories_json(data_dir, &categories)?; + write_category_meta_json(data_dir, &category_meta)?; + write_publishing_json(data_dir, &publishing) +} + // ── categories.json ───────────────────────────────────────────────── /// Read meta/categories.json as a sorted array of strings. @@ -100,6 +281,25 @@ pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineRe Ok(()) } +pub fn set_categories( + conn: &Connection, + data_dir: &Path, + project_id: &str, + categories: &[String], +) -> EngineResult<()> { + let mut sorted = categories.to_vec(); + sorted.sort_by_key(|category| category.to_lowercase()); + persist_snapshot( + conn, + project_id, + CATEGORIES_SETTING_SUFFIX, + &CategoriesSnapshot { + categories: sorted.clone(), + }, + )?; + write_categories_json(data_dir, &sorted) +} + // ── category-meta.json ────────────────────────────────────────────── /// Read meta/category-meta.json. @@ -121,6 +321,62 @@ pub fn write_category_meta_json( Ok(()) } +pub fn set_category_meta( + conn: &Connection, + data_dir: &Path, + project_id: &str, + meta: &HashMap, +) -> EngineResult<()> { + persist_snapshot( + conn, + project_id, + CATEGORY_META_SETTING_SUFFIX, + &CategoryMetaSnapshot { + categories: meta.clone(), + }, + )?; + write_category_meta_json(data_dir, meta) +} + +pub fn set_categories_and_meta( + conn: &Connection, + data_dir: &Path, + project_id: &str, + categories: &[String], + meta: &HashMap, +) -> EngineResult<()> { + let mut sorted = categories.to_vec(); + sorted.sort_by_key(|category| category.to_lowercase()); + conn.begin_savepoint()?; + let result = (|| { + persist_snapshot( + conn, + project_id, + CATEGORIES_SETTING_SUFFIX, + &CategoriesSnapshot { + categories: sorted.clone(), + }, + )?; + persist_snapshot( + conn, + project_id, + CATEGORY_META_SETTING_SUFFIX, + &CategoryMetaSnapshot { + categories: meta.clone(), + }, + ) + })(); + match result { + Ok(()) => conn.release_savepoint()?, + Err(error) => { + let _ = conn.rollback_savepoint(); + return Err(error); + } + } + write_categories_json(data_dir, &sorted)?; + write_category_meta_json(data_dir, meta) +} + // ── publishing.json ───────────────────────────────────────────────── /// Read meta/publishing.json. @@ -139,6 +395,16 @@ pub fn write_publishing_json(data_dir: &Path, prefs: &PublishingPreferences) -> Ok(()) } +pub fn set_publishing_preferences( + conn: &Connection, + data_dir: &Path, + project_id: &str, + prefs: &PublishingPreferences, +) -> EngineResult<()> { + persist_snapshot(conn, project_id, PUBLISHING_SETTING_SUFFIX, prefs)?; + write_publishing_json(data_dir, prefs) +} + // ── tags.json ─────────────────────────────────────────────────────── /// Read meta/tags.json. @@ -162,11 +428,15 @@ pub fn write_tags_json(data_dir: &Path, tags: &[TagEntry]) -> EngineResult<()> { // ── 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<()> { +pub fn add_category( + conn: &Connection, + data_dir: &Path, + project_id: &str, + 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)?; @@ -181,23 +451,22 @@ pub fn add_category(data_dir: &Path, category: &str) -> EngineResult<()> { list_template_slug: None, }, ); - write_category_meta_json(data_dir, &meta)?; } - - Ok(()) + set_categories_and_meta(conn, data_dir, project_id, &cats, &meta) } /// Remove a category from both categories.json and category-meta.json. -pub fn remove_category(data_dir: &Path, category: &str) -> EngineResult<()> { +pub fn remove_category( + conn: &Connection, + data_dir: &Path, + project_id: &str, + 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(()) + set_categories_and_meta(conn, data_dir, project_id, &cats, &meta) } // ── startup sync ──────────────────────────────────────────────────── @@ -404,11 +673,13 @@ mod tests { #[test] fn add_category_creates_entries() { let dir = setup(); + let db = crate::db::Database::open_in_memory().unwrap(); + db.migrate().unwrap(); // 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(); + add_category(db.conn(), dir.path(), "p1", "page").unwrap(); let cats = read_categories_json(dir.path()).unwrap(); assert!(cats.contains(&"page".to_string())); @@ -420,10 +691,12 @@ mod tests { #[test] fn add_category_idempotent() { let dir = setup(); + let db = crate::db::Database::open_in_memory().unwrap(); + db.migrate().unwrap(); write_categories_json(dir.path(), &["article".into()]).unwrap(); write_category_meta_json(dir.path(), &HashMap::new()).unwrap(); - add_category(dir.path(), "article").unwrap(); + add_category(db.conn(), dir.path(), "p1", "article").unwrap(); let cats = read_categories_json(dir.path()).unwrap(); assert_eq!(cats.iter().filter(|c| *c == "article").count(), 1); } @@ -431,6 +704,8 @@ mod tests { #[test] fn remove_category_deletes_entries() { let dir = setup(); + let db = crate::db::Database::open_in_memory().unwrap(); + db.migrate().unwrap(); write_categories_json(dir.path(), &["article".into(), "page".into()]).unwrap(); let mut meta = HashMap::new(); meta.insert( @@ -445,7 +720,7 @@ mod tests { ); write_category_meta_json(dir.path(), &meta).unwrap(); - remove_category(dir.path(), "article").unwrap(); + remove_category(db.conn(), dir.path(), "p1", "article").unwrap(); let cats = read_categories_json(dir.path()).unwrap(); assert!(!cats.contains(&"article".to_string())); @@ -454,4 +729,26 @@ mod tests { let meta = read_category_meta_json(dir.path()).unwrap(); assert!(!meta.contains_key("article")); } + + #[test] + fn snapshot_initialization_does_not_hide_later_filesystem_drift() { + let dir = setup(); + let db = crate::db::Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + crate::db::queries::project::insert_project( + db.conn(), + &crate::db::queries::project::make_test_project("p1", "blog"), + ) + .unwrap(); + startup_sync(dir.path()).unwrap(); + initialize_metadata_snapshots(db.conn(), dir.path(), "p1").unwrap(); + + write_categories_json(dir.path(), &["filesystem-only".into()]).unwrap(); + initialize_metadata_snapshots(db.conn(), dir.path(), "p1").unwrap(); + + assert_eq!( + read_categories_snapshot(db.conn(), "p1").unwrap().unwrap(), + vec!["article", "aside", "page", "picture"] + ); + } } diff --git a/crates/bds-core/src/engine/metadata_diff.rs b/crates/bds-core/src/engine/metadata_diff.rs index 206687e..5976e18 100644 --- a/crates/bds-core/src/engine/metadata_diff.rs +++ b/crates/bds-core/src/engine/metadata_diff.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use std::fs; use std::path::Path; @@ -73,11 +73,24 @@ pub fn compute_metadata_diff( let mut report = DiffReport::default(); if let Ok(project) = qproject::get_project_by_id(conn, project_id) { - match diff_project(data_dir, &project) { + match diff_project(conn, data_dir, &project) { Ok(Some(diff)) => report.diffs.push(diff), Ok(None) => {} Err(error) => report.errors.push(format!("project {project_id}: {error}")), } + for result in [ + diff_categories(conn, data_dir, project_id), + diff_category_meta(conn, data_dir, project_id), + diff_publishing(conn, data_dir, project_id), + ] { + match result { + Ok(Some(diff)) => report.diffs.push(diff), + Ok(None) => {} + Err(error) => report + .errors + .push(format!("project metadata {project_id}: {error}")), + } + } } // 1. Diff posts @@ -220,7 +233,9 @@ pub fn repair_metadata_diff_item( RepairDirection::FileToDatabase => { let path = data_dir.join(&item.file_path); match item.entity_type.as_str() { - "project" => sync_project_from_file(conn, data_dir, project_id)?, + "project" | "categories" | "category_meta" | "publishing" => { + crate::engine::meta::sync_metadata_from_filesystem(conn, data_dir, project_id)? + } "post" => { crate::engine::post::rebuild_canonical_post(conn, data_dir, project_id, &path)?; } @@ -256,7 +271,9 @@ pub fn repair_metadata_diff_item( } } RepairDirection::DatabaseToFile => match item.entity_type.as_str() { - "project" => rewrite_project_from_database(conn, data_dir, project_id)?, + "project" | "categories" | "category_meta" | "publishing" => { + crate::engine::meta::flush_metadata_to_filesystem(conn, data_dir, project_id)? + } "post" => rewrite_post_from_database(conn, data_dir, &item.entity_id)?, "post_translation" => { rewrite_post_translation_from_database(conn, data_dir, &item.entity_id)? @@ -335,18 +352,93 @@ pub fn import_orphan_file( } fn diff_project( + conn: &Connection, data_dir: &Path, project: &crate::model::Project, ) -> EngineResult> { let metadata = crate::engine::meta::read_project_json(data_dir)?; let mut fields = Vec::new(); - compare_field(&mut fields, "name", &project.name, &metadata.name); + let snapshot = crate::engine::meta::read_project_metadata_snapshot(conn, &project.id)?; + let database = snapshot.as_ref(); + compare_field( + &mut fields, + "name", + database.map_or(project.name.as_str(), |value| value.name.as_str()), + &metadata.name, + ); compare_field( &mut fields, "description", - project.description.as_deref().unwrap_or(""), + database.map_or_else( + || project.description.as_deref().unwrap_or(""), + |value| value.description.as_deref().unwrap_or(""), + ), metadata.description.as_deref().unwrap_or(""), ); + if let Some(database) = database { + compare_optional_field( + &mut fields, + "public_url", + &database.public_url, + &metadata.public_url, + ); + compare_optional_field( + &mut fields, + "main_language", + &database.main_language, + &metadata.main_language, + ); + compare_optional_field( + &mut fields, + "default_author", + &database.default_author, + &metadata.default_author, + ); + compare_field( + &mut fields, + "max_posts_per_page", + &database.max_posts_per_page.to_string(), + &metadata.max_posts_per_page.to_string(), + ); + compare_field( + &mut fields, + "image_import_concurrency", + &database.image_import_concurrency.to_string(), + &metadata.image_import_concurrency.to_string(), + ); + compare_optional_field( + &mut fields, + "blogmark_category", + &database.blogmark_category, + &metadata.blogmark_category, + ); + compare_optional_field( + &mut fields, + "pico_theme", + &database.pico_theme, + &metadata.pico_theme, + ); + compare_field( + &mut fields, + "semantic_similarity_enabled", + if database.semantic_similarity_enabled { + "true" + } else { + "false" + }, + if metadata.semantic_similarity_enabled { + "true" + } else { + "false" + }, + ); + compare_field( + &mut fields, + "blog_languages", + &json_value(&database.blog_languages)?, + &json_value(&metadata.blog_languages)?, + ); + } Ok((!fields.is_empty()).then_some(EntityDiff { entity_type: "project".into(), entity_id: project.id.clone(), @@ -355,24 +447,96 @@ fn diff_project( })) } -fn sync_project_from_file( +fn diff_categories( conn: &Connection, data_dir: &Path, project_id: &str, -) -> EngineResult<()> { - crate::engine::meta::sync_project_from_file(conn, data_dir, project_id) +) -> EngineResult> { + let Some(database) = crate::engine::meta::read_categories_snapshot(conn, project_id)? else { + return Ok(None); + }; + let filesystem = crate::engine::meta::read_categories_json(data_dir)?; + let mut fields = Vec::new(); + compare_field( + &mut fields, + "categories", + &json_value(&database)?, + &json_value(&filesystem)?, + ); + Ok((!fields.is_empty()).then_some(EntityDiff { + entity_type: "categories".into(), + entity_id: project_id.into(), + file_path: "meta/categories.json".into(), + fields, + })) } -fn rewrite_project_from_database( +fn diff_category_meta( conn: &Connection, data_dir: &Path, project_id: &str, -) -> EngineResult<()> { - let project = qproject::get_project_by_id(conn, project_id)?; - let mut metadata = crate::engine::meta::read_project_json(data_dir)?; - metadata.name = project.name; - metadata.description = project.description; - crate::engine::meta::write_project_json(data_dir, &metadata) +) -> EngineResult> { + let Some(database) = crate::engine::meta::read_category_meta_snapshot(conn, project_id)? else { + return Ok(None); + }; + let filesystem = crate::engine::meta::read_category_meta_json(data_dir)?; + let database = database.into_iter().collect::>(); + let filesystem = filesystem.into_iter().collect::>(); + let mut fields = Vec::new(); + compare_field( + &mut fields, + "category_settings", + &json_value(&database)?, + &json_value(&filesystem)?, + ); + Ok((!fields.is_empty()).then_some(EntityDiff { + entity_type: "category_meta".into(), + entity_id: project_id.into(), + file_path: "meta/category-meta.json".into(), + fields, + })) +} + +fn diff_publishing( + conn: &Connection, + data_dir: &Path, + project_id: &str, +) -> EngineResult> { + let Some(database) = crate::engine::meta::read_publishing_snapshot(conn, project_id)? else { + return Ok(None); + }; + let filesystem = crate::engine::meta::read_publishing_json(data_dir)?; + let mut fields = Vec::new(); + compare_optional_field( + &mut fields, + "ssh_host", + &database.ssh_host, + &filesystem.ssh_host, + ); + compare_optional_field( + &mut fields, + "ssh_user", + &database.ssh_user, + &filesystem.ssh_user, + ); + compare_optional_field( + &mut fields, + "ssh_remote_path", + &database.ssh_remote_path, + &filesystem.ssh_remote_path, + ); + compare_field( + &mut fields, + "ssh_mode", + ssh_mode_name(&database.ssh_mode), + ssh_mode_name(&filesystem.ssh_mode), + ); + Ok((!fields.is_empty()).then_some(EntityDiff { + entity_type: "publishing".into(), + entity_id: project_id.into(), + file_path: "meta/publishing.json".into(), + fields, + })) } fn unsupported_repair(entity_type: &str) -> EngineResult { @@ -536,6 +700,31 @@ fn compare_field(fields: &mut Vec, name: &str, db_val: &str, file_val } } +fn compare_optional_field( + fields: &mut Vec, + name: &str, + db_val: &Option, + file_val: &Option, +) { + compare_field( + fields, + name, + db_val.as_deref().unwrap_or(""), + file_val.as_deref().unwrap_or(""), + ); +} + +fn json_value(value: &impl serde::Serialize) -> EngineResult { + Ok(serde_json::to_string(value)?) +} + +fn ssh_mode_name(mode: &crate::model::SshMode) -> &'static str { + match mode { + crate::model::SshMode::Scp => "scp", + crate::model::SshMode::Rsync => "rsync", + } +} + fn diff_post(data_dir: &Path, post: &Post) -> EngineResult> { let abs_path = data_dir.join(&post.file_path); if !abs_path.exists() { @@ -1037,8 +1226,8 @@ mod tests { use crate::db::queries::template::insert_template; use crate::engine::post::{archive_post, create_post, publish_post}; use crate::model::{ - Media, Post, PostStatus, ProjectMetadata, Script, ScriptKind, ScriptStatus, Template, - TemplateKind, TemplateStatus, + Media, Post, PostStatus, ProjectMetadata, PublishingPreferences, Script, ScriptKind, + ScriptStatus, SshMode, Template, TemplateKind, TemplateStatus, }; use crate::util::frontmatter::{ ScriptFrontmatter, TemplateFrontmatter, write_script_file, write_template_file, @@ -1223,6 +1412,136 @@ mod tests { })); } + fn seed_portable_metadata(db: &Database, dir: &TempDir) { + crate::engine::meta::startup_sync(dir.path()).unwrap(); + crate::engine::meta::sync_metadata_from_filesystem(db.conn(), dir.path(), "p1").unwrap(); + crate::engine::meta::set_categories( + db.conn(), + dir.path(), + "p1", + &["article".into(), "news".into()], + ) + .unwrap(); + let mut category_meta = std::collections::HashMap::new(); + category_meta.insert( + "news".to_string(), + crate::model::metadata::CategorySettings { + title: Some("News".into()), + render_in_lists: false, + show_title: true, + post_template_slug: Some("article".into()), + list_template_slug: None, + }, + ); + crate::engine::meta::set_category_meta(db.conn(), dir.path(), "p1", &category_meta) + .unwrap(); + crate::engine::meta::set_publishing_preferences( + db.conn(), + dir.path(), + "p1", + &PublishingPreferences { + ssh_host: Some("example.net".into()), + ssh_user: Some("deploy".into()), + ssh_remote_path: Some("/srv/blog".into()), + ssh_mode: SshMode::Rsync, + }, + ) + .unwrap(); + } + + #[test] + fn detects_categories_category_meta_and_publishing_drift() { + let (db, dir) = setup(); + seed_portable_metadata(&db, &dir); + crate::engine::meta::write_categories_json(dir.path(), &["article".into()]).unwrap(); + crate::engine::meta::write_category_meta_json(dir.path(), &Default::default()).unwrap(); + crate::engine::meta::write_publishing_json(dir.path(), &PublishingPreferences::default()) + .unwrap(); + + let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); + + for entity_type in ["categories", "category_meta", "publishing"] { + assert!( + report + .diffs + .iter() + .any(|item| item.entity_type == entity_type), + "missing {entity_type} diff: {:?}", + report.diffs + ); + } + } + + #[test] + fn repairs_portable_metadata_in_both_directions() { + let (db, dir) = setup(); + seed_portable_metadata(&db, &dir); + crate::engine::meta::write_categories_json(dir.path(), &["filesystem".into()]).unwrap(); + crate::engine::meta::write_category_meta_json(dir.path(), &Default::default()).unwrap(); + crate::engine::meta::write_publishing_json(dir.path(), &PublishingPreferences::default()) + .unwrap(); + let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); + + let categories = report + .diffs + .iter() + .find(|item| item.entity_type == "categories") + .unwrap(); + repair_metadata_diff_item( + db.conn(), + dir.path(), + "p1", + RepairDirection::DatabaseToFile, + categories, + ) + .unwrap(); + assert_eq!( + crate::engine::meta::read_categories_json(dir.path()).unwrap(), + vec!["article", "news"] + ); + assert!( + crate::engine::meta::read_category_meta_json(dir.path()) + .unwrap() + .contains_key("news") + ); + assert_eq!( + crate::engine::meta::read_publishing_json(dir.path()) + .unwrap() + .ssh_host + .as_deref(), + Some("example.net") + ); + + crate::engine::meta::write_categories_json(dir.path(), &["filesystem".into()]).unwrap(); + crate::engine::meta::write_category_meta_json(dir.path(), &Default::default()).unwrap(); + crate::engine::meta::write_publishing_json(dir.path(), &PublishingPreferences::default()) + .unwrap(); + let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); + let categories = report + .diffs + .iter() + .find(|item| item.entity_type == "categories") + .unwrap(); + repair_metadata_diff_item( + db.conn(), + dir.path(), + "p1", + RepairDirection::FileToDatabase, + categories, + ) + .unwrap(); + assert!( + compute_metadata_diff(db.conn(), dir.path(), "p1") + .unwrap() + .diffs + .iter() + .all(|item| !matches!( + item.entity_type.as_str(), + "categories" | "category_meta" | "publishing" + )) + ); + } + #[test] fn ignores_python_scripts_during_orphan_scan() { let (db, dir) = setup(); diff --git a/crates/bds-core/src/engine/project.rs b/crates/bds-core/src/engine/project.rs index b18c03c..51d4064 100644 --- a/crates/bds-core/src/engine/project.rs +++ b/crates/bds-core/src/engine/project.rs @@ -57,6 +57,7 @@ pub fn create_project( // Write default meta files write_default_meta_files(&data_dir, name)?; + crate::engine::meta::sync_metadata_from_filesystem(conn, &data_dir, &project.id)?; emit_project(&project, NotificationAction::Created); @@ -122,6 +123,8 @@ pub fn open_project(conn: &Connection, folder_path: &Path) -> EngineResult EngineResult, ) -> EngineResult { match q::get_project_by_id(conn, DEFAULT_PROJECT_ID) { - Ok(p) => Ok(p), + Ok(p) => { + if let Some(data_dir) = default_data_dir { + crate::engine::meta::startup_sync(data_dir)?; + crate::engine::meta::initialize_metadata_snapshots(conn, data_dir, &p.id)?; + } + Ok(p) + } Err(diesel::result::Error::NotFound) => { let now = now_unix_ms(); let project = Project { @@ -174,6 +185,7 @@ pub fn ensure_default_project( }; create_directory_structure(&data_dir)?; write_default_meta_files(&data_dir, "My Blog")?; + crate::engine::meta::sync_metadata_from_filesystem(conn, &data_dir, &project.id)?; emit_project(&project, NotificationAction::Created); Ok(project) } @@ -237,7 +249,22 @@ pub fn delete_project( .map_err(|_| EngineError::NotFound(format!("project {project_id}")))?; let is_custom_path = project.data_path.is_some(); - q::delete_project(conn, project_id)?; + conn.begin_savepoint()?; + let deleted = (|| { + q::delete_project(conn, project_id)?; + crate::db::queries::setting::delete_settings_by_prefix( + conn, + &format!("project:{project_id}:"), + )?; + Ok::<_, EngineError>(()) + })(); + match deleted { + Ok(()) => conn.release_savepoint()?, + Err(error) => { + let _ = conn.rollback_savepoint(); + return Err(error); + } + } crate::engine::embedding::EmbeddingService::forget_project(project_id); // Clean up internal filesystem only (not custom external paths per spec) @@ -492,12 +519,26 @@ mod tests { updated_at: now, }; crate::db::queries::project::insert_project(db.conn(), &project).unwrap(); + crate::db::queries::setting::set_setting_value( + db.conn(), + &format!("project:{}:categories", project.id), + r#"{"categories":[]}"#, + now, + ) + .unwrap(); // Create the internal directory structure under the temp dir let internal_dir = dir.path().join("projects").join(&project.id); create_directory_structure(&internal_dir).unwrap(); assert!(internal_dir.join("posts").is_dir()); delete_project(db.conn(), &project.id, Some(&internal_dir)).unwrap(); assert!(list_projects(db.conn()).unwrap().is_empty()); + assert!( + crate::db::queries::setting::get_setting_by_key( + db.conn(), + &format!("project:{}:categories", project.id) + ) + .is_err() + ); assert!( !internal_dir.exists(), "internal directory should be cleaned up" diff --git a/crates/bds-core/src/engine/rebuild.rs b/crates/bds-core/src/engine/rebuild.rs index 91fc291..61fbadb 100644 --- a/crates/bds-core/src/engine/rebuild.rs +++ b/crates/bds-core/src/engine/rebuild.rs @@ -127,7 +127,8 @@ fn rebuild_from_filesystem_inner( // 1. Load portable project metadata and clear all reconstructible rows. progress(0.0, "Loading project metadata..."); fts::ensure_fts_tables(conn)?; - crate::engine::meta::sync_project_from_file(conn, data_dir, project_id)?; + crate::engine::meta::startup_sync(data_dir)?; + crate::engine::meta::sync_metadata_from_filesystem(conn, data_dir, project_id)?; clear_project_rows(conn, project_id)?; // 2. Rebuild posts (0.00 .. 0.35) @@ -628,6 +629,18 @@ function render() end let project = crate::db::queries::project::get_project_by_id(db.conn(), "p1").unwrap(); assert_eq!(project.name, "Rebuilt Project"); assert!(project.description.is_none()); + assert_eq!( + crate::engine::meta::read_categories_snapshot(db.conn(), "p1") + .unwrap() + .unwrap(), + vec!["article", "aside", "page", "picture"] + ); + assert_eq!( + crate::engine::meta::read_publishing_snapshot(db.conn(), "p1") + .unwrap() + .unwrap(), + crate::model::PublishingPreferences::default() + ); let links = crate::db::queries::post_media::list_post_media_by_media(db.conn(), "test-media-1") diff --git a/crates/bds-core/src/engine/settings.rs b/crates/bds-core/src/engine/settings.rs index 9b388da..f9dd239 100644 --- a/crates/bds-core/src/engine/settings.rs +++ b/crates/bds-core/src/engine/settings.rs @@ -82,6 +82,7 @@ pub fn list_effective(conn: &Connection) -> EngineResult meta::add_category(data_dir, &item.name)?, + TaxonomyKind::Category => { + meta::add_category(conn, data_dir, project_id, &item.name)? + } TaxonomyKind::Tag => { tag::create_tag(conn, data_dir, project_id, &item.name, None)?; } diff --git a/crates/bds-core/src/model/generation.rs b/crates/bds-core/src/model/generation.rs index afbccba..487cc5a 100644 --- a/crates/bds-core/src/model/generation.rs +++ b/crates/bds-core/src/model/generation.rs @@ -133,7 +133,7 @@ pub enum SshMode { } /// Publishing preferences stored in meta/publishing.json. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PublishingPreferences { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/bds-core/src/model/metadata.rs b/crates/bds-core/src/model/metadata.rs index da41dbc..794e651 100644 --- a/crates/bds-core/src/model/metadata.rs +++ b/crates/bds-core/src/model/metadata.rs @@ -41,7 +41,7 @@ fn default_true() -> bool { true } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProjectMetadata { pub name: String, @@ -94,7 +94,7 @@ impl ProjectMetadata { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CategorySettings { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/bds-core/src/scripting/core_host.rs b/crates/bds-core/src/scripting/core_host.rs index c244ff8..e25406e 100644 --- a/crates/bds-core/src/scripting/core_host.rs +++ b/crates/bds-core/src/scripting/core_host.rs @@ -207,6 +207,7 @@ impl CoreHost { match method { "get_project_metadata" => public_metadata(&self.data_dir), "update_project_metadata" | "set_project_metadata" => { + let db = self.database()?; let mut metadata = engine::meta::read_project_json(&self.data_dir)?; let updates = object_arg(args, 0)?; assign_string(updates, "name", &mut metadata.name); @@ -218,15 +219,33 @@ impl CoreHost { metadata.blog_languages = languages; } metadata.validate().map_err(text)?; - engine::meta::write_project_json(&self.data_dir, &metadata)?; + let project = project::get_project_by_id(db.conn(), &self.project_id)?; + engine::meta::update_project_metadata( + db.conn(), + &self.data_dir, + &project, + &metadata, + )?; public_metadata(&self.data_dir) } "add_category" => { - engine::meta::add_category(&self.data_dir, string_arg(args, 0)?)?; + let db = self.database()?; + engine::meta::add_category( + db.conn(), + &self.data_dir, + &self.project_id, + string_arg(args, 0)?, + )?; public_metadata(&self.data_dir) } "remove_category" => { - engine::meta::remove_category(&self.data_dir, string_arg(args, 0)?)?; + let db = self.database()?; + engine::meta::remove_category( + db.conn(), + &self.data_dir, + &self.project_id, + string_arg(args, 0)?, + )?; public_metadata(&self.data_dir) } "add_tag" => self.meta_tag(string_arg(args, 0)?, true), @@ -242,18 +261,36 @@ impl CoreHost { json_value(engine::meta::read_publishing_json(&self.data_dir)) } "set_publishing_preferences" => { + let db = self.database()?; let prefs = serde_json::from_value(Value::Object(object_arg(args, 0)?.clone())) .map_err(text)?; - engine::meta::write_publishing_json(&self.data_dir, &prefs)?; + engine::meta::set_publishing_preferences( + db.conn(), + &self.data_dir, + &self.project_id, + &prefs, + )?; json_value(engine::meta::read_publishing_json(&self.data_dir)) } "clear_publishing_preferences" => { + let db = self.database()?; let prefs = Default::default(); - engine::meta::write_publishing_json(&self.data_dir, &prefs)?; + engine::meta::set_publishing_preferences( + db.conn(), + &self.data_dir, + &self.project_id, + &prefs, + )?; json_value(Ok::<_, EngineError>(prefs)) } "sync_on_startup" => { + let db = self.database()?; engine::meta::startup_sync(&self.data_dir)?; + engine::meta::initialize_metadata_snapshots( + db.conn(), + &self.data_dir, + &self.project_id, + )?; Ok(json!({ "metadata": public_metadata(&self.data_dir)?, "categories": engine::meta::read_categories_json(&self.data_dir)?, diff --git a/crates/bds-core/tests/wordpress_import.rs b/crates/bds-core/tests/wordpress_import.rs index b509c62..6b9df32 100644 --- a/crates/bds-core/tests/wordpress_import.rs +++ b/crates/bds-core/tests/wordpress_import.rs @@ -50,7 +50,7 @@ fn parser_rejects_malformed_or_channel_less_xml_and_ignores_unknown_elements() { #[test] fn analysis_converts_html_and_shortcodes_and_classifies_every_status() { let (db, dir, project) = setup(); - meta::add_category(dir.path(), "GENERAL").unwrap(); + meta::add_category(db.conn(), dir.path(), &project.id, "GENERAL").unwrap(); tag::create_tag(db.conn(), dir.path(), &project.id, "nEWs", None).unwrap(); let update = post::create_post( diff --git a/crates/bds-server/src/host.rs b/crates/bds-server/src/host.rs index 0245335..26da9a8 100644 --- a/crates/bds-server/src/host.rs +++ b/crates/bds-server/src/host.rs @@ -352,6 +352,15 @@ impl ApplicationSession { format!("project '{}' data is unavailable", value.name), )); } + bds_core::engine::meta::startup_sync(&data_dir) + .and_then(|()| { + bds_core::engine::meta::initialize_metadata_snapshots( + db.conn(), + &data_dir, + &value.id, + ) + }) + .map_err(ProtocolError::engine)?; self.selected_project = Some(SelectedProject { id: value.id.clone(), data_dir, @@ -437,8 +446,8 @@ mod tests { let root = tempfile::tempdir().unwrap(); let database_path = root.path().join("bds.db"); let data_root = root.path().join("data"); - let host = ApplicationHost::start(database_path.clone(), data_root.clone()).unwrap(); let db = Database::open(&database_path).unwrap(); + db.migrate().unwrap(); let project_dir = root.path().join("blog"); let value = project::create_project( db.conn(), @@ -447,6 +456,8 @@ mod tests { ) .unwrap(); settings::set(db.conn(), settings::UI_LANGUAGE_KEY, "de").unwrap(); + drop(db); + let host = ApplicationHost::start(database_path, data_root).unwrap(); Self { _root: root, host, @@ -479,6 +490,12 @@ mod tests { #[test] fn session_negotiates_server_locale_and_selects_a_project() { let fixture = Fixture::new(); + let db = fixture.host.database().unwrap(); + bds_core::db::queries::setting::delete_settings_by_prefix( + db.conn(), + &format!("project:{}:", fixture.project_id), + ) + .unwrap(); let mut session = fixture.host.session().unwrap(); assert_eq!(session.locale(), "de"); assert!(matches!( @@ -495,6 +512,11 @@ mod tests { }, )); assert!(matches!(opened, ServerMessage::Response { .. })); + assert!( + bds_core::engine::meta::read_categories_snapshot(db.conn(), &fixture.project_id) + .unwrap() + .is_some() + ); } #[test] diff --git a/crates/bds-server/src/tui.rs b/crates/bds-server/src/tui.rs index 63ed4eb..fe8ada0 100644 --- a/crates/bds-server/src/tui.rs +++ b/crates/bds-server/src/tui.rs @@ -377,6 +377,10 @@ impl TuiApp { let data_dir = project .as_ref() .map(|project| host.project_data_dir(project)); + if let (Some(project), Some(data_dir)) = (&project, &data_dir) { + engine::meta::startup_sync(data_dir)?; + engine::meta::initialize_metadata_snapshots(db.conn(), data_dir, &project.id)?; + } let started_task_ids = host .tasks() .snapshots() @@ -2218,8 +2222,12 @@ impl TuiApp { .parse() .map_err(|_| anyhow!(self.tr("tui.imageImportConcurrencyInvalid")))?; metadata.validate().map_err(|error| anyhow!(error))?; - bds_core::db::queries::project::update_project(db.conn(), &project)?; - engine::meta::write_project_json(self.data_dir()?, &metadata)?; + engine::meta::update_project_metadata( + db.conn(), + self.data_dir()?, + &project, + &metadata, + )?; self.project = Some(project); if let Some(value) = self.setting_field(engine::settings::UI_LANGUAGE_KEY) { engine::settings::set(db.conn(), value.key, &value.value)?; @@ -2235,7 +2243,16 @@ impl TuiApp { metadata.semantic_similarity_enabled = self .setting_value("meta.semantic_similarity_enabled") .eq_ignore_ascii_case("true"); - engine::meta::write_project_json(self.data_dir()?, &metadata)?; + let project = self + .project + .as_ref() + .ok_or_else(|| anyhow!(self.tr("tui.noActiveProject")))?; + engine::meta::update_project_metadata( + db.conn(), + self.data_dir()?, + project, + &metadata, + )?; } SettingSection::Publishing => { let preferences = PublishingPreferences { @@ -2250,7 +2267,12 @@ impl TuiApp { SshMode::Scp }, }; - engine::meta::write_publishing_json(self.data_dir()?, &preferences)?; + engine::meta::set_publishing_preferences( + db.conn(), + self.data_dir()?, + self.project_id()?, + &preferences, + )?; } SettingSection::Mcp => { engine::settings::set( @@ -3942,6 +3964,25 @@ mod tests { } } + #[test] + fn startup_initializes_legacy_project_metadata_snapshots() { + let fixture = Fixture::new(); + let db = fixture.db(); + bds_core::db::queries::setting::delete_settings_by_prefix( + db.conn(), + &format!("project:{}:", fixture.project.id), + ) + .unwrap(); + + let _app = fixture.app(false); + + assert!( + engine::meta::read_categories_snapshot(db.conn(), &fixture.project.id) + .unwrap() + .is_some() + ); + } + fn wait_for(app: &mut TuiApp, predicate: impl Fn(&TuiApp) -> bool) { let started = Instant::now(); while !predicate(app) && started.elapsed() < Duration::from_secs(2) { diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 4e50c14..6a075aa 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -1704,6 +1704,17 @@ impl BdsApp { self.operation_failed_text("common.metadataSync", e.to_string()); self.add_output(&message); } + if let (Some(db), Some(project)) = (&self.db, &self.active_project) + && let Err(e) = engine::meta::initialize_metadata_snapshots( + db.conn(), + &data_dir, + &project.id, + ) + { + let message = + self.operation_failed_text("common.metadataSync", e.to_string()); + self.add_output(&message); + } // Extract content language from project metadata if let Ok(meta) = engine::meta::read_project_json(&data_dir) { let main_lang = meta.main_language.unwrap_or_else(|| "en".to_string()); @@ -1745,6 +1756,20 @@ impl BdsApp { } match engine::project::set_active_project(db.conn(), &project_id) { Ok(()) => { + if let Some(project) = self + .projects + .iter() + .find(|project| project.id == project_id) + && let Some(data_dir) = + project.data_path.as_deref().map(PathBuf::from) + { + let _ = engine::meta::startup_sync(&data_dir); + let _ = engine::meta::initialize_metadata_snapshots( + db.conn(), + &data_dir, + &project.id, + ); + } self.reset_git_for_project_change(); self.active_project = self.projects.iter().find(|p| p.id == project_id).cloned(); @@ -1760,7 +1785,6 @@ impl BdsApp { .map(PathBuf::from); // Per metadata.allium StartupSync if let Some(data_dir) = self.data_dir.clone() { - let _ = engine::meta::startup_sync(&data_dir); if let Ok(meta) = engine::meta::read_project_json(&data_dir) { let main_lang = meta.main_language.unwrap_or_else(|| "en".to_string()); @@ -4779,8 +4803,10 @@ impl BdsApp { let previous = self.menu_editor_state.clone(); if let Ok((name, is_new)) = self.menu_editor_state.submit_category() && is_new + && let (Some(db), Some(project)) = (&self.db, &self.active_project) && let Some(data_dir) = &self.data_dir - && let Err(error) = engine::meta::add_category(data_dir, &name) + && let Err(error) = + engine::meta::add_category(db.conn(), data_dir, &project.id, &name) { self.menu_editor_state = previous; self.notify( @@ -7826,8 +7852,15 @@ impl BdsApp { ); return Task::none(); } - if let Some(data_dir) = &self.data_dir { - match engine::meta::add_category(data_dir, category_name) { + if let (Some(db), Some(data_dir), Some(project)) = + (&self.db, &self.data_dir, &self.active_project) + { + match engine::meta::add_category( + db.conn(), + data_dir, + &project.id, + category_name, + ) { Ok(()) => { self.settings_state = Some(self.hydrate_settings_state()); if let Some(state) = self.settings_state.as_mut() { @@ -7866,7 +7899,8 @@ impl BdsApp { } } SettingsMsg::SaveCategory(name) => { - if let Some(data_dir) = &self.data_dir + if let (Some(db), Some(data_dir), Some(project)) = + (&self.db, &self.data_dir, &self.active_project) && let Some(row) = state.categories.iter().find(|row| row.name == name) { let mut category_meta = @@ -7883,7 +7917,12 @@ impl BdsApp { .then(|| row.list_template_slug.clone()), }, ); - match engine::meta::write_category_meta_json(data_dir, &category_meta) { + match engine::meta::set_category_meta( + db.conn(), + data_dir, + &project.id, + &category_meta, + ) { Ok(()) => { self.dashboard_state = Some(self.hydrate_dashboard_state()); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); @@ -7893,8 +7932,10 @@ impl BdsApp { } } SettingsMsg::RemoveCategory(name) => { - if let Some(data_dir) = &self.data_dir { - match engine::meta::remove_category(data_dir, &name) { + if let (Some(db), Some(data_dir), Some(project)) = + (&self.db, &self.data_dir, &self.active_project) + { + match engine::meta::remove_category(db.conn(), data_dir, &project.id, &name) { Ok(()) => { self.settings_state = Some(self.hydrate_settings_state()); self.dashboard_state = Some(self.hydrate_dashboard_state()); @@ -7905,7 +7946,9 @@ impl BdsApp { } } SettingsMsg::ResetCategoriesToDefaults => { - if let Some(data_dir) = &self.data_dir { + if let (Some(db), Some(data_dir), Some(project)) = + (&self.db, &self.data_dir, &self.active_project) + { let default_names = default_category_rows() .into_iter() .map(|row| row.name) @@ -7926,16 +7969,19 @@ impl BdsApp { ) }) .collect::>(); - match ( - engine::meta::write_categories_json(data_dir, &default_names), - engine::meta::write_category_meta_json(data_dir, &default_meta), + match engine::meta::set_categories_and_meta( + db.conn(), + data_dir, + &project.id, + &default_names, + &default_meta, ) { - (Ok(()), Ok(())) => { + Ok(()) => { self.settings_state = Some(self.hydrate_settings_state()); self.dashboard_state = Some(self.hydrate_dashboard_state()); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } - (Err(e), _) | (_, Err(e)) => self.notify_operation_failed("common.save", e), + Err(e) => self.notify_operation_failed("common.save", e), } } } @@ -7952,7 +7998,9 @@ impl BdsApp { state.ssh_remote_path = s; } SettingsMsg::SavePublishing => { - if let Some(data_dir) = &self.data_dir { + if let (Some(db), Some(data_dir), Some(project)) = + (&self.db, &self.data_dir, &self.active_project) + { let prefs = PublishingPreferences { ssh_host: if state.ssh_host.trim().is_empty() { None @@ -7975,7 +8023,12 @@ impl BdsApp { SshMode::Rsync }, }; - match engine::meta::write_publishing_json(data_dir, &prefs) { + match engine::meta::set_publishing_preferences( + db.conn(), + data_dir, + &project.id, + &prefs, + ) { Ok(()) => { self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")) } diff --git a/crates/bds-ui/src/views/metadata_diff.rs b/crates/bds-ui/src/views/metadata_diff.rs index a11b508..b525af4 100644 --- a/crates/bds-ui/src/views/metadata_diff.rs +++ b/crates/bds-ui/src/views/metadata_diff.rs @@ -155,3 +155,34 @@ fn message_card(value: String) -> Element<'static, Message> { ) .into() } + +#[cfg(test)] +mod tests { + use super::*; + use bds_core::engine::metadata_diff::{DiffField, EntityDiff}; + + #[test] + fn renders_all_portable_project_metadata_diff_cards() { + let state = MetadataDiffState { + report: Some(DiffReport { + diffs: ["categories", "category_meta", "publishing"] + .into_iter() + .map(|entity_type| EntityDiff { + entity_type: entity_type.into(), + entity_id: "p1".into(), + file_path: format!("meta/{entity_type}.json"), + fields: vec![DiffField { + field_name: "value".into(), + db_value: "database".into(), + file_value: "filesystem".into(), + }], + }) + .collect(), + ..Default::default() + }), + ..Default::default() + }; + + let _: Element<'_, Message> = view(&state, UiLocale::En); + } +} diff --git a/specs/metadata_diff.allium b/specs/metadata_diff.allium index ab75b25..bf735fc 100644 --- a/specs/metadata_diff.allium +++ b/specs/metadata_diff.allium @@ -25,8 +25,9 @@ value DiffField { } value DiffReport { - entity_type: String -- post, post_translation, media, - -- media_translation, script, template, embedding + entity_type: String -- project, categories, category_meta, publishing, + -- post, post_translation, media, media_translation, + -- script, template, embedding entity_id: String differences: List } @@ -44,6 +45,20 @@ rule RunMetadataDiff { -- translations, media, Lua scripts, templates -- Detected fields: tags, categories, title, excerpt, author, -- language, status, templateSlug, dates + -- Portable project metadata has a project-scoped database snapshot in the + -- settings table. Compare its project, category list, per-category settings, + -- and publishing preferences against the matching meta/*.json files. + for metadata_type in {"project", "categories", "category_meta", "publishing"}: + let db_metadata = ProjectMetadataSetting(project.id, metadata_type) + let file_metadata = ReadProjectMetadataFile(project, metadata_type) + let metadata_diffs = compare_fields(db_metadata, file_metadata) + if metadata_diffs.count > 0: + ensures: + DiffReport.created( + entity_type: metadata_type, + entity_id: project.id, + differences: metadata_diffs + ) for post in project.posts: let file_data = parse_post_file(post.file_path) let diffs = compare_fields(post, file_data) @@ -83,6 +98,7 @@ rule RebuildDatabase { -- project.json, posts, translations, media sidecars and links, tags, -- templates, and Lua scripts are imported. Python files in scripts/ are ignored. ensures: ProjectMetadataImported(project, "meta/project.json") + ensures: ProjectMetadataSettingsImported(project, "meta/*.json") ensures: post/RebuildPostsFromFiles(project) ensures: media/RebuildMediaFromFiles(project) ensures: script/RebuildScriptsFromFiles(project)