Add portable project metadata diff coverage.
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user