Add portable project metadata diff coverage.
This commit is contained in:
@@ -11,7 +11,7 @@ The project is under active development. Core blogging workflows are broadly ava
|
|||||||
- Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
|
- Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
|
||||||
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
||||||
- Post, Liquid template, and Lua script editing with dedicated syntax highlighting, explicit syntax-check feedback, change-aware published/draft lifecycle, normalized collision-safe template/script slug changes, reference-safe template renames, and publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync.
|
- Post, Liquid template, and Lua script editing with dedicated syntax highlighting, explicit syntax-check feedback, change-aware published/draft lifecycle, normalized collision-safe template/script slug changes, reference-safe template renames, and publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync.
|
||||||
- SQLite and filesystem persistence with frontmatter, relationship-safe media sidecars, rebuild, metadata diff/repair, stale post-path cleanup on republish, bDS2-compatible checksums and NFD slug generation, and FTS5 search.
|
- SQLite and filesystem persistence with frontmatter, relationship-safe media sidecars, rebuild, bidirectional metadata diff/repair including project categories and publishing preferences, stale post-path cleanup on republish, bDS2-compatible checksums and NFD slug generation, and FTS5 search.
|
||||||
- Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace.
|
- Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace.
|
||||||
- Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links.
|
- Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links.
|
||||||
- A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence.
|
- A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence.
|
||||||
|
|||||||
@@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -93,4 +99,18 @@ mod tests {
|
|||||||
let db = setup();
|
let db = setup();
|
||||||
assert!(get_setting_by_key(db.conn(), "nope").is_err());
|
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::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::db::DbConnection as Connection;
|
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::metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
||||||
use crate::model::{DomainEntity, NotificationAction, Project, PublishingPreferences};
|
use crate::model::{DomainEntity, NotificationAction, Project, PublishingPreferences};
|
||||||
use crate::util::atomic_write_str;
|
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 ────────────────────────────────────────────────────
|
// ── project.json ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Read and parse meta/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();
|
persisted_project.updated_at = crate::util::now_unix_ms();
|
||||||
|
|
||||||
let category_metadata = read_category_meta_json(data_dir)?;
|
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_project_json(data_dir, metadata)?;
|
||||||
write_category_meta_json(data_dir, &category_metadata)?;
|
write_category_meta_json(data_dir, &category_metadata)?;
|
||||||
crate::db::queries::project::update_project(conn, &persisted_project)?;
|
crate::db::queries::project::update_project(conn, &persisted_project)?;
|
||||||
@@ -61,25 +148,119 @@ pub fn update_project_metadata(
|
|||||||
Ok(metadata.clone())
|
Ok(metadata.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy the project fields persisted in both representations from project.json
|
/// Replace all database metadata snapshots with the portable filesystem state.
|
||||||
/// into the machine-local project row.
|
pub fn sync_metadata_from_filesystem(
|
||||||
pub fn sync_project_from_file(
|
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
) -> EngineResult<()> {
|
) -> EngineResult<()> {
|
||||||
let metadata = read_project_json(data_dir)?;
|
let project = read_project_json(data_dir)?;
|
||||||
metadata
|
project.validate().map_err(EngineError::Validation)?;
|
||||||
.validate()
|
let categories = read_categories_json(data_dir)?;
|
||||||
.map_err(crate::engine::EngineError::Validation)?;
|
let category_meta = read_category_meta_json(data_dir)?;
|
||||||
let mut project = crate::db::queries::project::get_project_by_id(conn, project_id)?;
|
let publishing = read_publishing_json(data_dir)?;
|
||||||
project.name = metadata.name;
|
|
||||||
project.description = metadata.description;
|
conn.begin_savepoint()?;
|
||||||
project.updated_at = crate::util::now_unix_ms();
|
let result = (|| {
|
||||||
crate::db::queries::project::update_project(conn, &project)?;
|
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(())
|
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 ─────────────────────────────────────────────────
|
// ── categories.json ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Read meta/categories.json as a sorted array of strings.
|
/// 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(())
|
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 ──────────────────────────────────────────────
|
// ── category-meta.json ──────────────────────────────────────────────
|
||||||
|
|
||||||
/// Read meta/category-meta.json.
|
/// Read meta/category-meta.json.
|
||||||
@@ -121,6 +321,62 @@ pub fn write_category_meta_json(
|
|||||||
Ok(())
|
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 ─────────────────────────────────────────────────
|
// ── publishing.json ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Read meta/publishing.json.
|
/// Read meta/publishing.json.
|
||||||
@@ -139,6 +395,16 @@ pub fn write_publishing_json(data_dir: &Path, prefs: &PublishingPreferences) ->
|
|||||||
Ok(())
|
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 ───────────────────────────────────────────────────────
|
// ── tags.json ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Read meta/tags.json.
|
/// Read meta/tags.json.
|
||||||
@@ -162,11 +428,15 @@ pub fn write_tags_json(data_dir: &Path, tags: &[TagEntry]) -> EngineResult<()> {
|
|||||||
// ── category helpers ────────────────────────────────────────────────
|
// ── category helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Add a category to categories.json and initialize it in category-meta.json.
|
/// 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)?;
|
let mut cats = read_categories_json(data_dir)?;
|
||||||
if !cats.iter().any(|c| c.eq_ignore_ascii_case(category)) {
|
if !cats.iter().any(|c| c.eq_ignore_ascii_case(category)) {
|
||||||
cats.push(category.to_string());
|
cats.push(category.to_string());
|
||||||
write_categories_json(data_dir, &cats)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut meta = read_category_meta_json(data_dir)?;
|
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,
|
list_template_slug: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
write_category_meta_json(data_dir, &meta)?;
|
|
||||||
}
|
}
|
||||||
|
set_categories_and_meta(conn, data_dir, project_id, &cats, &meta)
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a category from both categories.json and category-meta.json.
|
/// 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)?;
|
let mut cats = read_categories_json(data_dir)?;
|
||||||
cats.retain(|c| !c.eq_ignore_ascii_case(category));
|
cats.retain(|c| !c.eq_ignore_ascii_case(category));
|
||||||
write_categories_json(data_dir, &cats)?;
|
|
||||||
|
|
||||||
let mut meta = read_category_meta_json(data_dir)?;
|
let mut meta = read_category_meta_json(data_dir)?;
|
||||||
meta.remove(category);
|
meta.remove(category);
|
||||||
write_category_meta_json(data_dir, &meta)?;
|
set_categories_and_meta(conn, data_dir, project_id, &cats, &meta)
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── startup sync ────────────────────────────────────────────────────
|
// ── startup sync ────────────────────────────────────────────────────
|
||||||
@@ -404,11 +673,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn add_category_creates_entries() {
|
fn add_category_creates_entries() {
|
||||||
let dir = setup();
|
let dir = setup();
|
||||||
|
let db = crate::db::Database::open_in_memory().unwrap();
|
||||||
|
db.migrate().unwrap();
|
||||||
// Seed files
|
// Seed files
|
||||||
write_categories_json(dir.path(), &["article".into()]).unwrap();
|
write_categories_json(dir.path(), &["article".into()]).unwrap();
|
||||||
write_category_meta_json(dir.path(), &HashMap::new()).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();
|
let cats = read_categories_json(dir.path()).unwrap();
|
||||||
assert!(cats.contains(&"page".to_string()));
|
assert!(cats.contains(&"page".to_string()));
|
||||||
@@ -420,10 +691,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn add_category_idempotent() {
|
fn add_category_idempotent() {
|
||||||
let dir = setup();
|
let dir = setup();
|
||||||
|
let db = crate::db::Database::open_in_memory().unwrap();
|
||||||
|
db.migrate().unwrap();
|
||||||
write_categories_json(dir.path(), &["article".into()]).unwrap();
|
write_categories_json(dir.path(), &["article".into()]).unwrap();
|
||||||
write_category_meta_json(dir.path(), &HashMap::new()).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();
|
let cats = read_categories_json(dir.path()).unwrap();
|
||||||
assert_eq!(cats.iter().filter(|c| *c == "article").count(), 1);
|
assert_eq!(cats.iter().filter(|c| *c == "article").count(), 1);
|
||||||
}
|
}
|
||||||
@@ -431,6 +704,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn remove_category_deletes_entries() {
|
fn remove_category_deletes_entries() {
|
||||||
let dir = setup();
|
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();
|
write_categories_json(dir.path(), &["article".into(), "page".into()]).unwrap();
|
||||||
let mut meta = HashMap::new();
|
let mut meta = HashMap::new();
|
||||||
meta.insert(
|
meta.insert(
|
||||||
@@ -445,7 +720,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
write_category_meta_json(dir.path(), &meta).unwrap();
|
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();
|
let cats = read_categories_json(dir.path()).unwrap();
|
||||||
assert!(!cats.contains(&"article".to_string()));
|
assert!(!cats.contains(&"article".to_string()));
|
||||||
@@ -454,4 +729,26 @@ mod tests {
|
|||||||
let meta = read_category_meta_json(dir.path()).unwrap();
|
let meta = read_category_meta_json(dir.path()).unwrap();
|
||||||
assert!(!meta.contains_key("article"));
|
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::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -73,11 +73,24 @@ pub fn compute_metadata_diff(
|
|||||||
let mut report = DiffReport::default();
|
let mut report = DiffReport::default();
|
||||||
|
|
||||||
if let Ok(project) = qproject::get_project_by_id(conn, project_id) {
|
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(Some(diff)) => report.diffs.push(diff),
|
||||||
Ok(None) => {}
|
Ok(None) => {}
|
||||||
Err(error) => report.errors.push(format!("project {project_id}: {error}")),
|
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
|
// 1. Diff posts
|
||||||
@@ -220,7 +233,9 @@ pub fn repair_metadata_diff_item(
|
|||||||
RepairDirection::FileToDatabase => {
|
RepairDirection::FileToDatabase => {
|
||||||
let path = data_dir.join(&item.file_path);
|
let path = data_dir.join(&item.file_path);
|
||||||
match item.entity_type.as_str() {
|
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" => {
|
"post" => {
|
||||||
crate::engine::post::rebuild_canonical_post(conn, data_dir, project_id, &path)?;
|
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() {
|
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" => rewrite_post_from_database(conn, data_dir, &item.entity_id)?,
|
||||||
"post_translation" => {
|
"post_translation" => {
|
||||||
rewrite_post_translation_from_database(conn, data_dir, &item.entity_id)?
|
rewrite_post_translation_from_database(conn, data_dir, &item.entity_id)?
|
||||||
@@ -335,18 +352,93 @@ pub fn import_orphan_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn diff_project(
|
fn diff_project(
|
||||||
|
conn: &Connection,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
project: &crate::model::Project,
|
project: &crate::model::Project,
|
||||||
) -> EngineResult<Option<EntityDiff>> {
|
) -> EngineResult<Option<EntityDiff>> {
|
||||||
let metadata = crate::engine::meta::read_project_json(data_dir)?;
|
let metadata = crate::engine::meta::read_project_json(data_dir)?;
|
||||||
let mut fields = Vec::new();
|
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(
|
compare_field(
|
||||||
&mut fields,
|
&mut fields,
|
||||||
"description",
|
"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(""),
|
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 {
|
Ok((!fields.is_empty()).then_some(EntityDiff {
|
||||||
entity_type: "project".into(),
|
entity_type: "project".into(),
|
||||||
entity_id: project.id.clone(),
|
entity_id: project.id.clone(),
|
||||||
@@ -355,24 +447,96 @@ fn diff_project(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_project_from_file(
|
fn diff_categories(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
) -> EngineResult<()> {
|
) -> EngineResult<Option<EntityDiff>> {
|
||||||
crate::engine::meta::sync_project_from_file(conn, data_dir, project_id)
|
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,
|
conn: &Connection,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
) -> EngineResult<()> {
|
) -> EngineResult<Option<EntityDiff>> {
|
||||||
let project = qproject::get_project_by_id(conn, project_id)?;
|
let Some(database) = crate::engine::meta::read_category_meta_snapshot(conn, project_id)? else {
|
||||||
let mut metadata = crate::engine::meta::read_project_json(data_dir)?;
|
return Ok(None);
|
||||||
metadata.name = project.name;
|
};
|
||||||
metadata.description = project.description;
|
let filesystem = crate::engine::meta::read_category_meta_json(data_dir)?;
|
||||||
crate::engine::meta::write_project_json(data_dir, &metadata)
|
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> {
|
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>> {
|
fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
|
||||||
let abs_path = data_dir.join(&post.file_path);
|
let abs_path = data_dir.join(&post.file_path);
|
||||||
if !abs_path.exists() {
|
if !abs_path.exists() {
|
||||||
@@ -1037,8 +1226,8 @@ mod tests {
|
|||||||
use crate::db::queries::template::insert_template;
|
use crate::db::queries::template::insert_template;
|
||||||
use crate::engine::post::{archive_post, create_post, publish_post};
|
use crate::engine::post::{archive_post, create_post, publish_post};
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
Media, Post, PostStatus, ProjectMetadata, Script, ScriptKind, ScriptStatus, Template,
|
Media, Post, PostStatus, ProjectMetadata, PublishingPreferences, Script, ScriptKind,
|
||||||
TemplateKind, TemplateStatus,
|
ScriptStatus, SshMode, Template, TemplateKind, TemplateStatus,
|
||||||
};
|
};
|
||||||
use crate::util::frontmatter::{
|
use crate::util::frontmatter::{
|
||||||
ScriptFrontmatter, TemplateFrontmatter, write_script_file, write_template_file,
|
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]
|
#[test]
|
||||||
fn ignores_python_scripts_during_orphan_scan() {
|
fn ignores_python_scripts_during_orphan_scan() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ pub fn create_project(
|
|||||||
|
|
||||||
// Write default meta files
|
// Write default meta files
|
||||||
write_default_meta_files(&data_dir, name)?;
|
write_default_meta_files(&data_dir, name)?;
|
||||||
|
crate::engine::meta::sync_metadata_from_filesystem(conn, &data_dir, &project.id)?;
|
||||||
|
|
||||||
emit_project(&project, NotificationAction::Created);
|
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.data_path = Some(resolved_path);
|
||||||
project.updated_at = now_unix_ms();
|
project.updated_at = now_unix_ms();
|
||||||
q::update_project(conn, &project)?;
|
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);
|
emit_project(&project, NotificationAction::Updated);
|
||||||
return Ok(project);
|
return Ok(project);
|
||||||
}
|
}
|
||||||
@@ -141,6 +144,8 @@ pub fn open_project(conn: &Connection, folder_path: &Path) -> EngineResult<Proje
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
q::insert_project(conn, &project)?;
|
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);
|
emit_project(&project, NotificationAction::Created);
|
||||||
Ok(project)
|
Ok(project)
|
||||||
}
|
}
|
||||||
@@ -153,7 +158,13 @@ pub fn ensure_default_project(
|
|||||||
default_data_dir: Option<&Path>,
|
default_data_dir: Option<&Path>,
|
||||||
) -> EngineResult<Project> {
|
) -> EngineResult<Project> {
|
||||||
match q::get_project_by_id(conn, DEFAULT_PROJECT_ID) {
|
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) => {
|
Err(diesel::result::Error::NotFound) => {
|
||||||
let now = now_unix_ms();
|
let now = now_unix_ms();
|
||||||
let project = Project {
|
let project = Project {
|
||||||
@@ -174,6 +185,7 @@ pub fn ensure_default_project(
|
|||||||
};
|
};
|
||||||
create_directory_structure(&data_dir)?;
|
create_directory_structure(&data_dir)?;
|
||||||
write_default_meta_files(&data_dir, "My Blog")?;
|
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);
|
emit_project(&project, NotificationAction::Created);
|
||||||
Ok(project)
|
Ok(project)
|
||||||
}
|
}
|
||||||
@@ -237,7 +249,22 @@ pub fn delete_project(
|
|||||||
.map_err(|_| EngineError::NotFound(format!("project {project_id}")))?;
|
.map_err(|_| EngineError::NotFound(format!("project {project_id}")))?;
|
||||||
let is_custom_path = project.data_path.is_some();
|
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);
|
crate::engine::embedding::EmbeddingService::forget_project(project_id);
|
||||||
|
|
||||||
// Clean up internal filesystem only (not custom external paths per spec)
|
// Clean up internal filesystem only (not custom external paths per spec)
|
||||||
@@ -492,12 +519,26 @@ mod tests {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
crate::db::queries::project::insert_project(db.conn(), &project).unwrap();
|
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
|
// Create the internal directory structure under the temp dir
|
||||||
let internal_dir = dir.path().join("projects").join(&project.id);
|
let internal_dir = dir.path().join("projects").join(&project.id);
|
||||||
create_directory_structure(&internal_dir).unwrap();
|
create_directory_structure(&internal_dir).unwrap();
|
||||||
assert!(internal_dir.join("posts").is_dir());
|
assert!(internal_dir.join("posts").is_dir());
|
||||||
delete_project(db.conn(), &project.id, Some(&internal_dir)).unwrap();
|
delete_project(db.conn(), &project.id, Some(&internal_dir)).unwrap();
|
||||||
assert!(list_projects(db.conn()).unwrap().is_empty());
|
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!(
|
assert!(
|
||||||
!internal_dir.exists(),
|
!internal_dir.exists(),
|
||||||
"internal directory should be cleaned up"
|
"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.
|
// 1. Load portable project metadata and clear all reconstructible rows.
|
||||||
progress(0.0, "Loading project metadata...");
|
progress(0.0, "Loading project metadata...");
|
||||||
fts::ensure_fts_tables(conn)?;
|
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)?;
|
clear_project_rows(conn, project_id)?;
|
||||||
|
|
||||||
// 2. Rebuild posts (0.00 .. 0.35)
|
// 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();
|
let project = crate::db::queries::project::get_project_by_id(db.conn(), "p1").unwrap();
|
||||||
assert_eq!(project.name, "Rebuilt Project");
|
assert_eq!(project.name, "Rebuilt Project");
|
||||||
assert!(project.description.is_none());
|
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 =
|
let links =
|
||||||
crate::db::queries::post_media::list_post_media_by_media(db.conn(), "test-media-1")
|
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)? {
|
for value in setting::list_all_settings(conn)? {
|
||||||
if !value.key.starts_with("app.")
|
if !value.key.starts_with("app.")
|
||||||
|
&& !value.key.starts_with("project:")
|
||||||
&& value.key != ONLINE_API_KEY
|
&& value.key != ONLINE_API_KEY
|
||||||
&& value.key != ONLINE_API_KEY_CONFIGURED
|
&& value.key != ONLINE_API_KEY_CONFIGURED
|
||||||
&& value.key != AIRPLANE_API_KEY
|
&& value.key != AIRPLANE_API_KEY
|
||||||
@@ -162,4 +163,23 @@ mod tests {
|
|||||||
let db = Database::open(&path).unwrap();
|
let db = Database::open(&path).unwrap();
|
||||||
assert!(airplane_mode(db.conn()).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
|
false
|
||||||
} else {
|
} else {
|
||||||
match item.kind {
|
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 => {
|
TaxonomyKind::Tag => {
|
||||||
tag::create_tag(conn, data_dir, project_id, &item.name, None)?;
|
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.
|
/// Publishing preferences stored in meta/publishing.json.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PublishingPreferences {
|
pub struct PublishingPreferences {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ProjectMetadata {
|
pub struct ProjectMetadata {
|
||||||
pub name: String,
|
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")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CategorySettings {
|
pub struct CategorySettings {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ impl CoreHost {
|
|||||||
match method {
|
match method {
|
||||||
"get_project_metadata" => public_metadata(&self.data_dir),
|
"get_project_metadata" => public_metadata(&self.data_dir),
|
||||||
"update_project_metadata" | "set_project_metadata" => {
|
"update_project_metadata" | "set_project_metadata" => {
|
||||||
|
let db = self.database()?;
|
||||||
let mut metadata = engine::meta::read_project_json(&self.data_dir)?;
|
let mut metadata = engine::meta::read_project_json(&self.data_dir)?;
|
||||||
let updates = object_arg(args, 0)?;
|
let updates = object_arg(args, 0)?;
|
||||||
assign_string(updates, "name", &mut metadata.name);
|
assign_string(updates, "name", &mut metadata.name);
|
||||||
@@ -218,15 +219,33 @@ impl CoreHost {
|
|||||||
metadata.blog_languages = languages;
|
metadata.blog_languages = languages;
|
||||||
}
|
}
|
||||||
metadata.validate().map_err(text)?;
|
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)
|
public_metadata(&self.data_dir)
|
||||||
}
|
}
|
||||||
"add_category" => {
|
"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)
|
public_metadata(&self.data_dir)
|
||||||
}
|
}
|
||||||
"remove_category" => {
|
"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)
|
public_metadata(&self.data_dir)
|
||||||
}
|
}
|
||||||
"add_tag" => self.meta_tag(string_arg(args, 0)?, true),
|
"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))
|
json_value(engine::meta::read_publishing_json(&self.data_dir))
|
||||||
}
|
}
|
||||||
"set_publishing_preferences" => {
|
"set_publishing_preferences" => {
|
||||||
|
let db = self.database()?;
|
||||||
let prefs = serde_json::from_value(Value::Object(object_arg(args, 0)?.clone()))
|
let prefs = serde_json::from_value(Value::Object(object_arg(args, 0)?.clone()))
|
||||||
.map_err(text)?;
|
.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))
|
json_value(engine::meta::read_publishing_json(&self.data_dir))
|
||||||
}
|
}
|
||||||
"clear_publishing_preferences" => {
|
"clear_publishing_preferences" => {
|
||||||
|
let db = self.database()?;
|
||||||
let prefs = Default::default();
|
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))
|
json_value(Ok::<_, EngineError>(prefs))
|
||||||
}
|
}
|
||||||
"sync_on_startup" => {
|
"sync_on_startup" => {
|
||||||
|
let db = self.database()?;
|
||||||
engine::meta::startup_sync(&self.data_dir)?;
|
engine::meta::startup_sync(&self.data_dir)?;
|
||||||
|
engine::meta::initialize_metadata_snapshots(
|
||||||
|
db.conn(),
|
||||||
|
&self.data_dir,
|
||||||
|
&self.project_id,
|
||||||
|
)?;
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"metadata": public_metadata(&self.data_dir)?,
|
"metadata": public_metadata(&self.data_dir)?,
|
||||||
"categories": engine::meta::read_categories_json(&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]
|
#[test]
|
||||||
fn analysis_converts_html_and_shortcodes_and_classifies_every_status() {
|
fn analysis_converts_html_and_shortcodes_and_classifies_every_status() {
|
||||||
let (db, dir, project) = setup();
|
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();
|
tag::create_tag(db.conn(), dir.path(), &project.id, "nEWs", None).unwrap();
|
||||||
|
|
||||||
let update = post::create_post(
|
let update = post::create_post(
|
||||||
|
|||||||
@@ -352,6 +352,15 @@ impl ApplicationSession {
|
|||||||
format!("project '{}' data is unavailable", value.name),
|
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 {
|
self.selected_project = Some(SelectedProject {
|
||||||
id: value.id.clone(),
|
id: value.id.clone(),
|
||||||
data_dir,
|
data_dir,
|
||||||
@@ -437,8 +446,8 @@ mod tests {
|
|||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let database_path = root.path().join("bds.db");
|
let database_path = root.path().join("bds.db");
|
||||||
let data_root = root.path().join("data");
|
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();
|
let db = Database::open(&database_path).unwrap();
|
||||||
|
db.migrate().unwrap();
|
||||||
let project_dir = root.path().join("blog");
|
let project_dir = root.path().join("blog");
|
||||||
let value = project::create_project(
|
let value = project::create_project(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
@@ -447,6 +456,8 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
settings::set(db.conn(), settings::UI_LANGUAGE_KEY, "de").unwrap();
|
settings::set(db.conn(), settings::UI_LANGUAGE_KEY, "de").unwrap();
|
||||||
|
drop(db);
|
||||||
|
let host = ApplicationHost::start(database_path, data_root).unwrap();
|
||||||
Self {
|
Self {
|
||||||
_root: root,
|
_root: root,
|
||||||
host,
|
host,
|
||||||
@@ -479,6 +490,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn session_negotiates_server_locale_and_selects_a_project() {
|
fn session_negotiates_server_locale_and_selects_a_project() {
|
||||||
let fixture = Fixture::new();
|
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();
|
let mut session = fixture.host.session().unwrap();
|
||||||
assert_eq!(session.locale(), "de");
|
assert_eq!(session.locale(), "de");
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -495,6 +512,11 @@ mod tests {
|
|||||||
},
|
},
|
||||||
));
|
));
|
||||||
assert!(matches!(opened, ServerMessage::Response { .. }));
|
assert!(matches!(opened, ServerMessage::Response { .. }));
|
||||||
|
assert!(
|
||||||
|
bds_core::engine::meta::read_categories_snapshot(db.conn(), &fixture.project_id)
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -377,6 +377,10 @@ impl TuiApp {
|
|||||||
let data_dir = project
|
let data_dir = project
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|project| host.project_data_dir(project));
|
.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
|
let started_task_ids = host
|
||||||
.tasks()
|
.tasks()
|
||||||
.snapshots()
|
.snapshots()
|
||||||
@@ -2218,8 +2222,12 @@ impl TuiApp {
|
|||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| anyhow!(self.tr("tui.imageImportConcurrencyInvalid")))?;
|
.map_err(|_| anyhow!(self.tr("tui.imageImportConcurrencyInvalid")))?;
|
||||||
metadata.validate().map_err(|error| anyhow!(error))?;
|
metadata.validate().map_err(|error| anyhow!(error))?;
|
||||||
bds_core::db::queries::project::update_project(db.conn(), &project)?;
|
engine::meta::update_project_metadata(
|
||||||
engine::meta::write_project_json(self.data_dir()?, &metadata)?;
|
db.conn(),
|
||||||
|
self.data_dir()?,
|
||||||
|
&project,
|
||||||
|
&metadata,
|
||||||
|
)?;
|
||||||
self.project = Some(project);
|
self.project = Some(project);
|
||||||
if let Some(value) = self.setting_field(engine::settings::UI_LANGUAGE_KEY) {
|
if let Some(value) = self.setting_field(engine::settings::UI_LANGUAGE_KEY) {
|
||||||
engine::settings::set(db.conn(), value.key, &value.value)?;
|
engine::settings::set(db.conn(), value.key, &value.value)?;
|
||||||
@@ -2235,7 +2243,16 @@ impl TuiApp {
|
|||||||
metadata.semantic_similarity_enabled = self
|
metadata.semantic_similarity_enabled = self
|
||||||
.setting_value("meta.semantic_similarity_enabled")
|
.setting_value("meta.semantic_similarity_enabled")
|
||||||
.eq_ignore_ascii_case("true");
|
.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 => {
|
SettingSection::Publishing => {
|
||||||
let preferences = PublishingPreferences {
|
let preferences = PublishingPreferences {
|
||||||
@@ -2250,7 +2267,12 @@ impl TuiApp {
|
|||||||
SshMode::Scp
|
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 => {
|
SettingSection::Mcp => {
|
||||||
engine::settings::set(
|
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) {
|
fn wait_for(app: &mut TuiApp, predicate: impl Fn(&TuiApp) -> bool) {
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
while !predicate(app) && started.elapsed() < Duration::from_secs(2) {
|
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.operation_failed_text("common.metadataSync", e.to_string());
|
||||||
self.add_output(&message);
|
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
|
// Extract content language from project metadata
|
||||||
if let Ok(meta) = engine::meta::read_project_json(&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());
|
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) {
|
match engine::project::set_active_project(db.conn(), &project_id) {
|
||||||
Ok(()) => {
|
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.reset_git_for_project_change();
|
||||||
self.active_project =
|
self.active_project =
|
||||||
self.projects.iter().find(|p| p.id == project_id).cloned();
|
self.projects.iter().find(|p| p.id == project_id).cloned();
|
||||||
@@ -1760,7 +1785,6 @@ impl BdsApp {
|
|||||||
.map(PathBuf::from);
|
.map(PathBuf::from);
|
||||||
// Per metadata.allium StartupSync
|
// Per metadata.allium StartupSync
|
||||||
if let Some(data_dir) = self.data_dir.clone() {
|
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) {
|
if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
|
||||||
let main_lang =
|
let main_lang =
|
||||||
meta.main_language.unwrap_or_else(|| "en".to_string());
|
meta.main_language.unwrap_or_else(|| "en".to_string());
|
||||||
@@ -4779,8 +4803,10 @@ impl BdsApp {
|
|||||||
let previous = self.menu_editor_state.clone();
|
let previous = self.menu_editor_state.clone();
|
||||||
if let Ok((name, is_new)) = self.menu_editor_state.submit_category()
|
if let Ok((name, is_new)) = self.menu_editor_state.submit_category()
|
||||||
&& is_new
|
&& is_new
|
||||||
|
&& let (Some(db), Some(project)) = (&self.db, &self.active_project)
|
||||||
&& let Some(data_dir) = &self.data_dir
|
&& 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.menu_editor_state = previous;
|
||||||
self.notify(
|
self.notify(
|
||||||
@@ -7826,8 +7852,15 @@ impl BdsApp {
|
|||||||
);
|
);
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
if let Some(data_dir) = &self.data_dir {
|
if let (Some(db), Some(data_dir), Some(project)) =
|
||||||
match engine::meta::add_category(data_dir, category_name) {
|
(&self.db, &self.data_dir, &self.active_project)
|
||||||
|
{
|
||||||
|
match engine::meta::add_category(
|
||||||
|
db.conn(),
|
||||||
|
data_dir,
|
||||||
|
&project.id,
|
||||||
|
category_name,
|
||||||
|
) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.settings_state = Some(self.hydrate_settings_state());
|
self.settings_state = Some(self.hydrate_settings_state());
|
||||||
if let Some(state) = self.settings_state.as_mut() {
|
if let Some(state) = self.settings_state.as_mut() {
|
||||||
@@ -7866,7 +7899,8 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsMsg::SaveCategory(name) => {
|
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 Some(row) = state.categories.iter().find(|row| row.name == name)
|
||||||
{
|
{
|
||||||
let mut category_meta =
|
let mut category_meta =
|
||||||
@@ -7883,7 +7917,12 @@ impl BdsApp {
|
|||||||
.then(|| row.list_template_slug.clone()),
|
.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(()) => {
|
Ok(()) => {
|
||||||
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
||||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||||
@@ -7893,8 +7932,10 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsMsg::RemoveCategory(name) => {
|
SettingsMsg::RemoveCategory(name) => {
|
||||||
if let Some(data_dir) = &self.data_dir {
|
if let (Some(db), Some(data_dir), Some(project)) =
|
||||||
match engine::meta::remove_category(data_dir, &name) {
|
(&self.db, &self.data_dir, &self.active_project)
|
||||||
|
{
|
||||||
|
match engine::meta::remove_category(db.conn(), data_dir, &project.id, &name) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.settings_state = Some(self.hydrate_settings_state());
|
self.settings_state = Some(self.hydrate_settings_state());
|
||||||
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
||||||
@@ -7905,7 +7946,9 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsMsg::ResetCategoriesToDefaults => {
|
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()
|
let default_names = default_category_rows()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| row.name)
|
.map(|row| row.name)
|
||||||
@@ -7926,16 +7969,19 @@ impl BdsApp {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<HashMap<_, _>>();
|
.collect::<HashMap<_, _>>();
|
||||||
match (
|
match engine::meta::set_categories_and_meta(
|
||||||
engine::meta::write_categories_json(data_dir, &default_names),
|
db.conn(),
|
||||||
engine::meta::write_category_meta_json(data_dir, &default_meta),
|
data_dir,
|
||||||
|
&project.id,
|
||||||
|
&default_names,
|
||||||
|
&default_meta,
|
||||||
) {
|
) {
|
||||||
(Ok(()), Ok(())) => {
|
Ok(()) => {
|
||||||
self.settings_state = Some(self.hydrate_settings_state());
|
self.settings_state = Some(self.hydrate_settings_state());
|
||||||
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
||||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
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;
|
state.ssh_remote_path = s;
|
||||||
}
|
}
|
||||||
SettingsMsg::SavePublishing => {
|
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 {
|
let prefs = PublishingPreferences {
|
||||||
ssh_host: if state.ssh_host.trim().is_empty() {
|
ssh_host: if state.ssh_host.trim().is_empty() {
|
||||||
None
|
None
|
||||||
@@ -7975,7 +8023,12 @@ impl BdsApp {
|
|||||||
SshMode::Rsync
|
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(()) => {
|
Ok(()) => {
|
||||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"))
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,3 +155,34 @@ fn message_card(value: String) -> Element<'static, Message> {
|
|||||||
)
|
)
|
||||||
.into()
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ value DiffField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
value DiffReport {
|
value DiffReport {
|
||||||
entity_type: String -- post, post_translation, media,
|
entity_type: String -- project, categories, category_meta, publishing,
|
||||||
-- media_translation, script, template, embedding
|
-- post, post_translation, media, media_translation,
|
||||||
|
-- script, template, embedding
|
||||||
entity_id: String
|
entity_id: String
|
||||||
differences: List<DiffField>
|
differences: List<DiffField>
|
||||||
}
|
}
|
||||||
@@ -44,6 +45,20 @@ rule RunMetadataDiff {
|
|||||||
-- translations, media, Lua scripts, templates
|
-- translations, media, Lua scripts, templates
|
||||||
-- Detected fields: tags, categories, title, excerpt, author,
|
-- Detected fields: tags, categories, title, excerpt, author,
|
||||||
-- language, status, templateSlug, dates
|
-- language, status, templateSlug, dates
|
||||||
|
-- Portable project metadata has a project-scoped database snapshot in the
|
||||||
|
-- settings table. Compare its project, category list, per-category settings,
|
||||||
|
-- and publishing preferences against the matching meta/*.json files.
|
||||||
|
for metadata_type in {"project", "categories", "category_meta", "publishing"}:
|
||||||
|
let db_metadata = ProjectMetadataSetting(project.id, metadata_type)
|
||||||
|
let file_metadata = ReadProjectMetadataFile(project, metadata_type)
|
||||||
|
let metadata_diffs = compare_fields(db_metadata, file_metadata)
|
||||||
|
if metadata_diffs.count > 0:
|
||||||
|
ensures:
|
||||||
|
DiffReport.created(
|
||||||
|
entity_type: metadata_type,
|
||||||
|
entity_id: project.id,
|
||||||
|
differences: metadata_diffs
|
||||||
|
)
|
||||||
for post in project.posts:
|
for post in project.posts:
|
||||||
let file_data = parse_post_file(post.file_path)
|
let file_data = parse_post_file(post.file_path)
|
||||||
let diffs = compare_fields(post, file_data)
|
let diffs = compare_fields(post, file_data)
|
||||||
@@ -83,6 +98,7 @@ rule RebuildDatabase {
|
|||||||
-- project.json, posts, translations, media sidecars and links, tags,
|
-- project.json, posts, translations, media sidecars and links, tags,
|
||||||
-- templates, and Lua scripts are imported. Python files in scripts/ are ignored.
|
-- templates, and Lua scripts are imported. Python files in scripts/ are ignored.
|
||||||
ensures: ProjectMetadataImported(project, "meta/project.json")
|
ensures: ProjectMetadataImported(project, "meta/project.json")
|
||||||
|
ensures: ProjectMetadataSettingsImported(project, "meta/*.json")
|
||||||
ensures: post/RebuildPostsFromFiles(project)
|
ensures: post/RebuildPostsFromFiles(project)
|
||||||
ensures: media/RebuildMediaFromFiles(project)
|
ensures: media/RebuildMediaFromFiles(project)
|
||||||
ensures: script/RebuildScriptsFromFiles(project)
|
ensures: script/RebuildScriptsFromFiles(project)
|
||||||
|
|||||||
Reference in New Issue
Block a user