Add portable project metadata diff coverage.

This commit is contained in:
2026-07-21 23:54:37 +02:00
parent 1410b97aeb
commit 2fc683d513
17 changed files with 994 additions and 82 deletions

View File

@@ -46,6 +46,12 @@ pub fn list_all_settings(conn: &DbConnection) -> QueryResult<Vec<Setting>> {
})
}
pub fn delete_settings_by_prefix(conn: &DbConnection, prefix: &str) -> QueryResult<usize> {
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());
}
}

View File

@@ -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<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct CategoryMetaSnapshot {
categories: HashMap<String, CategorySettings>,
}
fn metadata_setting_key(project_id: &str, suffix: &str) -> String {
format!("project:{project_id}:{suffix}")
}
fn persist_snapshot<T: Serialize>(
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<T: DeserializeOwned>(
conn: &Connection,
project_id: &str,
suffix: &str,
) -> EngineResult<Option<T>> {
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<Option<ProjectMetadata>> {
load_snapshot(conn, project_id, PROJECT_SETTING_SUFFIX)
}
pub fn read_categories_snapshot(
conn: &Connection,
project_id: &str,
) -> EngineResult<Option<Vec<String>>> {
Ok(
load_snapshot::<CategoriesSnapshot>(conn, project_id, CATEGORIES_SETTING_SUFFIX)?
.map(|snapshot| snapshot.categories),
)
}
pub fn read_category_meta_snapshot(
conn: &Connection,
project_id: &str,
) -> EngineResult<Option<HashMap<String, CategorySettings>>> {
Ok(
load_snapshot::<CategoryMetaSnapshot>(conn, project_id, CATEGORY_META_SETTING_SUFFIX)?
.map(|snapshot| snapshot.categories),
)
}
pub fn read_publishing_snapshot(
conn: &Connection,
project_id: &str,
) -> EngineResult<Option<PublishingPreferences>> {
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<String, CategorySettings>,
) -> 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<String, CategorySettings>,
) -> 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"]
);
}
}

View File

@@ -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<Option<EntityDiff>> {
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<Option<EntityDiff>> {
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<Option<EntityDiff>> {
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::<BTreeMap<_, _>>();
let filesystem = filesystem.into_iter().collect::<BTreeMap<_, _>>();
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<Option<EntityDiff>> {
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<T>(entity_type: &str) -> EngineResult<T> {
@@ -536,6 +700,31 @@ fn compare_field(fields: &mut Vec<DiffField>, name: &str, db_val: &str, file_val
}
}
fn compare_optional_field(
fields: &mut Vec<DiffField>,
name: &str,
db_val: &Option<String>,
file_val: &Option<String>,
) {
compare_field(
fields,
name,
db_val.as_deref().unwrap_or(""),
file_val.as_deref().unwrap_or(""),
);
}
fn json_value(value: &impl serde::Serialize) -> EngineResult<String> {
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<Option<EntityDiff>> {
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();

View File

@@ -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<Proje
project.data_path = Some(resolved_path);
project.updated_at = now_unix_ms();
q::update_project(conn, &project)?;
crate::engine::meta::startup_sync(&folder_path)?;
crate::engine::meta::initialize_metadata_snapshots(conn, &folder_path, &project.id)?;
emit_project(&project, NotificationAction::Updated);
return Ok(project);
}
@@ -141,6 +144,8 @@ pub fn open_project(conn: &Connection, folder_path: &Path) -> EngineResult<Proje
updated_at: now,
};
q::insert_project(conn, &project)?;
crate::engine::meta::startup_sync(&folder_path)?;
crate::engine::meta::sync_metadata_from_filesystem(conn, &folder_path, &project.id)?;
emit_project(&project, NotificationAction::Created);
Ok(project)
}
@@ -153,7 +158,13 @@ pub fn ensure_default_project(
default_data_dir: Option<&Path>,
) -> EngineResult<Project> {
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"

View File

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

View File

@@ -82,6 +82,7 @@ pub fn list_effective(conn: &Connection) -> EngineResult<BTreeMap<String, String
);
for value in setting::list_all_settings(conn)? {
if !value.key.starts_with("app.")
&& !value.key.starts_with("project:")
&& value.key != ONLINE_API_KEY
&& value.key != ONLINE_API_KEY_CONFIGURED
&& value.key != AIRPLANE_API_KEY
@@ -162,4 +163,23 @@ mod tests {
let db = Database::open(&path).unwrap();
assert!(airplane_mode(db.conn()).unwrap());
}
#[test]
fn effective_settings_hide_internal_project_metadata_snapshots() {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
setting::set_setting_value(
db.conn(),
"project:p1:categories",
r#"{"categories":["article"]}"#,
1,
)
.unwrap();
assert!(
!list_effective(db.conn())
.unwrap()
.contains_key("project:p1:categories")
);
}
}

View File

@@ -1095,7 +1095,9 @@ pub fn execute_import(
false
} else {
match item.kind {
TaxonomyKind::Category => 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)?;
}

View File

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

View File

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

View File

@@ -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)?,

View File

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

View File

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

View File

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

View File

@@ -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::<HashMap<_, _>>();
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"))
}

View File

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