Files
RuDS/crates/bds-core/src/engine/meta.rs
2026-07-21 11:20:07 +02:00

458 lines
17 KiB
Rust

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