feat: implementation of M1
This commit is contained in:
619
crates/bds-core/src/db/from_row.rs
Normal file
619
crates/bds-core/src/db/from_row.rs
Normal file
@@ -0,0 +1,619 @@
|
||||
use rusqlite::Row;
|
||||
|
||||
use crate::model::{
|
||||
DbNotification, GeneratedFileHash, Media, MediaTranslation, NotificationAction,
|
||||
NotificationEntity, Post, PostLink, PostMedia, PostStatus, PostTranslation, Project, Script,
|
||||
ScriptKind, ScriptStatus, Setting, Tag, Template, TemplateKind, TemplateStatus,
|
||||
};
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn conversion_err(msg: String) -> rusqlite::Error {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, msg)),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_post_status(s: &str) -> rusqlite::Result<PostStatus> {
|
||||
match s {
|
||||
"draft" => Ok(PostStatus::Draft),
|
||||
"published" => Ok(PostStatus::Published),
|
||||
"archived" => Ok(PostStatus::Archived),
|
||||
_ => Err(conversion_err(format!("invalid PostStatus: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_template_kind(s: &str) -> rusqlite::Result<TemplateKind> {
|
||||
match s {
|
||||
"post" => Ok(TemplateKind::Post),
|
||||
"list" => Ok(TemplateKind::List),
|
||||
"not_found" => Ok(TemplateKind::NotFound),
|
||||
"partial" => Ok(TemplateKind::Partial),
|
||||
_ => Err(conversion_err(format!("invalid TemplateKind: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_template_status(s: &str) -> rusqlite::Result<TemplateStatus> {
|
||||
match s {
|
||||
"draft" => Ok(TemplateStatus::Draft),
|
||||
"published" => Ok(TemplateStatus::Published),
|
||||
_ => Err(conversion_err(format!("invalid TemplateStatus: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_script_kind(s: &str) -> rusqlite::Result<ScriptKind> {
|
||||
match s {
|
||||
"macro" => Ok(ScriptKind::Macro),
|
||||
"utility" => Ok(ScriptKind::Utility),
|
||||
"transform" => Ok(ScriptKind::Transform),
|
||||
_ => Err(conversion_err(format!("invalid ScriptKind: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_script_status(s: &str) -> rusqlite::Result<ScriptStatus> {
|
||||
match s {
|
||||
"draft" => Ok(ScriptStatus::Draft),
|
||||
"published" => Ok(ScriptStatus::Published),
|
||||
_ => Err(conversion_err(format!("invalid ScriptStatus: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_notification_entity(s: &str) -> rusqlite::Result<NotificationEntity> {
|
||||
match s {
|
||||
"post" => Ok(NotificationEntity::Post),
|
||||
"media" => Ok(NotificationEntity::Media),
|
||||
"script" => Ok(NotificationEntity::Script),
|
||||
"template" => Ok(NotificationEntity::Template),
|
||||
_ => Err(conversion_err(format!("invalid NotificationEntity: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_notification_action(s: &str) -> rusqlite::Result<NotificationAction> {
|
||||
match s {
|
||||
"created" => Ok(NotificationAction::Created),
|
||||
"updated" => Ok(NotificationAction::Updated),
|
||||
"deleted" => Ok(NotificationAction::Deleted),
|
||||
_ => Err(conversion_err(format!("invalid NotificationAction: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_to_vec_string(s: &str) -> rusqlite::Result<Vec<String>> {
|
||||
serde_json::from_str(s).map_err(|e| conversion_err(format!("JSON parse error: {e}")))
|
||||
}
|
||||
|
||||
fn bool_from_i64(v: i64) -> bool {
|
||||
v != 0
|
||||
}
|
||||
|
||||
// ── enum → DB string (for INSERT / UPDATE) ───────────────────────────
|
||||
|
||||
pub fn post_status_to_str(s: &PostStatus) -> &'static str {
|
||||
match s {
|
||||
PostStatus::Draft => "draft",
|
||||
PostStatus::Published => "published",
|
||||
PostStatus::Archived => "archived",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn template_kind_to_str(k: &TemplateKind) -> &'static str {
|
||||
match k {
|
||||
TemplateKind::Post => "post",
|
||||
TemplateKind::List => "list",
|
||||
TemplateKind::NotFound => "not_found",
|
||||
TemplateKind::Partial => "partial",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn template_status_to_str(s: &TemplateStatus) -> &'static str {
|
||||
match s {
|
||||
TemplateStatus::Draft => "draft",
|
||||
TemplateStatus::Published => "published",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn script_kind_to_str(k: &ScriptKind) -> &'static str {
|
||||
match k {
|
||||
ScriptKind::Macro => "macro",
|
||||
ScriptKind::Utility => "utility",
|
||||
ScriptKind::Transform => "transform",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn script_status_to_str(s: &ScriptStatus) -> &'static str {
|
||||
match s {
|
||||
ScriptStatus::Draft => "draft",
|
||||
ScriptStatus::Published => "published",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notification_entity_to_str(e: &NotificationEntity) -> &'static str {
|
||||
match e {
|
||||
NotificationEntity::Post => "post",
|
||||
NotificationEntity::Media => "media",
|
||||
NotificationEntity::Script => "script",
|
||||
NotificationEntity::Template => "template",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notification_action_to_str(a: &NotificationAction) -> &'static str {
|
||||
match a {
|
||||
NotificationAction::Created => "created",
|
||||
NotificationAction::Updated => "updated",
|
||||
NotificationAction::Deleted => "deleted",
|
||||
}
|
||||
}
|
||||
|
||||
// ── column lists (keep in sync with from_row functions) ──────────────
|
||||
|
||||
pub const PROJECT_COLUMNS: &str =
|
||||
"id, name, slug, description, data_path, is_active, created_at, updated_at";
|
||||
|
||||
pub const POST_COLUMNS: &str = "\
|
||||
id, project_id, title, slug, excerpt, content, status, author, \
|
||||
language, do_not_translate, template_slug, file_path, checksum, \
|
||||
tags, categories, \
|
||||
published_title, published_content, published_tags, \
|
||||
published_categories, published_excerpt, \
|
||||
created_at, updated_at, published_at";
|
||||
|
||||
pub const POST_TRANSLATION_COLUMNS: &str = "\
|
||||
id, project_id, translation_for, language, title, excerpt, content, \
|
||||
status, file_path, checksum, created_at, updated_at, published_at";
|
||||
|
||||
pub const POST_LINK_COLUMNS: &str =
|
||||
"id, source_post_id, target_post_id, link_text, created_at";
|
||||
|
||||
pub const POST_MEDIA_COLUMNS: &str =
|
||||
"id, project_id, post_id, media_id, sort_order, created_at";
|
||||
|
||||
pub const MEDIA_COLUMNS: &str = "\
|
||||
id, project_id, filename, original_name, mime_type, size, \
|
||||
width, height, title, alt, caption, author, language, \
|
||||
file_path, sidecar_path, checksum, tags, created_at, updated_at";
|
||||
|
||||
pub const MEDIA_TRANSLATION_COLUMNS: &str =
|
||||
"id, project_id, translation_for, language, title, alt, caption, created_at, updated_at";
|
||||
|
||||
pub const TAG_COLUMNS: &str =
|
||||
"id, project_id, name, color, post_template_slug, created_at, updated_at";
|
||||
|
||||
pub const TEMPLATE_COLUMNS: &str = "\
|
||||
id, project_id, slug, title, kind, enabled, version, \
|
||||
file_path, status, content, created_at, updated_at";
|
||||
|
||||
pub const SCRIPT_COLUMNS: &str = "\
|
||||
id, project_id, slug, title, kind, entrypoint, enabled, version, \
|
||||
file_path, status, content, created_at, updated_at";
|
||||
|
||||
pub const SETTING_COLUMNS: &str = "key, value, updated_at";
|
||||
|
||||
pub const GENERATED_FILE_HASH_COLUMNS: &str =
|
||||
"project_id, relative_path, content_hash, updated_at";
|
||||
|
||||
pub const DB_NOTIFICATION_COLUMNS: &str =
|
||||
"id, entity_type, entity_id, action, from_cli, seen_at, created_at";
|
||||
|
||||
// ── from_row functions ───────────────────────────────────────────────
|
||||
|
||||
pub fn project_from_row(row: &Row) -> rusqlite::Result<Project> {
|
||||
Ok(Project {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
slug: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
data_path: row.get(4)?,
|
||||
is_active: bool_from_i64(row.get(5)?),
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn post_from_row(row: &Row) -> rusqlite::Result<Post> {
|
||||
let status_str: String = row.get(6)?;
|
||||
let tags_json: String = row.get(13)?;
|
||||
let categories_json: String = row.get(14)?;
|
||||
Ok(Post {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
slug: row.get(3)?,
|
||||
excerpt: row.get(4)?,
|
||||
content: row.get(5)?,
|
||||
status: parse_post_status(&status_str)?,
|
||||
author: row.get(7)?,
|
||||
language: row.get(8)?,
|
||||
do_not_translate: bool_from_i64(row.get(9)?),
|
||||
template_slug: row.get(10)?,
|
||||
file_path: row.get(11)?,
|
||||
checksum: row.get(12)?,
|
||||
tags: json_to_vec_string(&tags_json)?,
|
||||
categories: json_to_vec_string(&categories_json)?,
|
||||
published_title: row.get(15)?,
|
||||
published_content: row.get(16)?,
|
||||
published_tags: row.get(17)?,
|
||||
published_categories: row.get(18)?,
|
||||
published_excerpt: row.get(19)?,
|
||||
created_at: row.get(20)?,
|
||||
updated_at: row.get(21)?,
|
||||
published_at: row.get(22)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn post_translation_from_row(row: &Row) -> rusqlite::Result<PostTranslation> {
|
||||
let status_str: String = row.get(7)?;
|
||||
Ok(PostTranslation {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
translation_for: row.get(2)?,
|
||||
language: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
excerpt: row.get(5)?,
|
||||
content: row.get(6)?,
|
||||
status: parse_post_status(&status_str)?,
|
||||
file_path: row.get(8)?,
|
||||
checksum: row.get(9)?,
|
||||
created_at: row.get(10)?,
|
||||
updated_at: row.get(11)?,
|
||||
published_at: row.get(12)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn post_link_from_row(row: &Row) -> rusqlite::Result<PostLink> {
|
||||
Ok(PostLink {
|
||||
id: row.get(0)?,
|
||||
source_post_id: row.get(1)?,
|
||||
target_post_id: row.get(2)?,
|
||||
link_text: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn post_media_from_row(row: &Row) -> rusqlite::Result<PostMedia> {
|
||||
Ok(PostMedia {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
post_id: row.get(2)?,
|
||||
media_id: row.get(3)?,
|
||||
sort_order: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn media_from_row(row: &Row) -> rusqlite::Result<Media> {
|
||||
let tags_json: String = row.get(16)?;
|
||||
Ok(Media {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
original_name: row.get(3)?,
|
||||
mime_type: row.get(4)?,
|
||||
size: row.get(5)?,
|
||||
width: row.get(6)?,
|
||||
height: row.get(7)?,
|
||||
title: row.get(8)?,
|
||||
alt: row.get(9)?,
|
||||
caption: row.get(10)?,
|
||||
author: row.get(11)?,
|
||||
language: row.get(12)?,
|
||||
file_path: row.get(13)?,
|
||||
sidecar_path: row.get(14)?,
|
||||
checksum: row.get(15)?,
|
||||
tags: json_to_vec_string(&tags_json)?,
|
||||
created_at: row.get(17)?,
|
||||
updated_at: row.get(18)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn media_translation_from_row(row: &Row) -> rusqlite::Result<MediaTranslation> {
|
||||
Ok(MediaTranslation {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
translation_for: row.get(2)?,
|
||||
language: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
alt: row.get(5)?,
|
||||
caption: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tag_from_row(row: &Row) -> rusqlite::Result<Tag> {
|
||||
Ok(Tag {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
color: row.get(3)?,
|
||||
post_template_slug: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn template_from_row(row: &Row) -> rusqlite::Result<Template> {
|
||||
let kind_str: String = row.get(4)?;
|
||||
let status_str: String = row.get(8)?;
|
||||
Ok(Template {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
slug: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
kind: parse_template_kind(&kind_str)?,
|
||||
enabled: bool_from_i64(row.get(5)?),
|
||||
version: row.get(6)?,
|
||||
file_path: row.get(7)?,
|
||||
status: parse_template_status(&status_str)?,
|
||||
content: row.get(9)?,
|
||||
created_at: row.get(10)?,
|
||||
updated_at: row.get(11)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn script_from_row(row: &Row) -> rusqlite::Result<Script> {
|
||||
let kind_str: String = row.get(4)?;
|
||||
let status_str: String = row.get(9)?;
|
||||
Ok(Script {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
slug: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
kind: parse_script_kind(&kind_str)?,
|
||||
entrypoint: row.get(5)?,
|
||||
enabled: bool_from_i64(row.get(6)?),
|
||||
version: row.get(7)?,
|
||||
file_path: row.get(8)?,
|
||||
status: parse_script_status(&status_str)?,
|
||||
content: row.get(10)?,
|
||||
created_at: row.get(11)?,
|
||||
updated_at: row.get(12)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn setting_from_row(row: &Row) -> rusqlite::Result<Setting> {
|
||||
Ok(Setting {
|
||||
key: row.get(0)?,
|
||||
value: row.get(1)?,
|
||||
updated_at: row.get(2)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generated_file_hash_from_row(row: &Row) -> rusqlite::Result<GeneratedFileHash> {
|
||||
Ok(GeneratedFileHash {
|
||||
project_id: row.get(0)?,
|
||||
relative_path: row.get(1)?,
|
||||
content_hash: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn db_notification_from_row(row: &Row) -> rusqlite::Result<DbNotification> {
|
||||
let entity_str: String = row.get(1)?;
|
||||
let action_str: String = row.get(3)?;
|
||||
Ok(DbNotification {
|
||||
id: row.get(0)?,
|
||||
entity_type: parse_notification_entity(&entity_str)?,
|
||||
entity_id: row.get(2)?,
|
||||
action: parse_notification_action(&action_str)?,
|
||||
from_cli: bool_from_i64(row.get(4)?),
|
||||
seen_at: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
// ── enum parsing ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_post_status_valid() {
|
||||
assert_eq!(parse_post_status("draft").unwrap(), PostStatus::Draft);
|
||||
assert_eq!(parse_post_status("published").unwrap(), PostStatus::Published);
|
||||
assert_eq!(parse_post_status("archived").unwrap(), PostStatus::Archived);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_post_status_invalid() {
|
||||
assert!(parse_post_status("nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_template_kind_valid() {
|
||||
assert_eq!(parse_template_kind("post").unwrap(), TemplateKind::Post);
|
||||
assert_eq!(parse_template_kind("list").unwrap(), TemplateKind::List);
|
||||
assert_eq!(parse_template_kind("not_found").unwrap(), TemplateKind::NotFound);
|
||||
assert_eq!(parse_template_kind("partial").unwrap(), TemplateKind::Partial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_template_kind_invalid() {
|
||||
assert!(parse_template_kind("unknown").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_template_status_valid() {
|
||||
assert_eq!(parse_template_status("draft").unwrap(), TemplateStatus::Draft);
|
||||
assert_eq!(parse_template_status("published").unwrap(), TemplateStatus::Published);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_script_kind_valid() {
|
||||
assert_eq!(parse_script_kind("macro").unwrap(), ScriptKind::Macro);
|
||||
assert_eq!(parse_script_kind("utility").unwrap(), ScriptKind::Utility);
|
||||
assert_eq!(parse_script_kind("transform").unwrap(), ScriptKind::Transform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_script_status_valid() {
|
||||
assert_eq!(parse_script_status("draft").unwrap(), ScriptStatus::Draft);
|
||||
assert_eq!(parse_script_status("published").unwrap(), ScriptStatus::Published);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_notification_entity_valid() {
|
||||
assert_eq!(parse_notification_entity("post").unwrap(), NotificationEntity::Post);
|
||||
assert_eq!(parse_notification_entity("media").unwrap(), NotificationEntity::Media);
|
||||
assert_eq!(parse_notification_entity("script").unwrap(), NotificationEntity::Script);
|
||||
assert_eq!(parse_notification_entity("template").unwrap(), NotificationEntity::Template);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_notification_action_valid() {
|
||||
assert_eq!(parse_notification_action("created").unwrap(), NotificationAction::Created);
|
||||
assert_eq!(parse_notification_action("updated").unwrap(), NotificationAction::Updated);
|
||||
assert_eq!(parse_notification_action("deleted").unwrap(), NotificationAction::Deleted);
|
||||
}
|
||||
|
||||
// ── JSON helpers ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn json_vec_string_roundtrip() {
|
||||
let v = json_to_vec_string(r#"["a","b","c"]"#).unwrap();
|
||||
assert_eq!(v, vec!["a", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_vec_string_empty() {
|
||||
let v = json_to_vec_string("[]").unwrap();
|
||||
assert!(v.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_vec_string_invalid() {
|
||||
assert!(json_to_vec_string("not json").is_err());
|
||||
}
|
||||
|
||||
// ── from_row round-trips via real DB ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn project_from_row_roundtrip() {
|
||||
let db = setup();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO projects (id, name, slug, description, data_path, is_active, created_at, updated_at)
|
||||
VALUES ('p1', 'Blog', 'blog', 'My blog', '/data', 1, 1000, 2000)",
|
||||
[],
|
||||
).unwrap();
|
||||
let p = c.query_row(
|
||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = 'p1'"),
|
||||
[],
|
||||
project_from_row,
|
||||
).unwrap();
|
||||
assert_eq!(p.id, "p1");
|
||||
assert_eq!(p.name, "Blog");
|
||||
assert_eq!(p.slug, "blog");
|
||||
assert_eq!(p.description.as_deref(), Some("My blog"));
|
||||
assert_eq!(p.data_path.as_deref(), Some("/data"));
|
||||
assert!(p.is_active);
|
||||
assert_eq!(p.created_at, 1000);
|
||||
assert_eq!(p.updated_at, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_from_row_roundtrip() {
|
||||
let db = setup();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
|
||||
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, excerpt, content, status, author,
|
||||
language, do_not_translate, template_slug, file_path, checksum,
|
||||
tags, categories,
|
||||
published_title, published_content, published_tags, published_categories, published_excerpt,
|
||||
created_at, updated_at, published_at)
|
||||
VALUES ('x', 'p1', 'Hello', 'hello', 'sum', 'body', 'draft', 'Alice',
|
||||
'en', 1, 'tpl', 'posts/hello.md', 'abc',
|
||||
'[\"rust\"]', '[\"tech\"]',
|
||||
NULL, NULL, NULL, NULL, NULL,
|
||||
1000, 2000, NULL)",
|
||||
[],
|
||||
).unwrap();
|
||||
let p = c.query_row(
|
||||
&format!("SELECT {POST_COLUMNS} FROM posts WHERE id = 'x'"),
|
||||
[],
|
||||
post_from_row,
|
||||
).unwrap();
|
||||
assert_eq!(p.id, "x");
|
||||
assert_eq!(p.project_id, "p1");
|
||||
assert_eq!(p.title, "Hello");
|
||||
assert_eq!(p.slug, "hello");
|
||||
assert_eq!(p.excerpt.as_deref(), Some("sum"));
|
||||
assert_eq!(p.content.as_deref(), Some("body"));
|
||||
assert_eq!(p.status, PostStatus::Draft);
|
||||
assert_eq!(p.author.as_deref(), Some("Alice"));
|
||||
assert_eq!(p.language.as_deref(), Some("en"));
|
||||
assert!(p.do_not_translate);
|
||||
assert_eq!(p.template_slug.as_deref(), Some("tpl"));
|
||||
assert_eq!(p.file_path, "posts/hello.md");
|
||||
assert_eq!(p.checksum.as_deref(), Some("abc"));
|
||||
assert_eq!(p.tags, vec!["rust"]);
|
||||
assert_eq!(p.categories, vec!["tech"]);
|
||||
assert!(p.published_title.is_none());
|
||||
assert_eq!(p.created_at, 1000);
|
||||
assert_eq!(p.updated_at, 2000);
|
||||
assert!(p.published_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_from_row_roundtrip() {
|
||||
let db = setup();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
|
||||
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO templates (id, project_id, slug, title, kind, enabled, version,
|
||||
file_path, status, content, created_at, updated_at)
|
||||
VALUES ('t1', 'p1', 'default', 'Default', 'not_found', 0, 3,
|
||||
'templates/default.liquid', 'draft', 'html', 1000, 2000)",
|
||||
[],
|
||||
).unwrap();
|
||||
let t = c.query_row(
|
||||
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = 't1'"),
|
||||
[],
|
||||
template_from_row,
|
||||
).unwrap();
|
||||
assert_eq!(t.kind, TemplateKind::NotFound);
|
||||
assert!(!t.enabled);
|
||||
assert_eq!(t.version, 3);
|
||||
assert_eq!(t.status, TemplateStatus::Draft);
|
||||
assert_eq!(t.content.as_deref(), Some("html"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_notification_from_row_roundtrip() {
|
||||
let db = setup();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO db_notifications (entity_type, entity_id, action, from_cli, seen_at, created_at)
|
||||
VALUES ('media', 'm1', 'deleted', 1, 5000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
let n = c.query_row(
|
||||
&format!("SELECT {DB_NOTIFICATION_COLUMNS} FROM db_notifications WHERE entity_id = 'm1'"),
|
||||
[],
|
||||
db_notification_from_row,
|
||||
).unwrap();
|
||||
assert_eq!(n.entity_type, NotificationEntity::Media);
|
||||
assert_eq!(n.entity_id, "m1");
|
||||
assert_eq!(n.action, NotificationAction::Deleted);
|
||||
assert!(n.from_cli);
|
||||
assert_eq!(n.seen_at, Some(5000));
|
||||
assert_eq!(n.created_at, 1000);
|
||||
}
|
||||
}
|
||||
297
crates/bds-core/src/db/fts.rs
Normal file
297
crates/bds-core/src/db/fts.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
use rust_stemmers::{Algorithm, Stemmer};
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Create FTS5 virtual tables at runtime (not in migrations per spec).
|
||||
pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||
post_id UNINDEXED,
|
||||
content
|
||||
);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5(
|
||||
media_id UNINDEXED,
|
||||
content
|
||||
);"
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map ISO 639-1 language code to Snowball stemmer algorithm.
|
||||
fn stemmer_for_language(lang: &str) -> Algorithm {
|
||||
match lang {
|
||||
"ar" => Algorithm::Arabic,
|
||||
"da" => Algorithm::Danish,
|
||||
"de" => Algorithm::German,
|
||||
"el" => Algorithm::Greek,
|
||||
"en" => Algorithm::English,
|
||||
"es" => Algorithm::Spanish,
|
||||
"fi" => Algorithm::Finnish,
|
||||
"fr" => Algorithm::French,
|
||||
"hu" => Algorithm::Hungarian,
|
||||
"hy" | "id" => Algorithm::English, // no dedicated stemmer, fallback
|
||||
"it" => Algorithm::Italian,
|
||||
"nb" | "nn" | "no" => Algorithm::Norwegian,
|
||||
"nl" => Algorithm::Dutch,
|
||||
"pt" => Algorithm::Portuguese,
|
||||
"ro" => Algorithm::Romanian,
|
||||
"ru" => Algorithm::Russian,
|
||||
"sv" => Algorithm::Swedish,
|
||||
"ta" => Algorithm::Tamil,
|
||||
"tr" => Algorithm::Turkish,
|
||||
_ => Algorithm::English,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stem a text string using the Snowball stemmer for the given language.
|
||||
pub fn stem_text(text: &str, language: &str) -> String {
|
||||
let algo = stemmer_for_language(language);
|
||||
let stemmer = Stemmer::create(algo);
|
||||
text.split_whitespace()
|
||||
.map(|word| stemmer.stem(word).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Index a post in the FTS table. Concatenates title, excerpt, content, tags,
|
||||
/// categories, and all translation text. Pre-stems before inserting.
|
||||
pub fn index_post(
|
||||
conn: &Connection,
|
||||
post_id: &str,
|
||||
title: &str,
|
||||
excerpt: Option<&str>,
|
||||
content: Option<&str>,
|
||||
tags: &[String],
|
||||
categories: &[String],
|
||||
translation_texts: &[String],
|
||||
language: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
// Remove existing entry
|
||||
remove_post_from_index(conn, post_id)?;
|
||||
|
||||
let mut parts: Vec<&str> = vec![title];
|
||||
if let Some(exc) = excerpt {
|
||||
parts.push(exc);
|
||||
}
|
||||
if let Some(cnt) = content {
|
||||
parts.push(cnt);
|
||||
}
|
||||
let tags_str = tags.join(" ");
|
||||
parts.push(&tags_str);
|
||||
let cats_str = categories.join(" ");
|
||||
parts.push(&cats_str);
|
||||
|
||||
let combined = parts.join(" ");
|
||||
let mut full_text = stem_text(&combined, language);
|
||||
|
||||
// Add translation texts (stemmed with their own implied language or same)
|
||||
for t in translation_texts {
|
||||
full_text.push(' ');
|
||||
full_text.push_str(&stem_text(t, language));
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO posts_fts (post_id, content) VALUES (?1, ?2)",
|
||||
rusqlite::params![post_id, full_text],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Index a media item in the FTS table.
|
||||
pub fn index_media(
|
||||
conn: &Connection,
|
||||
media_id: &str,
|
||||
title: Option<&str>,
|
||||
alt: Option<&str>,
|
||||
caption: Option<&str>,
|
||||
original_name: &str,
|
||||
tags: &[String],
|
||||
translation_texts: &[String],
|
||||
language: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
remove_media_from_index(conn, media_id)?;
|
||||
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
if let Some(t) = title {
|
||||
parts.push(t);
|
||||
}
|
||||
if let Some(a) = alt {
|
||||
parts.push(a);
|
||||
}
|
||||
if let Some(c) = caption {
|
||||
parts.push(c);
|
||||
}
|
||||
parts.push(original_name);
|
||||
let tags_str = tags.join(" ");
|
||||
parts.push(&tags_str);
|
||||
|
||||
let combined = parts.join(" ");
|
||||
let mut full_text = stem_text(&combined, language);
|
||||
|
||||
for t in translation_texts {
|
||||
full_text.push(' ');
|
||||
full_text.push_str(&stem_text(t, language));
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO media_fts (media_id, content) VALUES (?1, ?2)",
|
||||
rusqlite::params![media_id, full_text],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a post from the FTS index.
|
||||
pub fn remove_post_from_index(conn: &Connection, post_id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM posts_fts WHERE post_id = ?1",
|
||||
rusqlite::params![post_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a media item from the FTS index.
|
||||
pub fn remove_media_from_index(conn: &Connection, media_id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM media_fts WHERE media_id = ?1",
|
||||
rusqlite::params![media_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search posts by full-text query. Returns matching post IDs.
|
||||
pub fn search_posts(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
|
||||
let stemmed = stem_text(query, language);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank"
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
/// Search media by full-text query. Returns matching media IDs.
|
||||
pub fn search_media(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
|
||||
let stemmed = stem_text(query, language);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank"
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate();
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts_tables_created() {
|
||||
let db = setup();
|
||||
let count: i64 = db.conn()
|
||||
.query_row(
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('posts_fts', 'media_fts')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts_tables_idempotent() {
|
||||
let db = setup();
|
||||
ensure_fts_tables(db.conn()).unwrap(); // second call should not error
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stem_german() {
|
||||
let stemmed = stem_text("Programmierung Entwicklung", "de");
|
||||
// German stemmer should reduce these words
|
||||
assert!(!stemmed.is_empty());
|
||||
assert_ne!(stemmed, "Programmierung Entwicklung");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stem_english() {
|
||||
let stemmed = stem_text("running jumps", "en");
|
||||
assert!(stemmed.contains("run"));
|
||||
assert!(stemmed.contains("jump"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_and_search_post() {
|
||||
let db = setup();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"post-1",
|
||||
"Esmeralda Spider",
|
||||
Some("A macro photo"),
|
||||
Some("Beautiful spider web"),
|
||||
&["fotografie".into(), "natur".into()],
|
||||
&["picture".into()],
|
||||
&["Esmeralda English".into()],
|
||||
"en",
|
||||
).unwrap();
|
||||
|
||||
let results = search_posts(db.conn(), "spider", "en").unwrap();
|
||||
assert_eq!(results, vec!["post-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_and_search_media() {
|
||||
let db = setup();
|
||||
index_media(
|
||||
db.conn(),
|
||||
"media-1",
|
||||
Some("Sunset Photo"),
|
||||
Some("Beautiful sunset over mountains"),
|
||||
None,
|
||||
"sunset.jpg",
|
||||
&["nature".into()],
|
||||
&[],
|
||||
"en",
|
||||
).unwrap();
|
||||
|
||||
let results = search_media(db.conn(), "sunset", "en").unwrap();
|
||||
assert_eq!(results, vec!["media-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_no_results() {
|
||||
let db = setup();
|
||||
let results = search_posts(db.conn(), "nonexistent", "en").unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_from_index() {
|
||||
let db = setup();
|
||||
index_post(
|
||||
db.conn(), "p1", "Test", None, None,
|
||||
&[], &[], &[], "en",
|
||||
).unwrap();
|
||||
assert_eq!(search_posts(db.conn(), "test", "en").unwrap().len(), 1);
|
||||
|
||||
remove_post_from_index(db.conn(), "p1").unwrap();
|
||||
assert!(search_posts(db.conn(), "test", "en").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reindex_replaces_old() {
|
||||
let db = setup();
|
||||
index_post(db.conn(), "p1", "Alpha", None, None, &[], &[], &[], "en").unwrap();
|
||||
index_post(db.conn(), "p1", "Beta", None, None, &[], &[], &[], "en").unwrap();
|
||||
|
||||
assert!(search_posts(db.conn(), "alpha", "en").unwrap().is_empty());
|
||||
assert_eq!(search_posts(db.conn(), "beta", "en").unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
mod connection;
|
||||
pub mod from_row;
|
||||
pub mod fts;
|
||||
mod migrations;
|
||||
pub mod queries;
|
||||
|
||||
pub use connection::Database;
|
||||
pub use migrations::run_migrations;
|
||||
|
||||
210
crates/bds-core/src/db/queries/media.rs
Normal file
210
crates/bds-core/src/db/queries/media.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{media_from_row, MEDIA_COLUMNS};
|
||||
use crate::model::Media;
|
||||
|
||||
fn tags_to_json(tags: &[String]) -> String {
|
||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
||||
}
|
||||
|
||||
pub fn insert_media(conn: &Connection, m: &Media) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO media (
|
||||
id, project_id, filename, original_name, mime_type, size,
|
||||
width, height, title, alt, caption, author, language,
|
||||
file_path, sidecar_path, checksum, tags, created_at, updated_at
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6,
|
||||
?7, ?8, ?9, ?10, ?11, ?12, ?13,
|
||||
?14, ?15, ?16, ?17, ?18, ?19
|
||||
)",
|
||||
params![
|
||||
m.id,
|
||||
m.project_id,
|
||||
m.filename,
|
||||
m.original_name,
|
||||
m.mime_type,
|
||||
m.size,
|
||||
m.width,
|
||||
m.height,
|
||||
m.title,
|
||||
m.alt,
|
||||
m.caption,
|
||||
m.author,
|
||||
m.language,
|
||||
m.file_path,
|
||||
m.sidecar_path,
|
||||
m.checksum,
|
||||
tags_to_json(&m.tags),
|
||||
m.created_at,
|
||||
m.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_media_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Media> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {MEDIA_COLUMNS} FROM media WHERE id = ?1"),
|
||||
params![id],
|
||||
media_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_media_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Media>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {MEDIA_COLUMNS} FROM media WHERE project_id = ?1 ORDER BY created_at DESC"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], media_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_media(conn: &Connection, m: &Media) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE media SET
|
||||
filename = ?1, original_name = ?2, mime_type = ?3, size = ?4,
|
||||
width = ?5, height = ?6, title = ?7, alt = ?8, caption = ?9,
|
||||
author = ?10, language = ?11, file_path = ?12, sidecar_path = ?13,
|
||||
checksum = ?14, tags = ?15, updated_at = ?16
|
||||
WHERE id = ?17",
|
||||
params![
|
||||
m.filename,
|
||||
m.original_name,
|
||||
m.mime_type,
|
||||
m.size,
|
||||
m.width,
|
||||
m.height,
|
||||
m.title,
|
||||
m.alt,
|
||||
m.caption,
|
||||
m.author,
|
||||
m.language,
|
||||
m.file_path,
|
||||
m.sidecar_path,
|
||||
m.checksum,
|
||||
tags_to_json(&m.tags),
|
||||
m.updated_at,
|
||||
m.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_media(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM media WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test helper: create a minimal Media value (available to sibling test modules).
|
||||
#[cfg(test)]
|
||||
pub fn make_test_media(id: &str, project_id: &str) -> Media {
|
||||
Media {
|
||||
id: id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
filename: format!("{id}.jpg"),
|
||||
original_name: "photo.jpg".to_string(),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
size: 50000,
|
||||
width: Some(1920),
|
||||
height: Some(1080),
|
||||
title: None,
|
||||
alt: None,
|
||||
caption: None,
|
||||
author: None,
|
||||
language: None,
|
||||
file_path: format!("/media/{id}.jpg"),
|
||||
sidecar_path: format!("/media/{id}.jpg.meta"),
|
||||
checksum: None,
|
||||
tags: vec![],
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_media_item(id: &str) -> Media {
|
||||
Media {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
filename: "abc.jpg".to_string(),
|
||||
original_name: "photo.jpg".to_string(),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
size: 50000,
|
||||
width: Some(1920),
|
||||
height: Some(1080),
|
||||
title: Some("Sunset".into()),
|
||||
alt: Some("A sunset".into()),
|
||||
caption: None,
|
||||
author: Some("Bob".into()),
|
||||
language: Some("en".into()),
|
||||
file_path: "/media/abc.jpg".to_string(),
|
||||
sidecar_path: "/media/abc.jpg.meta".to_string(),
|
||||
checksum: Some("hash1".into()),
|
||||
tags: vec!["nature".into()],
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_by_id() {
|
||||
let db = setup();
|
||||
let m = make_media_item("m1");
|
||||
insert_media(db.conn(), &m).unwrap();
|
||||
let fetched = get_media_by_id(db.conn(), "m1").unwrap();
|
||||
assert_eq!(fetched.original_name, "photo.jpg");
|
||||
assert_eq!(fetched.width, Some(1920));
|
||||
assert_eq!(fetched.height, Some(1080));
|
||||
assert_eq!(fetched.tags, vec!["nature"]);
|
||||
assert_eq!(fetched.size, 50000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_project() {
|
||||
let db = setup();
|
||||
let mut m1 = make_media_item("m1");
|
||||
m1.created_at = 1000;
|
||||
let mut m2 = make_media_item("m2");
|
||||
m2.filename = "def.jpg".into();
|
||||
m2.created_at = 2000;
|
||||
insert_media(db.conn(), &m1).unwrap();
|
||||
insert_media(db.conn(), &m2).unwrap();
|
||||
let list = list_media_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].id, "m2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_media_fields() {
|
||||
let db = setup();
|
||||
let mut m = make_media_item("m1");
|
||||
insert_media(db.conn(), &m).unwrap();
|
||||
m.title = Some("New Title".into());
|
||||
m.tags = vec!["updated".into()];
|
||||
m.updated_at = 9999;
|
||||
update_media(db.conn(), &m).unwrap();
|
||||
let fetched = get_media_by_id(db.conn(), "m1").unwrap();
|
||||
assert_eq!(fetched.title.as_deref(), Some("New Title"));
|
||||
assert_eq!(fetched.tags, vec!["updated"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_media() {
|
||||
let db = setup();
|
||||
insert_media(db.conn(), &make_media_item("m1")).unwrap();
|
||||
delete_media(db.conn(), "m1").unwrap();
|
||||
assert!(get_media_by_id(db.conn(), "m1").is_err());
|
||||
}
|
||||
}
|
||||
171
crates/bds-core/src/db/queries/media_translation.rs
Normal file
171
crates/bds-core/src/db/queries/media_translation.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{media_translation_from_row, MEDIA_TRANSLATION_COLUMNS};
|
||||
use crate::model::MediaTranslation;
|
||||
|
||||
pub fn insert_media_translation(
|
||||
conn: &Connection,
|
||||
t: &MediaTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO media_translations (
|
||||
id, project_id, translation_for, language, title, alt, caption,
|
||||
created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
t.id,
|
||||
t.project_id,
|
||||
t.translation_for,
|
||||
t.language,
|
||||
t.title,
|
||||
t.alt,
|
||||
t.caption,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_media_translation_by_media_and_language(
|
||||
conn: &Connection,
|
||||
translation_for: &str,
|
||||
language: &str,
|
||||
) -> rusqlite::Result<MediaTranslation> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {MEDIA_TRANSLATION_COLUMNS} FROM media_translations
|
||||
WHERE translation_for = ?1 AND language = ?2"
|
||||
),
|
||||
params![translation_for, language],
|
||||
media_translation_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_media_translations_by_media(
|
||||
conn: &Connection,
|
||||
translation_for: &str,
|
||||
) -> rusqlite::Result<Vec<MediaTranslation>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {MEDIA_TRANSLATION_COLUMNS} FROM media_translations
|
||||
WHERE translation_for = ?1 ORDER BY language"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![translation_for], media_translation_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn upsert_media_translation(
|
||||
conn: &Connection,
|
||||
t: &MediaTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO media_translations (
|
||||
id, project_id, translation_for, language, title, alt, caption,
|
||||
created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
ON CONFLICT(translation_for, language) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
alt = excluded.alt,
|
||||
caption = excluded.caption,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
t.id,
|
||||
t.project_id,
|
||||
t.translation_for,
|
||||
t.language,
|
||||
t.title,
|
||||
t.alt,
|
||||
t.caption,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_media_translation(
|
||||
conn: &Connection,
|
||||
translation_for: &str,
|
||||
language: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM media_translations WHERE translation_for = ?1 AND language = ?2",
|
||||
params![translation_for, language],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
insert_media(db.conn(), &make_test_media("m1", "p1")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_mt(id: &str, lang: &str) -> MediaTranslation {
|
||||
MediaTranslation {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
translation_for: "m1".to_string(),
|
||||
language: lang.to_string(),
|
||||
title: Some(format!("Title {lang}")),
|
||||
alt: Some(format!("Alt {lang}")),
|
||||
caption: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get() {
|
||||
let db = setup();
|
||||
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
|
||||
let t = get_media_translation_by_media_and_language(db.conn(), "m1", "de").unwrap();
|
||||
assert_eq!(t.title.as_deref(), Some("Title de"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_media() {
|
||||
let db = setup();
|
||||
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
|
||||
insert_media_translation(db.conn(), &make_mt("mt2", "fr")).unwrap();
|
||||
let list = list_media_translations_by_media(db.conn(), "m1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].language, "de");
|
||||
assert_eq!(list[1].language, "fr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_inserts_then_updates() {
|
||||
let db = setup();
|
||||
let mut t = make_mt("mt1", "de");
|
||||
upsert_media_translation(db.conn(), &t).unwrap();
|
||||
let fetched = get_media_translation_by_media_and_language(db.conn(), "m1", "de").unwrap();
|
||||
assert_eq!(fetched.title.as_deref(), Some("Title de"));
|
||||
|
||||
t.title = Some("Neuer Titel".into());
|
||||
t.updated_at = 9999;
|
||||
upsert_media_translation(db.conn(), &t).unwrap();
|
||||
let fetched = get_media_translation_by_media_and_language(db.conn(), "m1", "de").unwrap();
|
||||
assert_eq!(fetched.title.as_deref(), Some("Neuer Titel"));
|
||||
assert_eq!(fetched.updated_at, 9999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_translation() {
|
||||
let db = setup();
|
||||
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
|
||||
delete_media_translation(db.conn(), "m1", "de").unwrap();
|
||||
assert!(
|
||||
get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
11
crates/bds-core/src/db/queries/mod.rs
Normal file
11
crates/bds-core/src/db/queries/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod project;
|
||||
pub mod post;
|
||||
pub mod post_translation;
|
||||
pub mod media;
|
||||
pub mod media_translation;
|
||||
pub mod tag;
|
||||
pub mod post_link;
|
||||
pub mod post_media;
|
||||
pub mod template;
|
||||
pub mod script;
|
||||
pub mod setting;
|
||||
339
crates/bds-core/src/db/queries/post.rs
Normal file
339
crates/bds-core/src/db/queries/post.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{post_from_row, post_status_to_str, POST_COLUMNS};
|
||||
use crate::model::{Post, PostStatus};
|
||||
|
||||
fn tags_to_json(tags: &[String]) -> String {
|
||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
||||
}
|
||||
|
||||
pub fn insert_post(conn: &Connection, post: &Post) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO posts (
|
||||
id, project_id, title, slug, excerpt, content, status, author,
|
||||
language, do_not_translate, template_slug, file_path, checksum,
|
||||
tags, categories,
|
||||
published_title, published_content, published_tags,
|
||||
published_categories, published_excerpt,
|
||||
created_at, updated_at, published_at
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
|
||||
?9, ?10, ?11, ?12, ?13,
|
||||
?14, ?15,
|
||||
?16, ?17, ?18, ?19, ?20,
|
||||
?21, ?22, ?23
|
||||
)",
|
||||
params![
|
||||
post.id,
|
||||
post.project_id,
|
||||
post.title,
|
||||
post.slug,
|
||||
post.excerpt,
|
||||
post.content,
|
||||
post_status_to_str(&post.status),
|
||||
post.author,
|
||||
post.language,
|
||||
post.do_not_translate as i64,
|
||||
post.template_slug,
|
||||
post.file_path,
|
||||
post.checksum,
|
||||
tags_to_json(&post.tags),
|
||||
tags_to_json(&post.categories),
|
||||
post.published_title,
|
||||
post.published_content,
|
||||
post.published_tags,
|
||||
post.published_categories,
|
||||
post.published_excerpt,
|
||||
post.created_at,
|
||||
post.updated_at,
|
||||
post.published_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_post_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Post> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {POST_COLUMNS} FROM posts WHERE id = ?1"),
|
||||
params![id],
|
||||
post_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_post_by_project_and_slug(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Post> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {POST_COLUMNS} FROM posts WHERE project_id = ?1 AND slug = ?2"),
|
||||
params![project_id, slug],
|
||||
post_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Post>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_COLUMNS} FROM posts WHERE project_id = ?1 ORDER BY created_at DESC"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], post_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_post(conn: &Connection, post: &Post) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET
|
||||
title = ?1, slug = ?2, excerpt = ?3, content = ?4, status = ?5,
|
||||
author = ?6, language = ?7, do_not_translate = ?8, template_slug = ?9,
|
||||
file_path = ?10, checksum = ?11, tags = ?12, categories = ?13,
|
||||
published_title = ?14, published_content = ?15, published_tags = ?16,
|
||||
published_categories = ?17, published_excerpt = ?18,
|
||||
updated_at = ?19, published_at = ?20
|
||||
WHERE id = ?21",
|
||||
params![
|
||||
post.title,
|
||||
post.slug,
|
||||
post.excerpt,
|
||||
post.content,
|
||||
post_status_to_str(&post.status),
|
||||
post.author,
|
||||
post.language,
|
||||
post.do_not_translate as i64,
|
||||
post.template_slug,
|
||||
post.file_path,
|
||||
post.checksum,
|
||||
tags_to_json(&post.tags),
|
||||
tags_to_json(&post.categories),
|
||||
post.published_title,
|
||||
post.published_content,
|
||||
post.published_tags,
|
||||
post.published_categories,
|
||||
post.published_excerpt,
|
||||
post.updated_at,
|
||||
post.published_at,
|
||||
post.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_post_status(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
status: &PostStatus,
|
||||
updated_at: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET status = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![post_status_to_str(status), updated_at, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_post_content(conn: &Connection, id: &str, updated_at: i64) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET content = NULL, updated_at = ?1 WHERE id = ?2",
|
||||
params![updated_at, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_post_file_path(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
file_path: &str,
|
||||
updated_at: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET file_path = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![file_path, updated_at, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_published_snapshot(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
title: &str,
|
||||
content: &str,
|
||||
tags: &str,
|
||||
categories: &str,
|
||||
excerpt: Option<&str>,
|
||||
published_at: i64,
|
||||
updated_at: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET
|
||||
published_title = ?1, published_content = ?2, published_tags = ?3,
|
||||
published_categories = ?4, published_excerpt = ?5,
|
||||
published_at = ?6, updated_at = ?7
|
||||
WHERE id = ?8",
|
||||
params![title, content, tags, categories, excerpt, published_at, updated_at, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_post(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM posts WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn count_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM posts WHERE project_id = ?1",
|
||||
params![project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_post(id: &str, slug: &str) -> Post {
|
||||
Post {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
title: format!("Post {id}"),
|
||||
slug: slug.to_string(),
|
||||
excerpt: Some("excerpt".into()),
|
||||
content: Some("body".into()),
|
||||
status: PostStatus::Draft,
|
||||
author: Some("Alice".into()),
|
||||
language: Some("en".into()),
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: format!("posts/{slug}.md"),
|
||||
checksum: None,
|
||||
tags: vec!["rust".into()],
|
||||
categories: vec!["tech".into()],
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
published_excerpt: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
published_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_by_id() {
|
||||
let db = setup();
|
||||
let post = make_post("x1", "hello");
|
||||
insert_post(db.conn(), &post).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert_eq!(fetched.title, "Post x1");
|
||||
assert_eq!(fetched.tags, vec!["rust"]);
|
||||
assert_eq!(fetched.categories, vec!["tech"]);
|
||||
assert_eq!(fetched.status, PostStatus::Draft);
|
||||
assert!(!fetched.do_not_translate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_project_and_slug() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
let fetched = get_post_by_project_and_slug(db.conn(), "p1", "hello").unwrap();
|
||||
assert_eq!(fetched.id, "x1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_posts_ordered_by_created_at_desc() {
|
||||
let db = setup();
|
||||
let mut p1 = make_post("x1", "first");
|
||||
p1.created_at = 1000;
|
||||
let mut p2 = make_post("x2", "second");
|
||||
p2.created_at = 2000;
|
||||
insert_post(db.conn(), &p1).unwrap();
|
||||
insert_post(db.conn(), &p2).unwrap();
|
||||
let list = list_posts_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].id, "x2");
|
||||
assert_eq!(list[1].id, "x1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_post_fields() {
|
||||
let db = setup();
|
||||
let mut post = make_post("x1", "hello");
|
||||
insert_post(db.conn(), &post).unwrap();
|
||||
post.title = "Updated".into();
|
||||
post.tags = vec!["new-tag".into()];
|
||||
post.updated_at = 9999;
|
||||
update_post(db.conn(), &post).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert_eq!(fetched.title, "Updated");
|
||||
assert_eq!(fetched.tags, vec!["new-tag"]);
|
||||
assert_eq!(fetched.updated_at, 9999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_status() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
update_post_status(db.conn(), "x1", &PostStatus::Published, 5000).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert_eq!(fetched.status, PostStatus::Published);
|
||||
assert_eq!(fetched.updated_at, 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_content_sets_null() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
clear_post_content(db.conn(), "x1", 5000).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert!(fetched.content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_file_path_updates() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
set_post_file_path(db.conn(), "x1", "posts/2024/01/hello.md", 5000).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert_eq!(fetched.file_path, "posts/2024/01/hello.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn published_snapshot() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
set_published_snapshot(
|
||||
db.conn(), "x1", "Pub Title", "Pub Body",
|
||||
"[\"rust\"]", "[\"tech\"]", Some("Pub Excerpt"), 3000, 3000,
|
||||
).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert_eq!(fetched.published_title.as_deref(), Some("Pub Title"));
|
||||
assert_eq!(fetched.published_content.as_deref(), Some("Pub Body"));
|
||||
assert_eq!(fetched.published_at, Some(3000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_post() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
delete_post(db.conn(), "x1").unwrap();
|
||||
assert!(get_post_by_id(db.conn(), "x1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_posts() {
|
||||
let db = setup();
|
||||
assert_eq!(count_posts_by_project(db.conn(), "p1").unwrap(), 0);
|
||||
insert_post(db.conn(), &make_post("x1", "a")).unwrap();
|
||||
insert_post(db.conn(), &make_post("x2", "b")).unwrap();
|
||||
assert_eq!(count_posts_by_project(db.conn(), "p1").unwrap(), 2);
|
||||
}
|
||||
}
|
||||
117
crates/bds-core/src/db/queries/post_link.rs
Normal file
117
crates/bds-core/src/db/queries/post_link.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{post_link_from_row, POST_LINK_COLUMNS};
|
||||
use crate::model::PostLink;
|
||||
|
||||
pub fn insert_post_link(conn: &Connection, link: &PostLink) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO post_links (id, source_post_id, target_post_id, link_text, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
link.id,
|
||||
link.source_post_id,
|
||||
link.target_post_id,
|
||||
link.link_text,
|
||||
link.created_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_links_by_source(conn: &Connection, source_post_id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM post_links WHERE source_post_id = ?1",
|
||||
params![source_post_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_links_by_source(
|
||||
conn: &Connection,
|
||||
source_post_id: &str,
|
||||
) -> rusqlite::Result<Vec<PostLink>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_LINK_COLUMNS} FROM post_links WHERE source_post_id = ?1 ORDER BY created_at"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![source_post_id], post_link_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn list_links_by_target(
|
||||
conn: &Connection,
|
||||
target_post_id: &str,
|
||||
) -> rusqlite::Result<Vec<PostLink>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_LINK_COLUMNS} FROM post_links WHERE target_post_id = ?1 ORDER BY created_at"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![target_post_id], post_link_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('a', 'p1', 'A', 'a', 'draft', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('b', 'p1', 'B', 'b', 'draft', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('c', 'p1', 'C', 'c', 'draft', 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_link(id: &str, src: &str, tgt: &str) -> PostLink {
|
||||
PostLink {
|
||||
id: id.to_string(),
|
||||
source_post_id: src.to_string(),
|
||||
target_post_id: tgt.to_string(),
|
||||
link_text: Some("see also".into()),
|
||||
created_at: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_list_by_source() {
|
||||
let db = setup();
|
||||
insert_post_link(db.conn(), &make_link("l1", "a", "b")).unwrap();
|
||||
insert_post_link(db.conn(), &make_link("l2", "a", "c")).unwrap();
|
||||
let links = list_links_by_source(db.conn(), "a").unwrap();
|
||||
assert_eq!(links.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_target() {
|
||||
let db = setup();
|
||||
insert_post_link(db.conn(), &make_link("l1", "a", "c")).unwrap();
|
||||
insert_post_link(db.conn(), &make_link("l2", "b", "c")).unwrap();
|
||||
let links = list_links_by_target(db.conn(), "c").unwrap();
|
||||
assert_eq!(links.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_by_source() {
|
||||
let db = setup();
|
||||
insert_post_link(db.conn(), &make_link("l1", "a", "b")).unwrap();
|
||||
insert_post_link(db.conn(), &make_link("l2", "a", "c")).unwrap();
|
||||
delete_links_by_source(db.conn(), "a").unwrap();
|
||||
let links = list_links_by_source(db.conn(), "a").unwrap();
|
||||
assert!(links.is_empty());
|
||||
}
|
||||
}
|
||||
144
crates/bds-core/src/db/queries/post_media.rs
Normal file
144
crates/bds-core/src/db/queries/post_media.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{post_media_from_row, POST_MEDIA_COLUMNS};
|
||||
use crate::model::PostMedia;
|
||||
|
||||
pub fn link_media(conn: &Connection, pm: &PostMedia) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
pm.id,
|
||||
pm.project_id,
|
||||
pm.post_id,
|
||||
pm.media_id,
|
||||
pm.sort_order,
|
||||
pm.created_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unlink_media(conn: &Connection, post_id: &str, media_id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM post_media WHERE post_id = ?1 AND media_id = ?2",
|
||||
params![post_id, media_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_post_media_by_post(
|
||||
conn: &Connection,
|
||||
post_id: &str,
|
||||
) -> rusqlite::Result<Vec<PostMedia>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_MEDIA_COLUMNS} FROM post_media WHERE post_id = ?1 ORDER BY sort_order"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![post_id], post_media_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn list_post_media_by_media(
|
||||
conn: &Connection,
|
||||
media_id: &str,
|
||||
) -> rusqlite::Result<Vec<PostMedia>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_MEDIA_COLUMNS} FROM post_media WHERE media_id = ?1 ORDER BY created_at"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![media_id], post_media_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_sort_order(
|
||||
conn: &Connection,
|
||||
post_id: &str,
|
||||
media_id: &str,
|
||||
sort_order: i32,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE post_media SET sort_order = ?1 WHERE post_id = ?2 AND media_id = ?3",
|
||||
params![sort_order, post_id, media_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db.conn()
|
||||
.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('post1', 'p1', 'Hello', 'hello', 'draft', 1000, 1000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
insert_media(db.conn(), &make_test_media("m1", "p1")).unwrap();
|
||||
insert_media(db.conn(), &make_test_media("m2", "p1")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_pm(id: &str, media_id: &str, order: i32) -> PostMedia {
|
||||
PostMedia {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
post_id: "post1".to_string(),
|
||||
media_id: media_id.to_string(),
|
||||
sort_order: order,
|
||||
created_at: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_and_list_by_post() {
|
||||
let db = setup();
|
||||
link_media(db.conn(), &make_pm("pm1", "m1", 1)).unwrap();
|
||||
link_media(db.conn(), &make_pm("pm2", "m2", 0)).unwrap();
|
||||
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].media_id, "m2"); // sort_order 0 first
|
||||
assert_eq!(list[1].media_id, "m1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_media() {
|
||||
let db = setup();
|
||||
link_media(db.conn(), &make_pm("pm1", "m1", 0)).unwrap();
|
||||
let list = list_post_media_by_media(db.conn(), "m1").unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].post_id, "post1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlink_removes_association() {
|
||||
let db = setup();
|
||||
link_media(db.conn(), &make_pm("pm1", "m1", 0)).unwrap();
|
||||
unlink_media(db.conn(), "post1", "m1").unwrap();
|
||||
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
|
||||
assert!(list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_sort_order_changes_value() {
|
||||
let db = setup();
|
||||
link_media(db.conn(), &make_pm("pm1", "m1", 0)).unwrap();
|
||||
update_sort_order(db.conn(), "post1", "m1", 10).unwrap();
|
||||
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
|
||||
assert_eq!(list[0].sort_order, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_post_media_rejected() {
|
||||
let db = setup();
|
||||
link_media(db.conn(), &make_pm("pm1", "m1", 0)).unwrap();
|
||||
let result = link_media(db.conn(), &make_pm("pm2", "m1", 1));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
221
crates/bds-core/src/db/queries/post_translation.rs
Normal file
221
crates/bds-core/src/db/queries/post_translation.rs
Normal file
@@ -0,0 +1,221 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{post_status_to_str, post_translation_from_row, POST_TRANSLATION_COLUMNS};
|
||||
use crate::model::PostTranslation;
|
||||
|
||||
pub fn insert_post_translation(
|
||||
conn: &Connection,
|
||||
t: &PostTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO post_translations (
|
||||
id, project_id, translation_for, language, title, excerpt, content,
|
||||
status, file_path, checksum, created_at, updated_at, published_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
t.id,
|
||||
t.project_id,
|
||||
t.translation_for,
|
||||
t.language,
|
||||
t.title,
|
||||
t.excerpt,
|
||||
t.content,
|
||||
post_status_to_str(&t.status),
|
||||
t.file_path,
|
||||
t.checksum,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
t.published_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_post_translation_by_id(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
) -> rusqlite::Result<PostTranslation> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations WHERE id = ?1"),
|
||||
params![id],
|
||||
post_translation_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_post_translation_by_post_and_language(
|
||||
conn: &Connection,
|
||||
translation_for: &str,
|
||||
language: &str,
|
||||
) -> rusqlite::Result<PostTranslation> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations
|
||||
WHERE translation_for = ?1 AND language = ?2"
|
||||
),
|
||||
params![translation_for, language],
|
||||
post_translation_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_post_translations_by_post(
|
||||
conn: &Connection,
|
||||
translation_for: &str,
|
||||
) -> rusqlite::Result<Vec<PostTranslation>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations
|
||||
WHERE translation_for = ?1 ORDER BY language"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![translation_for], post_translation_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_post_translation(
|
||||
conn: &Connection,
|
||||
t: &PostTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE post_translations SET
|
||||
title = ?1, excerpt = ?2, content = ?3, status = ?4,
|
||||
file_path = ?5, checksum = ?6, updated_at = ?7, published_at = ?8
|
||||
WHERE id = ?9",
|
||||
params![
|
||||
t.title,
|
||||
t.excerpt,
|
||||
t.content,
|
||||
post_status_to_str(&t.status),
|
||||
t.file_path,
|
||||
t.checksum,
|
||||
t.updated_at,
|
||||
t.published_at,
|
||||
t.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_post_translation(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM post_translations WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_all_translations_for_post(
|
||||
conn: &Connection,
|
||||
translation_for: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM post_translations WHERE translation_for = ?1",
|
||||
params![translation_for],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::model::PostStatus;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
// insert a parent post
|
||||
db.conn()
|
||||
.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('post1', 'p1', 'Hello', 'hello', 'draft', 1000, 1000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_translation(id: &str, lang: &str) -> PostTranslation {
|
||||
PostTranslation {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
translation_for: "post1".to_string(),
|
||||
language: lang.to_string(),
|
||||
title: format!("Title {lang}"),
|
||||
excerpt: Some("excerpt".into()),
|
||||
content: Some("body".into()),
|
||||
status: PostStatus::Draft,
|
||||
file_path: format!("posts/hello.{lang}.md"),
|
||||
checksum: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
published_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_by_id() {
|
||||
let db = setup();
|
||||
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
|
||||
let t = get_post_translation_by_id(db.conn(), "t1").unwrap();
|
||||
assert_eq!(t.language, "de");
|
||||
assert_eq!(t.title, "Title de");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_post_and_language() {
|
||||
let db = setup();
|
||||
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
|
||||
let t = get_post_translation_by_post_and_language(db.conn(), "post1", "de").unwrap();
|
||||
assert_eq!(t.id, "t1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_post() {
|
||||
let db = setup();
|
||||
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
|
||||
insert_post_translation(db.conn(), &make_translation("t2", "fr")).unwrap();
|
||||
let list = list_post_translations_by_post(db.conn(), "post1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].language, "de");
|
||||
assert_eq!(list[1].language, "fr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_translation() {
|
||||
let db = setup();
|
||||
let mut t = make_translation("t1", "de");
|
||||
insert_post_translation(db.conn(), &t).unwrap();
|
||||
t.title = "Neuer Titel".into();
|
||||
t.updated_at = 9999;
|
||||
update_post_translation(db.conn(), &t).unwrap();
|
||||
let fetched = get_post_translation_by_id(db.conn(), "t1").unwrap();
|
||||
assert_eq!(fetched.title, "Neuer Titel");
|
||||
assert_eq!(fetched.updated_at, 9999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_single() {
|
||||
let db = setup();
|
||||
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
|
||||
delete_post_translation(db.conn(), "t1").unwrap();
|
||||
assert!(get_post_translation_by_id(db.conn(), "t1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_all_for_post() {
|
||||
let db = setup();
|
||||
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
|
||||
insert_post_translation(db.conn(), &make_translation("t2", "fr")).unwrap();
|
||||
delete_all_translations_for_post(db.conn(), "post1").unwrap();
|
||||
let list = list_post_translations_by_post(db.conn(), "post1").unwrap();
|
||||
assert!(list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_language_rejected() {
|
||||
let db = setup();
|
||||
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
|
||||
let result = insert_post_translation(db.conn(), &make_translation("t2", "de"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
215
crates/bds-core/src/db/queries/project.rs
Normal file
215
crates/bds-core/src/db/queries/project.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{project_from_row, PROJECT_COLUMNS};
|
||||
use crate::model::Project;
|
||||
|
||||
pub fn insert_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, name, slug, description, data_path, is_active, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
project.id,
|
||||
project.name,
|
||||
project.slug,
|
||||
project.description,
|
||||
project.data_path,
|
||||
project.is_active as i64,
|
||||
project.created_at,
|
||||
project.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_project_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Project> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = ?1"),
|
||||
params![id],
|
||||
project_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_project_by_slug(conn: &Connection, slug: &str) -> rusqlite::Result<Project> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE slug = ?1"),
|
||||
params![slug],
|
||||
project_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_active_project(conn: &Connection) -> rusqlite::Result<Project> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE is_active = 1 LIMIT 1"),
|
||||
[],
|
||||
project_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_active_project(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("UPDATE projects SET is_active = 0 WHERE is_active = 1", [])?;
|
||||
conn.execute(
|
||||
"UPDATE projects SET is_active = 1 WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_projects(conn: &Connection) -> rusqlite::Result<Vec<Project>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {PROJECT_COLUMNS} FROM projects ORDER BY name"
|
||||
))?;
|
||||
let rows = stmt.query_map([], project_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE projects SET name = ?1, slug = ?2, description = ?3, data_path = ?4,
|
||||
is_active = ?5, updated_at = ?6
|
||||
WHERE id = ?7",
|
||||
params![
|
||||
project.name,
|
||||
project.slug,
|
||||
project.description,
|
||||
project.data_path,
|
||||
project.is_active as i64,
|
||||
project.updated_at,
|
||||
project.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_project(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test helper: create a minimal Project value (available to sibling test modules).
|
||||
#[cfg(test)]
|
||||
pub fn make_test_project(id: &str, slug: &str) -> Project {
|
||||
Project {
|
||||
id: id.to_string(),
|
||||
name: format!("Project {id}"),
|
||||
slug: slug.to_string(),
|
||||
description: Some("A test project".into()),
|
||||
data_path: Some("/data".into()),
|
||||
is_active: false,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_project(id: &str, slug: &str) -> Project {
|
||||
Project {
|
||||
id: id.to_string(),
|
||||
name: format!("Project {id}"),
|
||||
slug: slug.to_string(),
|
||||
description: Some("A test project".into()),
|
||||
data_path: Some("/data".into()),
|
||||
is_active: false,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_by_id() {
|
||||
let db = setup();
|
||||
let p = make_project("p1", "blog");
|
||||
insert_project(db.conn(), &p).unwrap();
|
||||
let fetched = get_project_by_id(db.conn(), "p1").unwrap();
|
||||
assert_eq!(fetched.id, "p1");
|
||||
assert_eq!(fetched.name, "Project p1");
|
||||
assert_eq!(fetched.slug, "blog");
|
||||
assert_eq!(fetched.description.as_deref(), Some("A test project"));
|
||||
assert_eq!(fetched.data_path.as_deref(), Some("/data"));
|
||||
assert!(!fetched.is_active);
|
||||
assert_eq!(fetched.created_at, 1000);
|
||||
assert_eq!(fetched.updated_at, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_slug() {
|
||||
let db = setup();
|
||||
insert_project(db.conn(), &make_project("p1", "my-blog")).unwrap();
|
||||
let fetched = get_project_by_slug(db.conn(), "my-blog").unwrap();
|
||||
assert_eq!(fetched.id, "p1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_project_flow() {
|
||||
let db = setup();
|
||||
insert_project(db.conn(), &make_project("p1", "blog1")).unwrap();
|
||||
insert_project(db.conn(), &make_project("p2", "blog2")).unwrap();
|
||||
|
||||
set_active_project(db.conn(), "p1").unwrap();
|
||||
let active = get_active_project(db.conn()).unwrap();
|
||||
assert_eq!(active.id, "p1");
|
||||
|
||||
set_active_project(db.conn(), "p2").unwrap();
|
||||
let active = get_active_project(db.conn()).unwrap();
|
||||
assert_eq!(active.id, "p2");
|
||||
|
||||
let p1 = get_project_by_id(db.conn(), "p1").unwrap();
|
||||
assert!(!p1.is_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_projects_ordered() {
|
||||
let db = setup();
|
||||
let mut p2 = make_project("p2", "zebra");
|
||||
p2.name = "Zebra".into();
|
||||
let mut p1 = make_project("p1", "alpha");
|
||||
p1.name = "Alpha".into();
|
||||
insert_project(db.conn(), &p2).unwrap();
|
||||
insert_project(db.conn(), &p1).unwrap();
|
||||
let list = list_projects(db.conn()).unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].name, "Alpha");
|
||||
assert_eq!(list[1].name, "Zebra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_project_fields() {
|
||||
let db = setup();
|
||||
let mut p = make_project("p1", "blog");
|
||||
insert_project(db.conn(), &p).unwrap();
|
||||
p.name = "Updated".into();
|
||||
p.description = None;
|
||||
p.updated_at = 9999;
|
||||
update_project(db.conn(), &p).unwrap();
|
||||
let fetched = get_project_by_id(db.conn(), "p1").unwrap();
|
||||
assert_eq!(fetched.name, "Updated");
|
||||
assert!(fetched.description.is_none());
|
||||
assert_eq!(fetched.updated_at, 9999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_project_removes_row() {
|
||||
let db = setup();
|
||||
insert_project(db.conn(), &make_project("p1", "blog")).unwrap();
|
||||
delete_project(db.conn(), "p1").unwrap();
|
||||
let result = get_project_by_id(db.conn(), "p1");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_slug_rejected() {
|
||||
let db = setup();
|
||||
insert_project(db.conn(), &make_project("p1", "blog")).unwrap();
|
||||
let result = insert_project(db.conn(), &make_project("p2", "blog"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
194
crates/bds-core/src/db/queries/script.rs
Normal file
194
crates/bds-core/src/db/queries/script.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{
|
||||
script_from_row, script_kind_to_str, script_status_to_str, SCRIPT_COLUMNS,
|
||||
};
|
||||
use crate::model::Script;
|
||||
|
||||
pub fn insert_script(conn: &Connection, s: &Script) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO scripts (
|
||||
id, project_id, slug, title, kind, entrypoint, enabled, version,
|
||||
file_path, status, content, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
s.id,
|
||||
s.project_id,
|
||||
s.slug,
|
||||
s.title,
|
||||
script_kind_to_str(&s.kind),
|
||||
s.entrypoint,
|
||||
s.enabled as i64,
|
||||
s.version,
|
||||
s.file_path,
|
||||
script_status_to_str(&s.status),
|
||||
s.content,
|
||||
s.created_at,
|
||||
s.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_script_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Script> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {SCRIPT_COLUMNS} FROM scripts WHERE id = ?1"),
|
||||
params![id],
|
||||
script_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_script_by_slug(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Script> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 AND slug = ?2"
|
||||
),
|
||||
params![project_id, slug],
|
||||
script_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_scripts_by_project(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<Script>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 ORDER BY title"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], script_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_script(conn: &Connection, s: &Script) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE scripts SET
|
||||
slug = ?1, title = ?2, kind = ?3, entrypoint = ?4, enabled = ?5,
|
||||
version = ?6, file_path = ?7, status = ?8, content = ?9, updated_at = ?10
|
||||
WHERE id = ?11",
|
||||
params![
|
||||
s.slug,
|
||||
s.title,
|
||||
script_kind_to_str(&s.kind),
|
||||
s.entrypoint,
|
||||
s.enabled as i64,
|
||||
s.version,
|
||||
s.file_path,
|
||||
script_status_to_str(&s.status),
|
||||
s.content,
|
||||
s.updated_at,
|
||||
s.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_script(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM scripts WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::model::{ScriptKind, ScriptStatus};
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_script(id: &str, slug: &str) -> Script {
|
||||
Script {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
slug: slug.to_string(),
|
||||
title: format!("Script {slug}"),
|
||||
kind: ScriptKind::Macro,
|
||||
entrypoint: "render".to_string(),
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: format!("scripts/{slug}.lua"),
|
||||
status: ScriptStatus::Published,
|
||||
content: Some("return html".into()),
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_by_id() {
|
||||
let db = setup();
|
||||
insert_script(db.conn(), &make_script("s1", "gallery")).unwrap();
|
||||
let s = get_script_by_id(db.conn(), "s1").unwrap();
|
||||
assert_eq!(s.slug, "gallery");
|
||||
assert_eq!(s.kind, ScriptKind::Macro);
|
||||
assert_eq!(s.entrypoint, "render");
|
||||
assert!(s.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_slug() {
|
||||
let db = setup();
|
||||
insert_script(db.conn(), &make_script("s1", "gallery")).unwrap();
|
||||
let s = get_script_by_slug(db.conn(), "p1", "gallery").unwrap();
|
||||
assert_eq!(s.id, "s1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_project() {
|
||||
let db = setup();
|
||||
let mut s1 = make_script("s1", "zebra");
|
||||
s1.title = "Zebra".into();
|
||||
let mut s2 = make_script("s2", "alpha");
|
||||
s2.title = "Alpha".into();
|
||||
insert_script(db.conn(), &s1).unwrap();
|
||||
insert_script(db.conn(), &s2).unwrap();
|
||||
let list = list_scripts_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].title, "Alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_script_fields() {
|
||||
let db = setup();
|
||||
let mut s = make_script("s1", "gallery");
|
||||
insert_script(db.conn(), &s).unwrap();
|
||||
s.kind = ScriptKind::Utility;
|
||||
s.enabled = false;
|
||||
s.version = 3;
|
||||
s.status = ScriptStatus::Draft;
|
||||
s.content = None;
|
||||
s.updated_at = 9999;
|
||||
update_script(db.conn(), &s).unwrap();
|
||||
let fetched = get_script_by_id(db.conn(), "s1").unwrap();
|
||||
assert_eq!(fetched.kind, ScriptKind::Utility);
|
||||
assert!(!fetched.enabled);
|
||||
assert_eq!(fetched.version, 3);
|
||||
assert_eq!(fetched.status, ScriptStatus::Draft);
|
||||
assert!(fetched.content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_script() {
|
||||
let db = setup();
|
||||
insert_script(db.conn(), &make_script("s1", "gallery")).unwrap();
|
||||
delete_script(db.conn(), "s1").unwrap();
|
||||
assert!(get_script_by_id(db.conn(), "s1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_slug_rejected() {
|
||||
let db = setup();
|
||||
insert_script(db.conn(), &make_script("s1", "gallery")).unwrap();
|
||||
let result = insert_script(db.conn(), &make_script("s2", "gallery"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
83
crates/bds-core/src/db/queries/setting.rs
Normal file
83
crates/bds-core/src/db/queries/setting.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{setting_from_row, SETTING_COLUMNS};
|
||||
use crate::model::Setting;
|
||||
|
||||
pub fn get_setting_by_key(conn: &Connection, key: &str) -> rusqlite::Result<Setting> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {SETTING_COLUMNS} FROM settings WHERE key = ?1"),
|
||||
params![key],
|
||||
setting_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_setting_value(
|
||||
conn: &Connection,
|
||||
key: &str,
|
||||
value: &str,
|
||||
updated_at: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
||||
params![key, value, updated_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_all_settings(conn: &Connection) -> rusqlite::Result<Vec<Setting>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {SETTING_COLUMNS} FROM settings ORDER BY key"
|
||||
))?;
|
||||
let rows = stmt.query_map([], setting_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_get() {
|
||||
let db = setup();
|
||||
set_setting_value(db.conn(), "theme", "dark", 1000).unwrap();
|
||||
let s = get_setting_by_key(db.conn(), "theme").unwrap();
|
||||
assert_eq!(s.key, "theme");
|
||||
assert_eq!(s.value, "dark");
|
||||
assert_eq!(s.updated_at, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_updates_existing() {
|
||||
let db = setup();
|
||||
set_setting_value(db.conn(), "theme", "dark", 1000).unwrap();
|
||||
set_setting_value(db.conn(), "theme", "light", 2000).unwrap();
|
||||
let s = get_setting_by_key(db.conn(), "theme").unwrap();
|
||||
assert_eq!(s.value, "light");
|
||||
assert_eq!(s.updated_at, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_all() {
|
||||
let db = setup();
|
||||
set_setting_value(db.conn(), "b_key", "val_b", 1000).unwrap();
|
||||
set_setting_value(db.conn(), "a_key", "val_a", 1000).unwrap();
|
||||
let list = list_all_settings(db.conn()).unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].key, "a_key");
|
||||
assert_eq!(list[1].key, "b_key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_nonexistent_returns_error() {
|
||||
let db = setup();
|
||||
assert!(get_setting_by_key(db.conn(), "nope").is_err());
|
||||
}
|
||||
}
|
||||
157
crates/bds-core/src/db/queries/tag.rs
Normal file
157
crates/bds-core/src/db/queries/tag.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{tag_from_row, TAG_COLUMNS};
|
||||
use crate::model::Tag;
|
||||
|
||||
pub fn insert_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO tags (id, project_id, name, color, post_template_slug, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
tag.id,
|
||||
tag.project_id,
|
||||
tag.name,
|
||||
tag.color,
|
||||
tag.post_template_slug,
|
||||
tag.created_at,
|
||||
tag.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_tag_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Tag> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {TAG_COLUMNS} FROM tags WHERE id = ?1"),
|
||||
params![id],
|
||||
tag_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_tag_by_project_and_name(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
) -> rusqlite::Result<Tag> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {TAG_COLUMNS} FROM tags WHERE project_id = ?1 AND LOWER(name) = LOWER(?2)"
|
||||
),
|
||||
params![project_id, name],
|
||||
tag_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_tags_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Tag>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {TAG_COLUMNS} FROM tags WHERE project_id = ?1 ORDER BY name"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], tag_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE tags SET name = ?1, color = ?2, post_template_slug = ?3, updated_at = ?4
|
||||
WHERE id = ?5",
|
||||
params![
|
||||
tag.name,
|
||||
tag.color,
|
||||
tag.post_template_slug,
|
||||
tag.updated_at,
|
||||
tag.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_tag(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM tags WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_tag(id: &str, name: &str) -> Tag {
|
||||
Tag {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
name: name.to_string(),
|
||||
color: Some("#ff0000".into()),
|
||||
post_template_slug: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_by_id() {
|
||||
let db = setup();
|
||||
insert_tag(db.conn(), &make_tag("t1", "rust")).unwrap();
|
||||
let tag = get_tag_by_id(db.conn(), "t1").unwrap();
|
||||
assert_eq!(tag.name, "rust");
|
||||
assert_eq!(tag.color.as_deref(), Some("#ff0000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_project_and_name_case_insensitive() {
|
||||
let db = setup();
|
||||
insert_tag(db.conn(), &make_tag("t1", "Rust")).unwrap();
|
||||
let tag = get_tag_by_project_and_name(db.conn(), "p1", "rust").unwrap();
|
||||
assert_eq!(tag.id, "t1");
|
||||
let tag = get_tag_by_project_and_name(db.conn(), "p1", "RUST").unwrap();
|
||||
assert_eq!(tag.id, "t1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_ordered_by_name() {
|
||||
let db = setup();
|
||||
insert_tag(db.conn(), &make_tag("t1", "zebra")).unwrap();
|
||||
insert_tag(db.conn(), &make_tag("t2", "alpha")).unwrap();
|
||||
let list = list_tags_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].name, "alpha");
|
||||
assert_eq!(list[1].name, "zebra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_tag_fields() {
|
||||
let db = setup();
|
||||
let mut tag = make_tag("t1", "rust");
|
||||
insert_tag(db.conn(), &tag).unwrap();
|
||||
tag.name = "go".into();
|
||||
tag.color = None;
|
||||
tag.updated_at = 9999;
|
||||
update_tag(db.conn(), &tag).unwrap();
|
||||
let fetched = get_tag_by_id(db.conn(), "t1").unwrap();
|
||||
assert_eq!(fetched.name, "go");
|
||||
assert!(fetched.color.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_tag_removes_row() {
|
||||
let db = setup();
|
||||
insert_tag(db.conn(), &make_tag("t1", "rust")).unwrap();
|
||||
delete_tag(db.conn(), "t1").unwrap();
|
||||
assert!(get_tag_by_id(db.conn(), "t1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_name_rejected() {
|
||||
let db = setup();
|
||||
insert_tag(db.conn(), &make_tag("t1", "rust")).unwrap();
|
||||
let result = insert_tag(db.conn(), &make_tag("t2", "rust"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
190
crates/bds-core/src/db/queries/template.rs
Normal file
190
crates/bds-core/src/db/queries/template.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{
|
||||
template_from_row, template_kind_to_str, template_status_to_str, TEMPLATE_COLUMNS,
|
||||
};
|
||||
use crate::model::Template;
|
||||
|
||||
pub fn insert_template(conn: &Connection, t: &Template) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO templates (
|
||||
id, project_id, slug, title, kind, enabled, version,
|
||||
file_path, status, content, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
t.id,
|
||||
t.project_id,
|
||||
t.slug,
|
||||
t.title,
|
||||
template_kind_to_str(&t.kind),
|
||||
t.enabled as i64,
|
||||
t.version,
|
||||
t.file_path,
|
||||
template_status_to_str(&t.status),
|
||||
t.content,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_template_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Template> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = ?1"),
|
||||
params![id],
|
||||
template_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_template_by_slug(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Template> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 AND slug = ?2"
|
||||
),
|
||||
params![project_id, slug],
|
||||
template_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_templates_by_project(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<Template>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 ORDER BY title"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], template_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_template(conn: &Connection, t: &Template) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE templates SET
|
||||
slug = ?1, title = ?2, kind = ?3, enabled = ?4, version = ?5,
|
||||
file_path = ?6, status = ?7, content = ?8, updated_at = ?9
|
||||
WHERE id = ?10",
|
||||
params![
|
||||
t.slug,
|
||||
t.title,
|
||||
template_kind_to_str(&t.kind),
|
||||
t.enabled as i64,
|
||||
t.version,
|
||||
t.file_path,
|
||||
template_status_to_str(&t.status),
|
||||
t.content,
|
||||
t.updated_at,
|
||||
t.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_template(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM templates WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::model::{TemplateKind, TemplateStatus};
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_tpl(id: &str, slug: &str) -> Template {
|
||||
Template {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
slug: slug.to_string(),
|
||||
title: format!("Template {slug}"),
|
||||
kind: TemplateKind::Post,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: format!("templates/{slug}.liquid"),
|
||||
status: TemplateStatus::Published,
|
||||
content: Some("html".into()),
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_by_id() {
|
||||
let db = setup();
|
||||
insert_template(db.conn(), &make_tpl("t1", "default")).unwrap();
|
||||
let t = get_template_by_id(db.conn(), "t1").unwrap();
|
||||
assert_eq!(t.slug, "default");
|
||||
assert_eq!(t.kind, TemplateKind::Post);
|
||||
assert!(t.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_slug() {
|
||||
let db = setup();
|
||||
insert_template(db.conn(), &make_tpl("t1", "default")).unwrap();
|
||||
let t = get_template_by_slug(db.conn(), "p1", "default").unwrap();
|
||||
assert_eq!(t.id, "t1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_project() {
|
||||
let db = setup();
|
||||
let mut t1 = make_tpl("t1", "zebra");
|
||||
t1.title = "Zebra".into();
|
||||
let mut t2 = make_tpl("t2", "alpha");
|
||||
t2.title = "Alpha".into();
|
||||
insert_template(db.conn(), &t1).unwrap();
|
||||
insert_template(db.conn(), &t2).unwrap();
|
||||
let list = list_templates_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].title, "Alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_template_fields() {
|
||||
let db = setup();
|
||||
let mut t = make_tpl("t1", "default");
|
||||
insert_template(db.conn(), &t).unwrap();
|
||||
t.kind = TemplateKind::List;
|
||||
t.enabled = false;
|
||||
t.version = 5;
|
||||
t.status = TemplateStatus::Draft;
|
||||
t.content = None;
|
||||
t.updated_at = 9999;
|
||||
update_template(db.conn(), &t).unwrap();
|
||||
let fetched = get_template_by_id(db.conn(), "t1").unwrap();
|
||||
assert_eq!(fetched.kind, TemplateKind::List);
|
||||
assert!(!fetched.enabled);
|
||||
assert_eq!(fetched.version, 5);
|
||||
assert_eq!(fetched.status, TemplateStatus::Draft);
|
||||
assert!(fetched.content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_template() {
|
||||
let db = setup();
|
||||
insert_template(db.conn(), &make_tpl("t1", "default")).unwrap();
|
||||
delete_template(db.conn(), "t1").unwrap();
|
||||
assert!(get_template_by_id(db.conn(), "t1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_slug_rejected() {
|
||||
let db = setup();
|
||||
insert_template(db.conn(), &make_tpl("t1", "default")).unwrap();
|
||||
let result = insert_template(db.conn(), &make_tpl("t2", "default"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
28
crates/bds-core/src/engine/context.rs
Normal file
28
crates/bds-core/src/engine/context.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Shared context passed to engine operations.
|
||||
pub struct EngineContext<'a> {
|
||||
pub conn: &'a rusqlite::Connection,
|
||||
pub project_id: String,
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn context_holds_references() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = EngineContext {
|
||||
conn: db.conn(),
|
||||
project_id: "p1".into(),
|
||||
data_dir: dir.path().to_path_buf(),
|
||||
};
|
||||
assert_eq!(ctx.project_id, "p1");
|
||||
assert!(ctx.data_dir.exists());
|
||||
}
|
||||
}
|
||||
81
crates/bds-core/src/engine/error.rs
Normal file
81
crates/bds-core/src/engine/error.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use std::fmt;
|
||||
|
||||
/// Errors produced by engine operations.
|
||||
#[derive(Debug)]
|
||||
pub enum EngineError {
|
||||
Db(rusqlite::Error),
|
||||
Io(std::io::Error),
|
||||
Parse(String),
|
||||
NotFound(String),
|
||||
Conflict(String),
|
||||
Validation(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for EngineError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Db(e) => write!(f, "database error: {e}"),
|
||||
Self::Io(e) => write!(f, "I/O error: {e}"),
|
||||
Self::Parse(msg) => write!(f, "parse error: {msg}"),
|
||||
Self::NotFound(msg) => write!(f, "not found: {msg}"),
|
||||
Self::Conflict(msg) => write!(f, "conflict: {msg}"),
|
||||
Self::Validation(msg) => write!(f, "validation error: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EngineError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Db(e) => Some(e),
|
||||
Self::Io(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for EngineError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
Self::Db(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for EngineError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for EngineError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
Self::Parse(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_yaml::Error> for EngineError {
|
||||
fn from(e: serde_yaml::Error) -> Self {
|
||||
Self::Parse(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type EngineResult<T> = Result<T, EngineError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn display_variants() {
|
||||
assert!(EngineError::Parse("bad yaml".into()).to_string().contains("parse error"));
|
||||
assert!(EngineError::NotFound("post 123".into()).to_string().contains("not found"));
|
||||
assert!(EngineError::Conflict("slug taken".into()).to_string().contains("conflict"));
|
||||
assert!(EngineError::Validation("title empty".into()).to_string().contains("validation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_io_error() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
|
||||
let engine_err = EngineError::from(io_err);
|
||||
assert!(matches!(engine_err, EngineError::Io(_)));
|
||||
}
|
||||
}
|
||||
987
crates/bds-core/src/engine/media.rs
Normal file
987
crates/bds-core/src/engine/media.rs
Normal file
@@ -0,0 +1,987 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::db::fts;
|
||||
use crate::db::queries::media as qm;
|
||||
use crate::db::queries::media_translation as qmt;
|
||||
use crate::db::queries::post_media as qpm;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Media, MediaTranslation};
|
||||
use crate::util::sidecar::{MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar};
|
||||
use crate::util::thumbnail::{
|
||||
generate_all_thumbnails, image_dimensions, mime_from_extension,
|
||||
ThumbnailFormat, THUMBNAIL_SIZES,
|
||||
};
|
||||
use crate::util::{
|
||||
atomic_write_str, content_hash, media_dir_path, media_sidecar_path,
|
||||
media_translation_sidecar_path, now_unix_ms,
|
||||
};
|
||||
|
||||
/// Report returned by `rebuild_media_from_filesystem`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MediaRebuildReport {
|
||||
pub media_created: usize,
|
||||
pub media_updated: usize,
|
||||
pub translations_created: usize,
|
||||
pub translations_updated: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Import a media file (image, etc.) into the project.
|
||||
pub fn import_media(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
source_path: &Path,
|
||||
original_name: &str,
|
||||
title: Option<&str>,
|
||||
alt: Option<&str>,
|
||||
caption: Option<&str>,
|
||||
author: Option<&str>,
|
||||
language: Option<&str>,
|
||||
tags: Vec<String>,
|
||||
) -> EngineResult<Media> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = now_unix_ms();
|
||||
|
||||
// Derive extension from original_name
|
||||
let ext = Path::new(original_name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("bin");
|
||||
let filename = format!("{id}.{ext}");
|
||||
|
||||
// Compute target directory and copy file
|
||||
let dir_path = media_dir_path(now);
|
||||
let rel_file_path = format!("{dir_path}{filename}");
|
||||
let abs_file_path = data_dir.join(&rel_file_path);
|
||||
|
||||
if let Some(parent) = abs_file_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(source_path, &abs_file_path)?;
|
||||
|
||||
// Try to get image dimensions (silently ignore errors for non-image files)
|
||||
let (width, height) = image_dimensions(&abs_file_path)
|
||||
.map(|(w, h)| (Some(w as i32), Some(h as i32)))
|
||||
.unwrap_or((None, None));
|
||||
|
||||
// Detect MIME type from extension
|
||||
let mime_type = mime_from_extension(ext).to_string();
|
||||
|
||||
// Get file size
|
||||
let file_size = fs::metadata(&abs_file_path)?.len() as i64;
|
||||
|
||||
// Compute sidecar path
|
||||
let sidecar_rel = media_sidecar_path(&rel_file_path);
|
||||
|
||||
// Compute checksum of the copied file
|
||||
let file_bytes = fs::read(&abs_file_path)?;
|
||||
let checksum = content_hash(&file_bytes);
|
||||
|
||||
// Generate thumbnails (silently ignore errors for non-image files)
|
||||
let thumbnails_dir = data_dir.join("thumbnails");
|
||||
let _ = generate_all_thumbnails(&abs_file_path, &thumbnails_dir, &id);
|
||||
|
||||
let media = Media {
|
||||
id: id.clone(),
|
||||
project_id: project_id.to_string(),
|
||||
filename,
|
||||
original_name: original_name.to_string(),
|
||||
mime_type,
|
||||
size: file_size,
|
||||
width,
|
||||
height,
|
||||
title: title.map(|s| s.to_string()),
|
||||
alt: alt.map(|s| s.to_string()),
|
||||
caption: caption.map(|s| s.to_string()),
|
||||
author: author.map(|s| s.to_string()),
|
||||
language: language.map(|s| s.to_string()),
|
||||
file_path: rel_file_path,
|
||||
sidecar_path: sidecar_rel.clone(),
|
||||
checksum: Some(checksum),
|
||||
tags,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
// Write sidecar
|
||||
let sidecar = MediaSidecar::from_media(&media, &[]);
|
||||
let abs_sidecar = data_dir.join(&sidecar_rel);
|
||||
atomic_write_str(&abs_sidecar, &sidecar.to_string())?;
|
||||
|
||||
// Insert into DB
|
||||
qm::insert_media(conn, &media)?;
|
||||
|
||||
// Index in FTS
|
||||
fts_index_media(conn, &media)?;
|
||||
|
||||
Ok(media)
|
||||
}
|
||||
|
||||
/// Update a media item's metadata fields.
|
||||
pub fn update_media(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
media_id: &str,
|
||||
title: Option<Option<&str>>,
|
||||
alt: Option<Option<&str>>,
|
||||
caption: Option<Option<&str>>,
|
||||
author: Option<Option<&str>>,
|
||||
language: Option<Option<&str>>,
|
||||
tags: Option<Vec<String>>,
|
||||
) -> EngineResult<Media> {
|
||||
let mut media = qm::get_media_by_id(conn, media_id)?;
|
||||
|
||||
if let Some(t) = title {
|
||||
media.title = t.map(|s| s.to_string());
|
||||
}
|
||||
if let Some(a) = alt {
|
||||
media.alt = a.map(|s| s.to_string());
|
||||
}
|
||||
if let Some(c) = caption {
|
||||
media.caption = c.map(|s| s.to_string());
|
||||
}
|
||||
if let Some(a) = author {
|
||||
media.author = a.map(|s| s.to_string());
|
||||
}
|
||||
if let Some(l) = language {
|
||||
media.language = l.map(|s| s.to_string());
|
||||
}
|
||||
if let Some(t) = tags {
|
||||
media.tags = t;
|
||||
}
|
||||
|
||||
media.updated_at = now_unix_ms();
|
||||
qm::update_media(conn, &media)?;
|
||||
|
||||
// Rewrite sidecar (need linked_post_ids from post_media table)
|
||||
let linked = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default();
|
||||
let linked_post_ids: Vec<String> = linked.iter().map(|pm| pm.post_id.clone()).collect();
|
||||
let sidecar = MediaSidecar::from_media(&media, &linked_post_ids);
|
||||
let abs_sidecar = data_dir.join(&media.sidecar_path);
|
||||
atomic_write_str(&abs_sidecar, &sidecar.to_string())?;
|
||||
|
||||
// Re-index FTS
|
||||
fts_index_media(conn, &media)?;
|
||||
|
||||
Ok(media)
|
||||
}
|
||||
|
||||
/// Delete a media item and all related artifacts.
|
||||
pub fn delete_media(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
media_id: &str,
|
||||
) -> EngineResult<()> {
|
||||
let media = qm::get_media_by_id(conn, media_id)?;
|
||||
|
||||
// Delete binary file
|
||||
let abs_file = data_dir.join(&media.file_path);
|
||||
if abs_file.exists() {
|
||||
fs::remove_file(&abs_file)?;
|
||||
}
|
||||
|
||||
// Delete sidecar file
|
||||
let abs_sidecar = data_dir.join(&media.sidecar_path);
|
||||
if abs_sidecar.exists() {
|
||||
fs::remove_file(&abs_sidecar)?;
|
||||
}
|
||||
|
||||
// Delete all translation sidecar files
|
||||
let translations = qmt::list_media_translations_by_media(conn, media_id).unwrap_or_default();
|
||||
for t in &translations {
|
||||
let trans_sidecar = media_translation_sidecar_path(&media.file_path, &t.language);
|
||||
let abs_trans = data_dir.join(&trans_sidecar);
|
||||
if abs_trans.exists() {
|
||||
fs::remove_file(&abs_trans)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all thumbnails
|
||||
let ext_map = |fmt: &ThumbnailFormat| -> &str {
|
||||
match fmt {
|
||||
ThumbnailFormat::Webp => "webp",
|
||||
ThumbnailFormat::Jpeg => "jpg",
|
||||
}
|
||||
};
|
||||
let prefix = &media_id[..2.min(media_id.len())];
|
||||
for size in THUMBNAIL_SIZES {
|
||||
let ext = ext_map(&size.format);
|
||||
let thumb_rel = format!("thumbnails/{prefix}/{media_id}-{}.{ext}", size.name);
|
||||
let abs_thumb = data_dir.join(&thumb_rel);
|
||||
if abs_thumb.exists() {
|
||||
let _ = fs::remove_file(&abs_thumb);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete media translations from DB
|
||||
for t in &translations {
|
||||
qmt::delete_media_translation(conn, media_id, &t.language)?;
|
||||
}
|
||||
|
||||
// Delete post_media links from DB
|
||||
let links = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default();
|
||||
for link in &links {
|
||||
qpm::unlink_media(conn, &link.post_id, media_id)?;
|
||||
}
|
||||
|
||||
// Remove from FTS index
|
||||
fts::remove_media_from_index(conn, media_id)?;
|
||||
|
||||
// Delete from media table
|
||||
qm::delete_media(conn, media_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create or update a translation for a media item.
|
||||
pub fn upsert_media_translation(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
media_id: &str,
|
||||
language: &str,
|
||||
title: Option<&str>,
|
||||
alt: Option<&str>,
|
||||
caption: Option<&str>,
|
||||
) -> EngineResult<MediaTranslation> {
|
||||
let media = qm::get_media_by_id(conn, media_id)?;
|
||||
let now = now_unix_ms();
|
||||
|
||||
// Check if translation already exists
|
||||
let existing = qmt::get_media_translation_by_media_and_language(conn, media_id, language);
|
||||
let translation = match existing {
|
||||
Ok(mut t) => {
|
||||
t.title = title.map(|s| s.to_string());
|
||||
t.alt = alt.map(|s| s.to_string());
|
||||
t.caption = caption.map(|s| s.to_string());
|
||||
t.updated_at = now;
|
||||
qmt::upsert_media_translation(conn, &t)?;
|
||||
t
|
||||
}
|
||||
Err(_) => {
|
||||
let t = MediaTranslation {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
project_id: media.project_id.clone(),
|
||||
translation_for: media_id.to_string(),
|
||||
language: language.to_string(),
|
||||
title: title.map(|s| s.to_string()),
|
||||
alt: alt.map(|s| s.to_string()),
|
||||
caption: caption.map(|s| s.to_string()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
qmt::upsert_media_translation(conn, &t)?;
|
||||
t
|
||||
}
|
||||
};
|
||||
|
||||
// Write translation sidecar file
|
||||
let trans_sidecar = MediaTranslationSidecar {
|
||||
translation_for: media_id.to_string(),
|
||||
language: language.to_string(),
|
||||
title: title.map(|s| s.to_string()),
|
||||
alt: alt.map(|s| s.to_string()),
|
||||
caption: caption.map(|s| s.to_string()),
|
||||
};
|
||||
let sidecar_rel = media_translation_sidecar_path(&media.file_path, language);
|
||||
let abs_sidecar = data_dir.join(&sidecar_rel);
|
||||
atomic_write_str(&abs_sidecar, &trans_sidecar.to_string())?;
|
||||
|
||||
// Re-index FTS for parent media
|
||||
fts_index_media(conn, &media)?;
|
||||
|
||||
Ok(translation)
|
||||
}
|
||||
|
||||
/// Delete a media translation and its sidecar file.
|
||||
pub fn delete_media_translation(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
media_id: &str,
|
||||
language: &str,
|
||||
) -> EngineResult<()> {
|
||||
let media = qm::get_media_by_id(conn, media_id)?;
|
||||
|
||||
// Delete translation sidecar file
|
||||
let sidecar_rel = media_translation_sidecar_path(&media.file_path, language);
|
||||
let abs_sidecar = data_dir.join(&sidecar_rel);
|
||||
if abs_sidecar.exists() {
|
||||
fs::remove_file(&abs_sidecar)?;
|
||||
}
|
||||
|
||||
// Delete from DB
|
||||
qmt::delete_media_translation(conn, media_id, language)?;
|
||||
|
||||
// Re-index FTS for parent media
|
||||
fts_index_media(conn, &media)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild media entries from filesystem. Walk `media/` directory, parse sidecars, upsert into DB.
|
||||
pub fn rebuild_media_from_filesystem(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<MediaRebuildReport> {
|
||||
let mut report = MediaRebuildReport::default();
|
||||
let media_dir = data_dir.join("media");
|
||||
|
||||
if !media_dir.exists() {
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
let mut canonical_sidecars = Vec::new();
|
||||
let mut translation_sidecars = Vec::new();
|
||||
|
||||
for entry in WalkDir::new(&media_dir)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let file_name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if !file_name.ends_with(".meta") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine if this is a translation sidecar: {name}.{lang}.meta
|
||||
// where lang is exactly 2 lowercase letters.
|
||||
if is_translation_sidecar(file_name) {
|
||||
translation_sidecars.push(path.to_path_buf());
|
||||
} else {
|
||||
canonical_sidecars.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
// Process canonical sidecars first
|
||||
for path in &canonical_sidecars {
|
||||
match rebuild_canonical_media(conn, data_dir, project_id, path) {
|
||||
Ok(created) => {
|
||||
if created {
|
||||
report.media_created += 1;
|
||||
} else {
|
||||
report.media_updated += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
report.errors.push(format!("{}: {e}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process translation sidecars
|
||||
for path in &translation_sidecars {
|
||||
match rebuild_translation_sidecar(conn, data_dir, project_id, path) {
|
||||
Ok(created) => {
|
||||
if created {
|
||||
report.translations_created += 1;
|
||||
} else {
|
||||
report.translations_updated += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
report.errors.push(format!("{}: {e}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-index FTS for all media in this project
|
||||
let all_media = qm::list_media_by_project(conn, project_id)?;
|
||||
for m in &all_media {
|
||||
fts_index_media(conn, m)?;
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
/// Index a media item in FTS, gathering translation texts.
|
||||
fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> {
|
||||
let translations = qmt::list_media_translations_by_media(conn, &media.id).unwrap_or_default();
|
||||
let translation_texts: Vec<String> = translations
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(ref title) = t.title {
|
||||
parts.push(title.clone());
|
||||
}
|
||||
if let Some(ref alt) = t.alt {
|
||||
parts.push(alt.clone());
|
||||
}
|
||||
if let Some(ref caption) = t.caption {
|
||||
parts.push(caption.clone());
|
||||
}
|
||||
parts.join(" ")
|
||||
})
|
||||
.collect();
|
||||
|
||||
let lang = media.language.as_deref().unwrap_or("en");
|
||||
fts::index_media(
|
||||
conn,
|
||||
&media.id,
|
||||
media.title.as_deref(),
|
||||
media.alt.as_deref(),
|
||||
media.caption.as_deref(),
|
||||
&media.original_name,
|
||||
&media.tags,
|
||||
&translation_texts,
|
||||
lang,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a .meta filename is a translation sidecar: `{name}.{lang}.meta`
|
||||
/// where lang is exactly 2 lowercase ASCII letters.
|
||||
fn is_translation_sidecar(file_name: &str) -> bool {
|
||||
// file_name ends with .meta
|
||||
// Strip .meta suffix, then check if remaining ends with .{2-letter-lang}
|
||||
let without_meta = match file_name.strip_suffix(".meta") {
|
||||
Some(s) => s,
|
||||
None => return false,
|
||||
};
|
||||
// Find the last dot in what remains
|
||||
if let Some(dot_pos) = without_meta.rfind('.') {
|
||||
let suffix = &without_meta[dot_pos + 1..];
|
||||
// Must also have another dot before this (the extension of the media file)
|
||||
// e.g. "foo.jpg.de" -> dot_pos points to the dot before "de"
|
||||
// "foo.jpg" would be the canonical sidecar (no extra dot+lang)
|
||||
suffix.len() == 2 && suffix.chars().all(|c| c.is_ascii_lowercase()) && dot_pos > 0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild a canonical media from its sidecar file. Returns true if created, false if updated.
|
||||
fn rebuild_canonical_media(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
sidecar_path: &Path,
|
||||
) -> EngineResult<bool> {
|
||||
let content = fs::read_to_string(sidecar_path)?;
|
||||
let sc = read_sidecar(&content).map_err(EngineError::Parse)?;
|
||||
|
||||
// Derive file_path: the sidecar path minus ".meta" suffix, relative to data_dir
|
||||
let sidecar_rel = sidecar_path
|
||||
.strip_prefix(data_dir)
|
||||
.unwrap_or(sidecar_path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let file_path = sidecar_rel
|
||||
.strip_suffix(".meta")
|
||||
.unwrap_or(&sidecar_rel)
|
||||
.to_string();
|
||||
|
||||
let abs_file = data_dir.join(&file_path);
|
||||
|
||||
// Get file size (if file exists)
|
||||
let file_size = if abs_file.exists() {
|
||||
fs::metadata(&abs_file)?.len() as i64
|
||||
} else {
|
||||
sc.size
|
||||
};
|
||||
|
||||
// Get checksum (if file exists)
|
||||
let checksum = if abs_file.exists() {
|
||||
let bytes = fs::read(&abs_file)?;
|
||||
Some(content_hash(&bytes))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get dimensions (if file exists and is an image)
|
||||
let (width, height) = if abs_file.exists() {
|
||||
image_dimensions(&abs_file)
|
||||
.map(|(w, h)| (Some(w as i32), Some(h as i32)))
|
||||
.unwrap_or((sc.width, sc.height))
|
||||
} else {
|
||||
(sc.width, sc.height)
|
||||
};
|
||||
|
||||
// Derive filename from file_path
|
||||
let filename = Path::new(&file_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let now = now_unix_ms();
|
||||
|
||||
let existing = qm::get_media_by_id(conn, &sc.id);
|
||||
match existing {
|
||||
Ok(mut media) => {
|
||||
// Update existing media
|
||||
media.original_name = sc.original_name;
|
||||
media.mime_type = sc.mime_type;
|
||||
media.size = file_size;
|
||||
media.width = width;
|
||||
media.height = height;
|
||||
media.title = sc.title;
|
||||
media.alt = sc.alt;
|
||||
media.caption = sc.caption;
|
||||
media.author = sc.author;
|
||||
media.language = sc.language;
|
||||
media.file_path = file_path;
|
||||
media.sidecar_path = sidecar_rel;
|
||||
media.checksum = checksum;
|
||||
media.tags = sc.tags;
|
||||
media.updated_at = now;
|
||||
qm::update_media(conn, &media)?;
|
||||
Ok(false)
|
||||
}
|
||||
Err(_) => {
|
||||
let media = Media {
|
||||
id: sc.id,
|
||||
project_id: project_id.to_string(),
|
||||
filename,
|
||||
original_name: sc.original_name,
|
||||
mime_type: sc.mime_type,
|
||||
size: file_size,
|
||||
width,
|
||||
height,
|
||||
title: sc.title,
|
||||
alt: sc.alt,
|
||||
caption: sc.caption,
|
||||
author: sc.author,
|
||||
language: sc.language,
|
||||
file_path,
|
||||
sidecar_path: sidecar_rel,
|
||||
checksum,
|
||||
tags: sc.tags,
|
||||
created_at: sc.created_at,
|
||||
updated_at: now,
|
||||
};
|
||||
qm::insert_media(conn, &media)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild a translation from a `*.{lang}.meta` sidecar. Returns true if created, false if updated.
|
||||
fn rebuild_translation_sidecar(
|
||||
conn: &Connection,
|
||||
_data_dir: &Path,
|
||||
project_id: &str,
|
||||
sidecar_path: &Path,
|
||||
) -> EngineResult<bool> {
|
||||
let content = fs::read_to_string(sidecar_path)?;
|
||||
let sc = read_translation_sidecar(&content).map_err(EngineError::Parse)?;
|
||||
|
||||
// Check parent media exists
|
||||
let _media = qm::get_media_by_id(conn, &sc.translation_for).map_err(|_| {
|
||||
EngineError::NotFound(format!(
|
||||
"parent media '{}' not found for translation",
|
||||
sc.translation_for
|
||||
))
|
||||
})?;
|
||||
|
||||
let now = now_unix_ms();
|
||||
|
||||
let existing =
|
||||
qmt::get_media_translation_by_media_and_language(conn, &sc.translation_for, &sc.language);
|
||||
match existing {
|
||||
Ok(mut t) => {
|
||||
t.title = sc.title;
|
||||
t.alt = sc.alt;
|
||||
t.caption = sc.caption;
|
||||
t.updated_at = now;
|
||||
qmt::upsert_media_translation(conn, &t)?;
|
||||
Ok(false)
|
||||
}
|
||||
Err(_) => {
|
||||
let t = MediaTranslation {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
translation_for: sc.translation_for,
|
||||
language: sc.language,
|
||||
title: sc.title,
|
||||
alt: sc.alt,
|
||||
caption: sc.caption,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
qmt::upsert_media_translation(conn, &t)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::fts::ensure_fts_tables;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use image::DynamicImage;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
(db, dir)
|
||||
}
|
||||
|
||||
/// Create a simple 100x80 PNG in the given directory.
|
||||
fn create_test_png(dir: &Path) -> std::path::PathBuf {
|
||||
let path = dir.join("test-source.png");
|
||||
let img = DynamicImage::new_rgb8(100, 80);
|
||||
img.save(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_media_creates_artifacts() {
|
||||
let (db, dir) = setup();
|
||||
let source = create_test_png(dir.path());
|
||||
|
||||
let media = import_media(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&source,
|
||||
"photo.png",
|
||||
Some("My Photo"),
|
||||
Some("A photo"),
|
||||
None,
|
||||
Some("Alice"),
|
||||
Some("en"),
|
||||
vec!["nature".into()],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify DB entry
|
||||
let from_db = qm::get_media_by_id(db.conn(), &media.id).unwrap();
|
||||
assert_eq!(from_db.original_name, "photo.png");
|
||||
assert_eq!(from_db.title.as_deref(), Some("My Photo"));
|
||||
assert_eq!(from_db.alt.as_deref(), Some("A photo"));
|
||||
assert_eq!(from_db.author.as_deref(), Some("Alice"));
|
||||
assert_eq!(from_db.language.as_deref(), Some("en"));
|
||||
assert_eq!(from_db.tags, vec!["nature"]);
|
||||
assert_eq!(from_db.mime_type, "image/png");
|
||||
assert_eq!(from_db.width, Some(100));
|
||||
assert_eq!(from_db.height, Some(80));
|
||||
assert!(from_db.checksum.is_some());
|
||||
assert!(from_db.size > 0);
|
||||
|
||||
// Verify binary file exists
|
||||
let abs_file = dir.path().join(&from_db.file_path);
|
||||
assert!(abs_file.exists(), "binary file should exist");
|
||||
|
||||
// Verify sidecar exists
|
||||
let abs_sidecar = dir.path().join(&from_db.sidecar_path);
|
||||
assert!(abs_sidecar.exists(), "sidecar should exist");
|
||||
|
||||
// Verify sidecar content is parseable
|
||||
let sidecar_content = fs::read_to_string(&abs_sidecar).unwrap();
|
||||
let sc = read_sidecar(&sidecar_content).unwrap();
|
||||
assert_eq!(sc.id, media.id);
|
||||
assert_eq!(sc.original_name, "photo.png");
|
||||
|
||||
// Verify thumbnails exist
|
||||
let prefix = &media.id[..2];
|
||||
for size in THUMBNAIL_SIZES {
|
||||
let ext = match size.format {
|
||||
ThumbnailFormat::Webp => "webp",
|
||||
ThumbnailFormat::Jpeg => "jpg",
|
||||
};
|
||||
let thumb = dir
|
||||
.path()
|
||||
.join("thumbnails")
|
||||
.join(prefix)
|
||||
.join(format!("{}-{}.{ext}", media.id, size.name));
|
||||
assert!(thumb.exists(), "thumbnail {} should exist", size.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_media_rewrites_sidecar() {
|
||||
let (db, dir) = setup();
|
||||
let source = create_test_png(dir.path());
|
||||
|
||||
let media = import_media(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&source,
|
||||
"photo.png",
|
||||
Some("Original Title"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Read original sidecar
|
||||
let abs_sidecar = dir.path().join(&media.sidecar_path);
|
||||
let original_content = fs::read_to_string(&abs_sidecar).unwrap();
|
||||
|
||||
// Update
|
||||
let updated = update_media(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&media.id,
|
||||
Some(Some("New Title")),
|
||||
Some(Some("New alt")),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(vec!["updated-tag".into()]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updated.title.as_deref(), Some("New Title"));
|
||||
assert_eq!(updated.alt.as_deref(), Some("New alt"));
|
||||
assert_eq!(updated.tags, vec!["updated-tag"]);
|
||||
|
||||
// Verify sidecar was rewritten
|
||||
let new_content = fs::read_to_string(&abs_sidecar).unwrap();
|
||||
assert_ne!(original_content, new_content);
|
||||
let sc = read_sidecar(&new_content).unwrap();
|
||||
assert_eq!(sc.title.as_deref(), Some("New Title"));
|
||||
assert_eq!(sc.tags, vec!["updated-tag"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_media_removes_everything() {
|
||||
let (db, dir) = setup();
|
||||
let source = create_test_png(dir.path());
|
||||
|
||||
let media = import_media(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&source,
|
||||
"photo.png",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let abs_file = dir.path().join(&media.file_path);
|
||||
let abs_sidecar = dir.path().join(&media.sidecar_path);
|
||||
assert!(abs_file.exists());
|
||||
assert!(abs_sidecar.exists());
|
||||
|
||||
// Delete
|
||||
delete_media(db.conn(), dir.path(), &media.id).unwrap();
|
||||
|
||||
// Verify file gone
|
||||
assert!(!abs_file.exists(), "binary file should be removed");
|
||||
|
||||
// Verify sidecar gone
|
||||
assert!(!abs_sidecar.exists(), "sidecar should be removed");
|
||||
|
||||
// Verify DB entry gone
|
||||
assert!(qm::get_media_by_id(db.conn(), &media.id).is_err());
|
||||
|
||||
// Verify thumbnails gone
|
||||
let prefix = &media.id[..2];
|
||||
for size in THUMBNAIL_SIZES {
|
||||
let ext = match size.format {
|
||||
ThumbnailFormat::Webp => "webp",
|
||||
ThumbnailFormat::Jpeg => "jpg",
|
||||
};
|
||||
let thumb = dir
|
||||
.path()
|
||||
.join("thumbnails")
|
||||
.join(prefix)
|
||||
.join(format!("{}-{}.{ext}", media.id, size.name));
|
||||
assert!(!thumb.exists(), "thumbnail {} should be removed", size.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_media_translation_writes_sidecar() {
|
||||
let (db, dir) = setup();
|
||||
let source = create_test_png(dir.path());
|
||||
|
||||
let media = import_media(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&source,
|
||||
"photo.png",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create translation
|
||||
let t = upsert_media_translation(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&media.id,
|
||||
"de",
|
||||
Some("Deutsches Foto"),
|
||||
Some("Ein Foto"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(t.language, "de");
|
||||
assert_eq!(t.title.as_deref(), Some("Deutsches Foto"));
|
||||
|
||||
// Verify translation sidecar file
|
||||
let sidecar_rel = media_translation_sidecar_path(&media.file_path, "de");
|
||||
let abs_sidecar = dir.path().join(&sidecar_rel);
|
||||
assert!(abs_sidecar.exists(), "translation sidecar should exist");
|
||||
|
||||
let content = fs::read_to_string(&abs_sidecar).unwrap();
|
||||
let sc = read_translation_sidecar(&content).unwrap();
|
||||
assert_eq!(sc.language, "de");
|
||||
assert_eq!(sc.title.as_deref(), Some("Deutsches Foto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_media_translation_removes_sidecar() {
|
||||
let (db, dir) = setup();
|
||||
let source = create_test_png(dir.path());
|
||||
|
||||
let media = import_media(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&source,
|
||||
"photo.png",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create translation
|
||||
upsert_media_translation(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&media.id,
|
||||
"de",
|
||||
Some("Titel"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sidecar_rel = media_translation_sidecar_path(&media.file_path, "de");
|
||||
let abs_sidecar = dir.path().join(&sidecar_rel);
|
||||
assert!(abs_sidecar.exists());
|
||||
|
||||
// Delete translation
|
||||
delete_media_translation(db.conn(), dir.path(), &media.id, "de").unwrap();
|
||||
|
||||
// Sidecar should be gone
|
||||
assert!(!abs_sidecar.exists(), "translation sidecar should be removed");
|
||||
|
||||
// DB entry should be gone
|
||||
assert!(
|
||||
qmt::get_media_translation_by_media_and_language(db.conn(), &media.id, "de").is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_media_from_filesystem_test() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
// Create a fake media file and its sidecar manually
|
||||
let media_subdir = dir.path().join("media").join("2024").join("01");
|
||||
fs::create_dir_all(&media_subdir).unwrap();
|
||||
|
||||
// Write a dummy binary file
|
||||
let media_file = media_subdir.join("abcdef12-test-uuid.png");
|
||||
fs::write(&media_file, b"fake-png-data").unwrap();
|
||||
|
||||
// Write a canonical sidecar
|
||||
let sidecar_content = "\
|
||||
---
|
||||
id: abcdef12-test-uuid
|
||||
originalName: \"photo.png\"
|
||||
mimeType: image/png
|
||||
size: 13
|
||||
title: \"Rebuild Test\"
|
||||
alt: \"An image\"
|
||||
createdAt: 2024-01-15T12:00:00.000Z
|
||||
updatedAt: 2024-01-15T12:00:00.000Z
|
||||
tags: [\"test\"]
|
||||
---";
|
||||
fs::write(media_subdir.join("abcdef12-test-uuid.png.meta"), sidecar_content).unwrap();
|
||||
|
||||
// Write a translation sidecar
|
||||
let trans_sidecar_content = "\
|
||||
---
|
||||
translationFor: abcdef12-test-uuid
|
||||
language: de
|
||||
title: \"Wiederherstellungstest\"
|
||||
alt: \"Ein Bild\"
|
||||
---";
|
||||
fs::write(
|
||||
media_subdir.join("abcdef12-test-uuid.png.de.meta"),
|
||||
trans_sidecar_content,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Run rebuild
|
||||
let report = rebuild_media_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.media_created, 1);
|
||||
assert_eq!(report.translations_created, 1);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
|
||||
// Verify media in DB
|
||||
let media = qm::get_media_by_id(db.conn(), "abcdef12-test-uuid").unwrap();
|
||||
assert_eq!(media.title.as_deref(), Some("Rebuild Test"));
|
||||
assert_eq!(media.tags, vec!["test"]);
|
||||
assert_eq!(media.original_name, "photo.png");
|
||||
|
||||
// Verify translation in DB
|
||||
let trans =
|
||||
qmt::get_media_translation_by_media_and_language(db.conn(), "abcdef12-test-uuid", "de")
|
||||
.unwrap();
|
||||
assert_eq!(trans.title.as_deref(), Some("Wiederherstellungstest"));
|
||||
|
||||
// Run rebuild again - should update, not create
|
||||
let report2 = rebuild_media_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert_eq!(report2.media_created, 0);
|
||||
assert_eq!(report2.media_updated, 1);
|
||||
assert_eq!(report2.translations_created, 0);
|
||||
assert_eq!(report2.translations_updated, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_translation_sidecar_detection() {
|
||||
assert!(is_translation_sidecar("photo.jpg.de.meta"));
|
||||
assert!(is_translation_sidecar("photo.jpg.fr.meta"));
|
||||
assert!(is_translation_sidecar("uuid.png.en.meta"));
|
||||
assert!(!is_translation_sidecar("photo.jpg.meta"));
|
||||
assert!(!is_translation_sidecar("photo.meta"));
|
||||
assert!(!is_translation_sidecar("photo.jpg.123.meta"));
|
||||
assert!(!is_translation_sidecar("photo.jpg.DEU.meta"));
|
||||
}
|
||||
}
|
||||
318
crates/bds-core/src/engine/meta.rs
Normal file
318
crates/bds-core/src/engine/meta.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::engine::EngineResult;
|
||||
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
||||
use crate::model::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(())
|
||||
}
|
||||
|
||||
// ── 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(|a, b| a.to_lowercase().cmp(&b.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(|a, b| a.name.to_lowercase().cmp(&b.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 {
|
||||
render_in_lists: true,
|
||||
show_title: true,
|
||||
title: None,
|
||||
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(())
|
||||
}
|
||||
|
||||
#[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,
|
||||
blogmark_category: None,
|
||||
pico_theme: None,
|
||||
python_runtime_mode: 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 {
|
||||
render_in_lists: true,
|
||||
show_title: true,
|
||||
title: None,
|
||||
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);
|
||||
}
|
||||
|
||||
// ── 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(), &vec!["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(), &vec!["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(), &vec!["article".into(), "page".into()]).unwrap();
|
||||
let mut meta = HashMap::new();
|
||||
meta.insert(
|
||||
"article".to_string(),
|
||||
CategorySettings {
|
||||
render_in_lists: true,
|
||||
show_title: true,
|
||||
title: None,
|
||||
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"));
|
||||
}
|
||||
}
|
||||
980
crates/bds-core/src/engine/metadata_diff.rs
Normal file
980
crates/bds-core/src/engine/metadata_diff.rs
Normal file
@@ -0,0 +1,980 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::db::from_row::{script_kind_to_str, template_kind_to_str};
|
||||
use crate::db::queries::media as qm;
|
||||
use crate::db::queries::post as qp;
|
||||
use crate::db::queries::post_translation as qt;
|
||||
use crate::db::queries::script as qs;
|
||||
use crate::db::queries::template as qtpl;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Media, Post, PostStatus, PostTranslation, Script, Template};
|
||||
use crate::util::frontmatter::{read_post_file, read_script_file, read_template_file, read_translation_file};
|
||||
use crate::util::sidecar::read_sidecar;
|
||||
|
||||
/// A single field difference.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffField {
|
||||
pub field_name: String,
|
||||
pub db_value: String,
|
||||
pub file_value: String,
|
||||
}
|
||||
|
||||
/// Diff for a single entity.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EntityDiff {
|
||||
pub entity_type: String,
|
||||
pub entity_id: String,
|
||||
pub file_path: String,
|
||||
pub fields: Vec<DiffField>,
|
||||
}
|
||||
|
||||
/// An orphan file (exists on disk but not in DB, or vice versa).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrphanFile {
|
||||
pub file_path: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Complete diff report.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DiffReport {
|
||||
pub diffs: Vec<EntityDiff>,
|
||||
pub orphans: Vec<OrphanFile>,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Compare DB state vs filesystem files and report all differences.
|
||||
///
|
||||
/// This function does NOT modify anything -- it only reports differences.
|
||||
pub fn compute_metadata_diff(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<DiffReport> {
|
||||
let mut report = DiffReport::default();
|
||||
|
||||
// 1. Diff posts
|
||||
let posts = qp::list_posts_by_project(conn, project_id)?;
|
||||
for post in &posts {
|
||||
if post.file_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match diff_post(data_dir, post) {
|
||||
Ok(Some(d)) => report.diffs.push(d),
|
||||
Ok(None) => {}
|
||||
Err(e) => report.errors.push(format!("post {}: {e}", post.id)),
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Diff translations
|
||||
for post in &posts {
|
||||
let translations = qt::list_post_translations_by_post(conn, &post.id)?;
|
||||
for t in &translations {
|
||||
if t.file_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match diff_translation(data_dir, t) {
|
||||
Ok(Some(d)) => report.diffs.push(d),
|
||||
Ok(None) => {}
|
||||
Err(e) => report.errors.push(format!("translation {}: {e}", t.id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Diff media
|
||||
let media_items = qm::list_media_by_project(conn, project_id)?;
|
||||
for m in &media_items {
|
||||
if m.sidecar_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match diff_media(data_dir, m) {
|
||||
Ok(Some(d)) => report.diffs.push(d),
|
||||
Ok(None) => {}
|
||||
Err(e) => report.errors.push(format!("media {}: {e}", m.id)),
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Diff templates
|
||||
let templates = qtpl::list_templates_by_project(conn, project_id)?;
|
||||
for t in &templates {
|
||||
if t.file_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match diff_template(data_dir, t) {
|
||||
Ok(Some(d)) => report.diffs.push(d),
|
||||
Ok(None) => {}
|
||||
Err(e) => report.errors.push(format!("template {}: {e}", t.id)),
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Diff scripts
|
||||
let scripts = qs::list_scripts_by_project(conn, project_id)?;
|
||||
for s in &scripts {
|
||||
if s.file_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match diff_script(data_dir, s) {
|
||||
Ok(Some(d)) => report.diffs.push(d),
|
||||
Ok(None) => {}
|
||||
Err(e) => report.errors.push(format!("script {}: {e}", s.id)),
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Detect orphans
|
||||
let orphans = detect_orphan_files(conn, data_dir, project_id)?;
|
||||
report.orphans = orphans;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
fn opt_to_str(opt: &Option<String>) -> String {
|
||||
opt.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn bool_to_str(b: bool) -> String {
|
||||
if b { "true".to_string() } else { "false".to_string() }
|
||||
}
|
||||
|
||||
fn tags_to_json(tags: &[String]) -> String {
|
||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
|
||||
fn status_to_str(s: &PostStatus) -> &'static str {
|
||||
match s {
|
||||
PostStatus::Draft => "draft",
|
||||
PostStatus::Published => "published",
|
||||
PostStatus::Archived => "archived",
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_field(fields: &mut Vec<DiffField>, name: &str, db_val: &str, file_val: &str) {
|
||||
if db_val != file_val {
|
||||
fields.push(DiffField {
|
||||
field_name: name.to_string(),
|
||||
db_value: db_val.to_string(),
|
||||
file_value: file_val.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
|
||||
let abs_path = data_dir.join(&post.file_path);
|
||||
if !abs_path.exists() {
|
||||
// Will be caught by orphan detection
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&abs_path)?;
|
||||
let (fm, _body) = read_post_file(&content).map_err(|e| EngineError::Parse(e))?;
|
||||
|
||||
let mut fields = Vec::new();
|
||||
|
||||
compare_field(&mut fields, "title", &post.title, &fm.title);
|
||||
compare_field(&mut fields, "slug", &post.slug, &fm.slug);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"status",
|
||||
status_to_str(&post.status),
|
||||
&fm.status,
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"tags",
|
||||
&tags_to_json(&post.tags),
|
||||
&tags_to_json(&fm.tags),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"categories",
|
||||
&tags_to_json(&post.categories),
|
||||
&tags_to_json(&fm.categories),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"excerpt",
|
||||
&opt_to_str(&post.excerpt),
|
||||
&opt_to_str(&fm.excerpt),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"author",
|
||||
&opt_to_str(&post.author),
|
||||
&opt_to_str(&fm.author),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"language",
|
||||
&opt_to_str(&post.language),
|
||||
&opt_to_str(&fm.language),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"doNotTranslate",
|
||||
&bool_to_str(post.do_not_translate),
|
||||
&bool_to_str(fm.do_not_translate),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"templateSlug",
|
||||
&opt_to_str(&post.template_slug),
|
||||
&opt_to_str(&fm.template_slug),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"createdAt",
|
||||
&post.created_at.to_string(),
|
||||
&fm.created_at.to_string(),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"updatedAt",
|
||||
&post.updated_at.to_string(),
|
||||
&fm.updated_at.to_string(),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"publishedAt",
|
||||
&post.published_at.map(|v| v.to_string()).unwrap_or_default(),
|
||||
&fm.published_at.map(|v| v.to_string()).unwrap_or_default(),
|
||||
);
|
||||
|
||||
if fields.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(EntityDiff {
|
||||
entity_type: "post".to_string(),
|
||||
entity_id: post.id.clone(),
|
||||
file_path: post.file_path.clone(),
|
||||
fields,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_translation(
|
||||
data_dir: &Path,
|
||||
t: &PostTranslation,
|
||||
) -> EngineResult<Option<EntityDiff>> {
|
||||
let abs_path = data_dir.join(&t.file_path);
|
||||
if !abs_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&abs_path)?;
|
||||
let (fm, _body) = read_translation_file(&content).map_err(|e| EngineError::Parse(e))?;
|
||||
|
||||
let mut fields = Vec::new();
|
||||
|
||||
compare_field(&mut fields, "translationFor", &t.translation_for, &fm.translation_for);
|
||||
compare_field(&mut fields, "language", &t.language, &fm.language);
|
||||
compare_field(&mut fields, "title", &t.title, &fm.title);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"excerpt",
|
||||
&opt_to_str(&t.excerpt),
|
||||
&opt_to_str(&fm.excerpt),
|
||||
);
|
||||
|
||||
if fields.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(EntityDiff {
|
||||
entity_type: "translation".to_string(),
|
||||
entity_id: t.id.clone(),
|
||||
file_path: t.file_path.clone(),
|
||||
fields,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_media(data_dir: &Path, media: &Media) -> EngineResult<Option<EntityDiff>> {
|
||||
let abs_path = data_dir.join(&media.sidecar_path);
|
||||
if !abs_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&abs_path)?;
|
||||
let sc = read_sidecar(&content).map_err(|e| EngineError::Parse(e))?;
|
||||
|
||||
let mut fields = Vec::new();
|
||||
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"title",
|
||||
&opt_to_str(&media.title),
|
||||
&opt_to_str(&sc.title),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"alt",
|
||||
&opt_to_str(&media.alt),
|
||||
&opt_to_str(&sc.alt),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"caption",
|
||||
&opt_to_str(&media.caption),
|
||||
&opt_to_str(&sc.caption),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"author",
|
||||
&opt_to_str(&media.author),
|
||||
&opt_to_str(&sc.author),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"tags",
|
||||
&tags_to_json(&media.tags),
|
||||
&tags_to_json(&sc.tags),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"language",
|
||||
&opt_to_str(&media.language),
|
||||
&opt_to_str(&sc.language),
|
||||
);
|
||||
|
||||
if fields.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(EntityDiff {
|
||||
entity_type: "media".to_string(),
|
||||
entity_id: media.id.clone(),
|
||||
file_path: media.sidecar_path.clone(),
|
||||
fields,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_template(data_dir: &Path, tpl: &Template) -> EngineResult<Option<EntityDiff>> {
|
||||
let abs_path = data_dir.join(&tpl.file_path);
|
||||
if !abs_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&abs_path)?;
|
||||
let (fm, _body) = read_template_file(&content).map_err(|e| EngineError::Parse(e))?;
|
||||
|
||||
let mut fields = Vec::new();
|
||||
|
||||
compare_field(&mut fields, "title", &tpl.title, &fm.title);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"kind",
|
||||
template_kind_to_str(&tpl.kind),
|
||||
&fm.kind,
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"enabled",
|
||||
&bool_to_str(tpl.enabled),
|
||||
&bool_to_str(fm.enabled),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"version",
|
||||
&tpl.version.to_string(),
|
||||
&fm.version.to_string(),
|
||||
);
|
||||
|
||||
if fields.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(EntityDiff {
|
||||
entity_type: "template".to_string(),
|
||||
entity_id: tpl.id.clone(),
|
||||
file_path: tpl.file_path.clone(),
|
||||
fields,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_script(data_dir: &Path, script: &Script) -> EngineResult<Option<EntityDiff>> {
|
||||
let abs_path = data_dir.join(&script.file_path);
|
||||
if !abs_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&abs_path)?;
|
||||
let (fm, _body) = read_script_file(&content).map_err(|e| EngineError::Parse(e))?;
|
||||
|
||||
let mut fields = Vec::new();
|
||||
|
||||
compare_field(&mut fields, "title", &script.title, &fm.title);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"kind",
|
||||
script_kind_to_str(&script.kind),
|
||||
&fm.kind,
|
||||
);
|
||||
compare_field(&mut fields, "entrypoint", &script.entrypoint, &fm.entrypoint);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"enabled",
|
||||
&bool_to_str(script.enabled),
|
||||
&bool_to_str(fm.enabled),
|
||||
);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"version",
|
||||
&script.version.to_string(),
|
||||
&fm.version.to_string(),
|
||||
);
|
||||
|
||||
if fields.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(EntityDiff {
|
||||
entity_type: "script".to_string(),
|
||||
entity_id: script.id.clone(),
|
||||
file_path: script.file_path.clone(),
|
||||
fields,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_orphan_files(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<Vec<OrphanFile>> {
|
||||
let mut orphans = Vec::new();
|
||||
|
||||
// Collect all known file paths from DB
|
||||
let mut db_file_paths: HashSet<String> = HashSet::new();
|
||||
|
||||
let posts = qp::list_posts_by_project(conn, project_id)?;
|
||||
for post in &posts {
|
||||
if !post.file_path.is_empty() {
|
||||
db_file_paths.insert(post.file_path.clone());
|
||||
}
|
||||
let translations = qt::list_post_translations_by_post(conn, &post.id)?;
|
||||
for t in &translations {
|
||||
if !t.file_path.is_empty() {
|
||||
db_file_paths.insert(t.file_path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let media_items = qm::list_media_by_project(conn, project_id)?;
|
||||
for m in &media_items {
|
||||
if !m.file_path.is_empty() {
|
||||
db_file_paths.insert(m.file_path.clone());
|
||||
}
|
||||
if !m.sidecar_path.is_empty() {
|
||||
db_file_paths.insert(m.sidecar_path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let templates = qtpl::list_templates_by_project(conn, project_id)?;
|
||||
for t in &templates {
|
||||
if !t.file_path.is_empty() {
|
||||
db_file_paths.insert(t.file_path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let scripts = qs::list_scripts_by_project(conn, project_id)?;
|
||||
for s in &scripts {
|
||||
if !s.file_path.is_empty() {
|
||||
db_file_paths.insert(s.file_path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Walk filesystem directories and find orphans
|
||||
let dirs_to_walk = ["posts", "media", "templates", "scripts"];
|
||||
for dir_name in &dirs_to_walk {
|
||||
let dir_path = data_dir.join(dir_name);
|
||||
if !dir_path.exists() {
|
||||
continue;
|
||||
}
|
||||
for entry in WalkDir::new(&dir_path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute relative path from data_dir
|
||||
let rel_path = path
|
||||
.strip_prefix(data_dir)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Skip non-content files (thumbnails, etc.)
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
let is_content_file = match *dir_name {
|
||||
"posts" => ext == "md",
|
||||
"media" => ext == "meta",
|
||||
"templates" => ext == "liquid",
|
||||
"scripts" => ext == "lua" || ext == "py",
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !is_content_file {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !db_file_paths.contains(&rel_path) {
|
||||
orphans.push(OrphanFile {
|
||||
file_path: rel_path,
|
||||
reason: "file_without_db_entry".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check DB entries whose file_path doesn't exist on disk
|
||||
for post in &posts {
|
||||
if !post.file_path.is_empty() {
|
||||
let abs = data_dir.join(&post.file_path);
|
||||
if !abs.exists() {
|
||||
orphans.push(OrphanFile {
|
||||
file_path: post.file_path.clone(),
|
||||
reason: "db_entry_without_file".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let translations = qt::list_post_translations_by_post(conn, &post.id)?;
|
||||
for t in &translations {
|
||||
if !t.file_path.is_empty() {
|
||||
let abs = data_dir.join(&t.file_path);
|
||||
if !abs.exists() {
|
||||
orphans.push(OrphanFile {
|
||||
file_path: t.file_path.clone(),
|
||||
reason: "db_entry_without_file".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for m in &media_items {
|
||||
if !m.sidecar_path.is_empty() {
|
||||
let abs = data_dir.join(&m.sidecar_path);
|
||||
if !abs.exists() {
|
||||
orphans.push(OrphanFile {
|
||||
file_path: m.sidecar_path.clone(),
|
||||
reason: "db_entry_without_file".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for t in &templates {
|
||||
if !t.file_path.is_empty() {
|
||||
let abs = data_dir.join(&t.file_path);
|
||||
if !abs.exists() {
|
||||
orphans.push(OrphanFile {
|
||||
file_path: t.file_path.clone(),
|
||||
reason: "db_entry_without_file".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for s in &scripts {
|
||||
if !s.file_path.is_empty() {
|
||||
let abs = data_dir.join(&s.file_path);
|
||||
if !abs.exists() {
|
||||
orphans.push(OrphanFile {
|
||||
file_path: s.file_path.clone(),
|
||||
reason: "db_entry_without_file".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(orphans)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::fts::ensure_fts_tables;
|
||||
use crate::db::queries::media::insert_media;
|
||||
use crate::db::queries::post::insert_post;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::queries::script::insert_script;
|
||||
use crate::db::queries::template::insert_template;
|
||||
use crate::db::Database;
|
||||
use crate::engine::post::{create_post, publish_post};
|
||||
use crate::model::{
|
||||
Media, Post, PostStatus, Script, ScriptKind, ScriptStatus, Template, TemplateKind,
|
||||
TemplateStatus,
|
||||
};
|
||||
use crate::util::frontmatter::{
|
||||
write_script_file, write_template_file, ScriptFrontmatter, TemplateFrontmatter,
|
||||
};
|
||||
use crate::util::sidecar::MediaSidecar;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
(db, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_diffs_for_clean_state() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
// Create and publish a post via the engine
|
||||
let post = create_post(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"Clean Post",
|
||||
Some("body text"),
|
||||
vec!["rust".into()],
|
||||
vec!["tech".into()],
|
||||
Some("Alice"),
|
||||
Some("en"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||
|
||||
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
// Published post: file was just written by publish, DB and file should match.
|
||||
// The only expected diff is updatedAt because publish sets it to now() in the DB
|
||||
// but the file was written with that same now(). They should match.
|
||||
// However, publish_post updates updatedAt multiple times, so the DB value may
|
||||
// differ from the file. Filter to only non-updatedAt diffs.
|
||||
let non_time_diffs: Vec<_> = report
|
||||
.diffs
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
d.fields
|
||||
.iter()
|
||||
.any(|f| f.field_name != "updatedAt")
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
non_time_diffs.is_empty(),
|
||||
"expected no non-timestamp diffs, got: {non_time_diffs:?}"
|
||||
);
|
||||
assert!(report.orphans.is_empty(), "orphans: {:?}", report.orphans);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_title_drift_in_post() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
let post = create_post(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"Original Title",
|
||||
Some("body"),
|
||||
vec!["rust".into()],
|
||||
vec!["tech".into()],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let published = publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||
|
||||
// Manually edit the .md file to change the title
|
||||
let abs_path = dir.path().join(&published.file_path);
|
||||
let content = fs::read_to_string(&abs_path).unwrap();
|
||||
let modified = content.replace("Original Title", "Tampered Title");
|
||||
fs::write(&abs_path, modified).unwrap();
|
||||
|
||||
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
// Should detect title drift
|
||||
let title_diffs: Vec<_> = report
|
||||
.diffs
|
||||
.iter()
|
||||
.filter(|d| d.entity_type == "post")
|
||||
.flat_map(|d| d.fields.iter())
|
||||
.filter(|f| f.field_name == "title")
|
||||
.collect();
|
||||
|
||||
assert_eq!(title_diffs.len(), 1);
|
||||
assert_eq!(title_diffs[0].db_value, "Original Title");
|
||||
assert_eq!(title_diffs[0].file_value, "Tampered Title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_media_sidecar_drift() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
// Create media in DB with sidecar
|
||||
let media = Media {
|
||||
id: "m1".to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
filename: "m1.jpg".to_string(),
|
||||
original_name: "photo.jpg".to_string(),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
size: 50000,
|
||||
width: Some(800),
|
||||
height: Some(600),
|
||||
title: Some("My Photo".to_string()),
|
||||
alt: Some("A nice photo".to_string()),
|
||||
caption: None,
|
||||
author: None,
|
||||
language: None,
|
||||
file_path: "media/2024/01/m1.jpg".to_string(),
|
||||
sidecar_path: "media/2024/01/m1.jpg.meta".to_string(),
|
||||
checksum: None,
|
||||
tags: vec![],
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
};
|
||||
insert_media(db.conn(), &media).unwrap();
|
||||
|
||||
// Write sidecar with matching data initially, then change alt
|
||||
let sidecar = MediaSidecar {
|
||||
id: "m1".to_string(),
|
||||
original_name: "photo.jpg".to_string(),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
size: 50000,
|
||||
width: Some(800),
|
||||
height: Some(600),
|
||||
title: Some("My Photo".to_string()),
|
||||
alt: Some("TAMPERED ALT".to_string()),
|
||||
caption: None,
|
||||
author: None,
|
||||
language: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
tags: vec![],
|
||||
linked_post_ids: vec![],
|
||||
};
|
||||
|
||||
let sidecar_dir = dir.path().join("media/2024/01");
|
||||
fs::create_dir_all(&sidecar_dir).unwrap();
|
||||
fs::write(sidecar_dir.join("m1.jpg.meta"), sidecar.to_string()).unwrap();
|
||||
|
||||
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
let alt_diffs: Vec<_> = report
|
||||
.diffs
|
||||
.iter()
|
||||
.filter(|d| d.entity_type == "media")
|
||||
.flat_map(|d| d.fields.iter())
|
||||
.filter(|f| f.field_name == "alt")
|
||||
.collect();
|
||||
|
||||
assert_eq!(alt_diffs.len(), 1);
|
||||
assert_eq!(alt_diffs[0].db_value, "A nice photo");
|
||||
assert_eq!(alt_diffs[0].file_value, "TAMPERED ALT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_orphan_file() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
// Create a .md file in posts/ that is not in the DB
|
||||
let posts_dir = dir.path().join("posts/2024/01");
|
||||
fs::create_dir_all(&posts_dir).unwrap();
|
||||
fs::write(posts_dir.join("orphan.md"), "---\ntitle: Orphan\n---\nBody\n").unwrap();
|
||||
|
||||
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
let orphan_files: Vec<_> = report
|
||||
.orphans
|
||||
.iter()
|
||||
.filter(|o| o.reason == "file_without_db_entry")
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!orphan_files.is_empty(),
|
||||
"expected at least one orphan file"
|
||||
);
|
||||
assert!(
|
||||
orphan_files.iter().any(|o| o.file_path.contains("orphan.md")),
|
||||
"expected orphan.md in orphans, got: {orphan_files:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_db_without_file() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
// Insert post in DB with file_path pointing to a non-existent file
|
||||
let post = Post {
|
||||
id: "ghost-post".to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
title: "Ghost".to_string(),
|
||||
slug: "ghost".to_string(),
|
||||
excerpt: None,
|
||||
content: None,
|
||||
status: PostStatus::Published,
|
||||
author: None,
|
||||
language: None,
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: "posts/2024/01/ghost.md".to_string(),
|
||||
checksum: None,
|
||||
tags: vec![],
|
||||
categories: vec![],
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
published_excerpt: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
published_at: Some(3000),
|
||||
};
|
||||
insert_post(db.conn(), &post).unwrap();
|
||||
|
||||
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
let db_orphans: Vec<_> = report
|
||||
.orphans
|
||||
.iter()
|
||||
.filter(|o| o.reason == "db_entry_without_file")
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!db_orphans.is_empty(),
|
||||
"expected at least one db_entry_without_file orphan"
|
||||
);
|
||||
assert!(
|
||||
db_orphans.iter().any(|o| o.file_path.contains("ghost.md")),
|
||||
"expected ghost.md in orphans, got: {db_orphans:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_template_drift() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
// Insert template in DB
|
||||
let tpl = Template {
|
||||
id: "tpl1".to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
slug: "my-template".to_string(),
|
||||
title: "My Template".to_string(),
|
||||
kind: TemplateKind::Post,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: "templates/my-template.liquid".to_string(),
|
||||
status: TemplateStatus::Published,
|
||||
content: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
};
|
||||
insert_template(db.conn(), &tpl).unwrap();
|
||||
|
||||
// Write template file with different title
|
||||
let fm = TemplateFrontmatter {
|
||||
id: "tpl1".to_string(),
|
||||
project_id: Some("p1".to_string()),
|
||||
slug: "my-template".to_string(),
|
||||
title: "CHANGED Template Title".to_string(),
|
||||
kind: "post".to_string(),
|
||||
enabled: true,
|
||||
version: 1,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
};
|
||||
let file_content = write_template_file(&fm, "<div>body</div>");
|
||||
let tpl_dir = dir.path().join("templates");
|
||||
fs::create_dir_all(&tpl_dir).unwrap();
|
||||
fs::write(tpl_dir.join("my-template.liquid"), file_content).unwrap();
|
||||
|
||||
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
let title_diffs: Vec<_> = report
|
||||
.diffs
|
||||
.iter()
|
||||
.filter(|d| d.entity_type == "template")
|
||||
.flat_map(|d| d.fields.iter())
|
||||
.filter(|f| f.field_name == "title")
|
||||
.collect();
|
||||
|
||||
assert_eq!(title_diffs.len(), 1);
|
||||
assert_eq!(title_diffs[0].db_value, "My Template");
|
||||
assert_eq!(title_diffs[0].file_value, "CHANGED Template Title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_script_drift() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
// Insert script in DB
|
||||
let script = Script {
|
||||
id: "s1".to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
slug: "my-script".to_string(),
|
||||
title: "My Script".to_string(),
|
||||
kind: ScriptKind::Utility,
|
||||
entrypoint: "main".to_string(),
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: "scripts/my-script.lua".to_string(),
|
||||
status: ScriptStatus::Published,
|
||||
content: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
};
|
||||
insert_script(db.conn(), &script).unwrap();
|
||||
|
||||
// Write script file with different title and version
|
||||
let fm = ScriptFrontmatter {
|
||||
id: "s1".to_string(),
|
||||
project_id: Some("p1".to_string()),
|
||||
slug: "my-script".to_string(),
|
||||
title: "CHANGED Script Title".to_string(),
|
||||
kind: "utility".to_string(),
|
||||
entrypoint: "main".to_string(),
|
||||
enabled: true,
|
||||
version: 5,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
};
|
||||
let file_content = write_script_file(&fm, "-- lua code\nreturn 1");
|
||||
let scripts_dir = dir.path().join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(scripts_dir.join("my-script.lua"), file_content).unwrap();
|
||||
|
||||
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
let script_diffs: Vec<_> = report
|
||||
.diffs
|
||||
.iter()
|
||||
.filter(|d| d.entity_type == "script")
|
||||
.collect();
|
||||
|
||||
assert!(!script_diffs.is_empty(), "expected script diffs");
|
||||
|
||||
let title_diffs: Vec<_> = script_diffs
|
||||
.iter()
|
||||
.flat_map(|d| d.fields.iter())
|
||||
.filter(|f| f.field_name == "title")
|
||||
.collect();
|
||||
assert_eq!(title_diffs.len(), 1);
|
||||
assert_eq!(title_diffs[0].db_value, "My Script");
|
||||
assert_eq!(title_diffs[0].file_value, "CHANGED Script Title");
|
||||
|
||||
let version_diffs: Vec<_> = script_diffs
|
||||
.iter()
|
||||
.flat_map(|d| d.fields.iter())
|
||||
.filter(|f| f.field_name == "version")
|
||||
.collect();
|
||||
assert_eq!(version_diffs.len(), 1);
|
||||
assert_eq!(version_diffs[0].db_value, "1");
|
||||
assert_eq!(version_diffs[0].file_value, "5");
|
||||
}
|
||||
}
|
||||
@@ -1 +1,16 @@
|
||||
// Engine modules — stubs for M0, implemented in M1.
|
||||
pub mod error;
|
||||
pub mod context;
|
||||
pub mod project;
|
||||
pub mod meta;
|
||||
pub mod tag;
|
||||
pub mod post;
|
||||
pub mod media;
|
||||
pub mod post_media;
|
||||
pub mod template_rebuild;
|
||||
pub mod script_rebuild;
|
||||
pub mod task;
|
||||
pub mod metadata_diff;
|
||||
pub mod rebuild;
|
||||
|
||||
pub use error::{EngineError, EngineResult};
|
||||
pub use context::EngineContext;
|
||||
|
||||
1235
crates/bds-core/src/engine/post.rs
Normal file
1235
crates/bds-core/src/engine/post.rs
Normal file
File diff suppressed because it is too large
Load Diff
196
crates/bds-core/src/engine/post_media.rs
Normal file
196
crates/bds-core/src/engine/post_media.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::queries::media as qm;
|
||||
use crate::db::queries::post_media as qpm;
|
||||
use crate::engine::EngineResult;
|
||||
use crate::model::PostMedia;
|
||||
use crate::util::sidecar::MediaSidecar;
|
||||
use crate::util::{atomic_write_str, now_unix_ms};
|
||||
|
||||
/// Link a media item to a post and sync the media sidecar.
|
||||
pub fn link_media_to_post(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
post_id: &str,
|
||||
media_id: &str,
|
||||
sort_order: i32,
|
||||
) -> EngineResult<PostMedia> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = now_unix_ms();
|
||||
let pm = PostMedia {
|
||||
id,
|
||||
project_id: project_id.to_string(),
|
||||
post_id: post_id.to_string(),
|
||||
media_id: media_id.to_string(),
|
||||
sort_order,
|
||||
created_at: now,
|
||||
};
|
||||
qpm::link_media(conn, &pm)?;
|
||||
sync_sidecar_linked_post_ids(conn, data_dir, media_id)?;
|
||||
Ok(pm)
|
||||
}
|
||||
|
||||
/// Unlink a media item from a post and sync the media sidecar.
|
||||
pub fn unlink_media_from_post(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
post_id: &str,
|
||||
media_id: &str,
|
||||
) -> EngineResult<()> {
|
||||
qpm::unlink_media(conn, post_id, media_id)?;
|
||||
sync_sidecar_linked_post_ids(conn, data_dir, media_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reorder media items for a post. `media_ids` contains the new ordering;
|
||||
/// each entry gets sort_order = its index.
|
||||
pub fn reorder_post_media(
|
||||
conn: &Connection,
|
||||
post_id: &str,
|
||||
media_ids: &[String],
|
||||
) -> EngineResult<()> {
|
||||
for (i, media_id) in media_ids.iter().enumerate() {
|
||||
qpm::update_sort_order(conn, post_id, media_id, i as i32)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild the media sidecar file so that `linkedPostIds` reflects the current
|
||||
/// set of posts linked to this media item.
|
||||
fn sync_sidecar_linked_post_ids(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
media_id: &str,
|
||||
) -> EngineResult<()> {
|
||||
let links = qpm::list_post_media_by_media(conn, media_id)?;
|
||||
let post_ids: Vec<String> = links.iter().map(|pm| pm.post_id.clone()).collect();
|
||||
let media = qm::get_media_by_id(conn, media_id)?;
|
||||
let sidecar = MediaSidecar::from_media(&media, &post_ids);
|
||||
let abs_path = data_dir.join(&media.sidecar_path);
|
||||
atomic_write_str(&abs_path, &sidecar.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::post_media::list_post_media_by_post;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::util::sidecar::read_sidecar;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Create a post
|
||||
db.conn()
|
||||
.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) VALUES ('post1', 'p1', 'Test', 'test', 'draft', '', 1000, 1000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
(db, dir)
|
||||
}
|
||||
|
||||
/// Insert a media item whose sidecar_path is inside the temp dir.
|
||||
fn insert_test_media(db: &Database, dir: &Path, id: &str) {
|
||||
let sidecar_rel = format!("media/{id}.jpg.meta");
|
||||
let mut media = make_test_media(id, "p1");
|
||||
media.file_path = format!("media/{id}.jpg");
|
||||
media.sidecar_path = sidecar_rel.clone();
|
||||
insert_media(db.conn(), &media).unwrap();
|
||||
|
||||
// Write an initial sidecar so the directory exists
|
||||
let initial = MediaSidecar::from_media(&media, &[]);
|
||||
let abs_sidecar = dir.join(&sidecar_rel);
|
||||
fs::create_dir_all(abs_sidecar.parent().unwrap()).unwrap();
|
||||
fs::write(&abs_sidecar, initial.to_string()).unwrap();
|
||||
}
|
||||
|
||||
fn read_linked_ids(dir: &Path, sidecar_rel: &str) -> Vec<String> {
|
||||
let content = fs::read_to_string(dir.join(sidecar_rel)).unwrap();
|
||||
let sc = read_sidecar(&content).unwrap();
|
||||
sc.linked_post_ids
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_and_verify_sidecar() {
|
||||
let (db, dir) = setup();
|
||||
insert_test_media(&db, dir.path(), "m1");
|
||||
|
||||
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap();
|
||||
|
||||
let ids = read_linked_ids(dir.path(), "media/m1.jpg.meta");
|
||||
assert_eq!(ids, vec!["post1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlink_removes_from_sidecar() {
|
||||
let (db, dir) = setup();
|
||||
insert_test_media(&db, dir.path(), "m1");
|
||||
|
||||
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap();
|
||||
unlink_media_from_post(db.conn(), dir.path(), "post1", "m1").unwrap();
|
||||
|
||||
let ids = read_linked_ids(dir.path(), "media/m1.jpg.meta");
|
||||
assert!(ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_updates_sort_order() {
|
||||
let (db, dir) = setup();
|
||||
insert_test_media(&db, dir.path(), "m1");
|
||||
insert_test_media(&db, dir.path(), "m2");
|
||||
|
||||
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap();
|
||||
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m2", 1).unwrap();
|
||||
|
||||
// Reverse the order: m2 first, m1 second
|
||||
reorder_post_media(
|
||||
db.conn(),
|
||||
"post1",
|
||||
&["m2".to_string(), "m1".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].media_id, "m2");
|
||||
assert_eq!(list[0].sort_order, 0);
|
||||
assert_eq!(list[1].media_id, "m1");
|
||||
assert_eq!(list[1].sort_order, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_posts_linked() {
|
||||
let (db, dir) = setup();
|
||||
insert_test_media(&db, dir.path(), "m1");
|
||||
|
||||
// Create a second post
|
||||
db.conn()
|
||||
.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) VALUES ('post2', 'p1', 'Test2', 'test2', 'draft', '', 2000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap();
|
||||
link_media_to_post(db.conn(), dir.path(), "p1", "post2", "m1", 0).unwrap();
|
||||
|
||||
let ids = read_linked_ids(dir.path(), "media/m1.jpg.meta");
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert!(ids.contains(&"post1".to_string()));
|
||||
assert!(ids.contains(&"post2".to_string()));
|
||||
}
|
||||
}
|
||||
235
crates/bds-core/src/engine/project.rs
Normal file
235
crates/bds-core/src/engine/project.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::queries::project as q;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::Project;
|
||||
use crate::model::metadata::ProjectMetadata;
|
||||
use crate::util::{atomic_write_str, now_unix_ms, slugify, ensure_unique};
|
||||
|
||||
/// Create a new project: insert into DB, create directory structure, write default meta files.
|
||||
pub fn create_project(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
data_path: Option<&str>,
|
||||
) -> EngineResult<Project> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let base_slug = slugify(name);
|
||||
let slug = ensure_unique(&base_slug, |candidate| {
|
||||
q::get_project_by_slug(conn, candidate).is_ok()
|
||||
});
|
||||
|
||||
let now = now_unix_ms();
|
||||
let project = Project {
|
||||
id: id.clone(),
|
||||
name: name.to_string(),
|
||||
slug: slug.clone(),
|
||||
description: None,
|
||||
data_path: data_path.map(|s| s.to_string()),
|
||||
is_active: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
q::insert_project(conn, &project)?;
|
||||
|
||||
// Determine data directory
|
||||
let data_dir = match data_path {
|
||||
Some(p) => std::path::PathBuf::from(p),
|
||||
None => std::path::PathBuf::from(&slug),
|
||||
};
|
||||
|
||||
// Create directory structure
|
||||
create_directory_structure(&data_dir)?;
|
||||
|
||||
// Write default meta files
|
||||
write_default_meta_files(&data_dir, name)?;
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
/// Get the currently active project, if any.
|
||||
pub fn get_active_project(conn: &Connection) -> EngineResult<Option<Project>> {
|
||||
match q::get_active_project(conn) {
|
||||
Ok(p) => Ok(Some(p)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(EngineError::Db(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deactivate all projects, then activate the given one.
|
||||
pub fn set_active_project(conn: &Connection, project_id: &str) -> EngineResult<()> {
|
||||
q::set_active_project(conn, project_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all projects ordered by name.
|
||||
pub fn list_projects(conn: &Connection) -> EngineResult<Vec<Project>> {
|
||||
Ok(q::list_projects(conn)?)
|
||||
}
|
||||
|
||||
/// Delete a project row (cascading handled by queries).
|
||||
pub fn delete_project(conn: &Connection, project_id: &str) -> EngineResult<()> {
|
||||
q::delete_project(conn, project_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn create_directory_structure(data_dir: &Path) -> EngineResult<()> {
|
||||
let subdirs = ["posts", "media", "meta", "thumbnails", "templates", "scripts"];
|
||||
for sub in &subdirs {
|
||||
fs::create_dir_all(data_dir.join(sub))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_default_meta_files(data_dir: &Path, project_name: &str) -> EngineResult<()> {
|
||||
let meta_dir = data_dir.join("meta");
|
||||
|
||||
// project.json
|
||||
let project_meta = ProjectMetadata {
|
||||
name: project_name.to_string(),
|
||||
description: None,
|
||||
public_url: None,
|
||||
main_language: None,
|
||||
default_author: None,
|
||||
max_posts_per_page: 50,
|
||||
blogmark_category: None,
|
||||
pico_theme: None,
|
||||
python_runtime_mode: None,
|
||||
semantic_similarity_enabled: false,
|
||||
blog_languages: Vec::new(),
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&project_meta)?;
|
||||
atomic_write_str(&meta_dir.join("project.json"), &json)?;
|
||||
|
||||
// categories.json — default categories
|
||||
let categories = vec!["article", "aside", "page", "picture"];
|
||||
let json = serde_json::to_string_pretty(&categories)?;
|
||||
atomic_write_str(&meta_dir.join("categories.json"), &json)?;
|
||||
|
||||
// category-meta.json — empty object
|
||||
let empty_map: HashMap<String, serde_json::Value> = HashMap::new();
|
||||
let json = serde_json::to_string_pretty(&empty_map)?;
|
||||
atomic_write_str(&meta_dir.join("category-meta.json"), &json)?;
|
||||
|
||||
// publishing.json — empty object
|
||||
atomic_write_str(&meta_dir.join("publishing.json"), "{}")?;
|
||||
|
||||
// tags.json — empty array
|
||||
atomic_write_str(&meta_dir.join("tags.json"), "[]")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
(db, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_project_inserts_and_creates_dirs() {
|
||||
let (db, dir) = setup();
|
||||
let data_path = dir.path().join("my-blog");
|
||||
let project = create_project(
|
||||
db.conn(),
|
||||
"My Blog",
|
||||
Some(data_path.to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(project.name, "My Blog");
|
||||
assert_eq!(project.slug, "my-blog");
|
||||
assert!(!project.is_active);
|
||||
|
||||
// Verify directories
|
||||
assert!(data_path.join("posts").is_dir());
|
||||
assert!(data_path.join("media").is_dir());
|
||||
assert!(data_path.join("meta").is_dir());
|
||||
assert!(data_path.join("thumbnails").is_dir());
|
||||
assert!(data_path.join("templates").is_dir());
|
||||
assert!(data_path.join("scripts").is_dir());
|
||||
|
||||
// Verify meta files
|
||||
let project_json: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(data_path.join("meta/project.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(project_json["name"], "My Blog");
|
||||
assert_eq!(project_json["maxPostsPerPage"], 50);
|
||||
|
||||
let cats: Vec<String> =
|
||||
serde_json::from_str(&fs::read_to_string(data_path.join("meta/categories.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(cats, vec!["article", "aside", "page", "picture"]);
|
||||
|
||||
let cat_meta: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(data_path.join("meta/category-meta.json")).unwrap())
|
||||
.unwrap();
|
||||
assert!(cat_meta.as_object().unwrap().is_empty());
|
||||
|
||||
let tags: Vec<String> =
|
||||
serde_json::from_str(&fs::read_to_string(data_path.join("meta/tags.json")).unwrap())
|
||||
.unwrap();
|
||||
assert!(tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_project_unique_slug() {
|
||||
let (db, dir) = setup();
|
||||
let p1_path = dir.path().join("blog1");
|
||||
create_project(db.conn(), "Blog", Some(p1_path.to_str().unwrap())).unwrap();
|
||||
let p2_path = dir.path().join("blog2");
|
||||
let p2 = create_project(db.conn(), "Blog", Some(p2_path.to_str().unwrap())).unwrap();
|
||||
assert_eq!(p2.slug, "blog-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_active_project_none() {
|
||||
let (db, _dir) = setup();
|
||||
assert!(get_active_project(db.conn()).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_get_active_project() {
|
||||
let (db, dir) = setup();
|
||||
let p1_path = dir.path().join("p1");
|
||||
let p1 = create_project(db.conn(), "P1", Some(p1_path.to_str().unwrap())).unwrap();
|
||||
set_active_project(db.conn(), &p1.id).unwrap();
|
||||
let active = get_active_project(db.conn()).unwrap().unwrap();
|
||||
assert_eq!(active.id, p1.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_projects_returns_all() {
|
||||
let (db, dir) = setup();
|
||||
let p1_path = dir.path().join("alpha");
|
||||
let p2_path = dir.path().join("beta");
|
||||
create_project(db.conn(), "Beta", Some(p2_path.to_str().unwrap())).unwrap();
|
||||
create_project(db.conn(), "Alpha", Some(p1_path.to_str().unwrap())).unwrap();
|
||||
let list = list_projects(db.conn()).unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].name, "Alpha");
|
||||
assert_eq!(list[1].name, "Beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_project_removes_row() {
|
||||
let (db, dir) = setup();
|
||||
let p_path = dir.path().join("p");
|
||||
let p = create_project(db.conn(), "P", Some(p_path.to_str().unwrap())).unwrap();
|
||||
delete_project(db.conn(), &p.id).unwrap();
|
||||
assert!(list_projects(db.conn()).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
343
crates/bds-core/src/engine/rebuild.rs
Normal file
343
crates/bds-core/src/engine/rebuild.rs
Normal file
@@ -0,0 +1,343 @@
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::db::fts;
|
||||
use crate::engine::media;
|
||||
use crate::engine::post;
|
||||
use crate::engine::script_rebuild;
|
||||
use crate::engine::template_rebuild;
|
||||
use crate::engine::EngineResult;
|
||||
|
||||
/// Report from a full rebuild operation.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FullRebuildReport {
|
||||
pub posts_created: usize,
|
||||
pub posts_updated: usize,
|
||||
pub translations_created: usize,
|
||||
pub translations_updated: usize,
|
||||
pub media_created: usize,
|
||||
pub media_updated: usize,
|
||||
pub media_translations_created: usize,
|
||||
pub media_translations_updated: usize,
|
||||
pub templates_created: usize,
|
||||
pub templates_updated: usize,
|
||||
pub scripts_created: usize,
|
||||
pub scripts_updated: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Orchestrate a full rebuild from filesystem into the database.
|
||||
///
|
||||
/// Ensures FTS tables exist, then rebuilds posts, media, templates, and scripts
|
||||
/// from their respective filesystem directories. Returns an aggregated report.
|
||||
pub fn rebuild_from_filesystem(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<FullRebuildReport> {
|
||||
let mut report = FullRebuildReport::default();
|
||||
|
||||
// 1. Ensure FTS tables exist
|
||||
fts::ensure_fts_tables(conn)?;
|
||||
|
||||
// 2. Rebuild posts
|
||||
let post_report = post::rebuild_posts_from_filesystem(conn, data_dir, project_id)?;
|
||||
report.posts_created = post_report.posts_created;
|
||||
report.posts_updated = post_report.posts_updated;
|
||||
report.translations_created = post_report.translations_created;
|
||||
report.translations_updated = post_report.translations_updated;
|
||||
report.errors.extend(post_report.errors);
|
||||
|
||||
// 3. Rebuild media
|
||||
let media_report = media::rebuild_media_from_filesystem(conn, data_dir, project_id)?;
|
||||
report.media_created = media_report.media_created;
|
||||
report.media_updated = media_report.media_updated;
|
||||
report.media_translations_created = media_report.translations_created;
|
||||
report.media_translations_updated = media_report.translations_updated;
|
||||
report.errors.extend(media_report.errors);
|
||||
|
||||
// 4. Rebuild templates
|
||||
let tpl_report =
|
||||
template_rebuild::rebuild_templates_from_filesystem(conn, data_dir, project_id)?;
|
||||
report.templates_created = tpl_report.created;
|
||||
report.templates_updated = tpl_report.updated;
|
||||
report.errors.extend(tpl_report.errors);
|
||||
|
||||
// 5. Rebuild scripts
|
||||
let script_report =
|
||||
script_rebuild::rebuild_scripts_from_filesystem(conn, data_dir, project_id)?;
|
||||
report.scripts_created = script_report.created;
|
||||
report.scripts_updated = script_report.updated;
|
||||
report.errors.extend(script_report.errors);
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::media as qm;
|
||||
use crate::db::queries::post as qp;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::queries::script as qs;
|
||||
use crate::db::queries::template as qtpl;
|
||||
use crate::db::Database;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
fts::ensure_fts_tables(db.conn()).unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
(db, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_empty_dir_returns_zeros() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
let report = rebuild_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.posts_created, 0);
|
||||
assert_eq!(report.posts_updated, 0);
|
||||
assert_eq!(report.translations_created, 0);
|
||||
assert_eq!(report.translations_updated, 0);
|
||||
assert_eq!(report.media_created, 0);
|
||||
assert_eq!(report.media_updated, 0);
|
||||
assert_eq!(report.media_translations_created, 0);
|
||||
assert_eq!(report.media_translations_updated, 0);
|
||||
assert_eq!(report.templates_created, 0);
|
||||
assert_eq!(report.templates_updated, 0);
|
||||
assert_eq!(report.scripts_created, 0);
|
||||
assert_eq!(report.scripts_updated, 0);
|
||||
assert!(report.errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_creates_posts_and_media() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
// Write a post fixture
|
||||
let posts_dir = dir.path().join("posts").join("2024").join("01");
|
||||
fs::create_dir_all(&posts_dir).unwrap();
|
||||
|
||||
let post_content = "\
|
||||
---
|
||||
id: test-post-1
|
||||
title: Test Post
|
||||
slug: test-post
|
||||
status: published
|
||||
createdAt: '2024-01-15T12:00:00.000Z'
|
||||
updatedAt: '2024-01-15T12:00:00.000Z'
|
||||
tags:
|
||||
- test
|
||||
categories: []
|
||||
publishedAt: '2024-01-15T12:00:00.000Z'
|
||||
---
|
||||
Hello from rebuild test!
|
||||
";
|
||||
fs::write(posts_dir.join("test-post.md"), post_content).unwrap();
|
||||
|
||||
// Write a media fixture: sidecar + dummy binary
|
||||
let media_dir = dir.path().join("media").join("2024").join("01");
|
||||
fs::create_dir_all(&media_dir).unwrap();
|
||||
|
||||
let media_file = media_dir.join("test-media-1.jpg");
|
||||
fs::write(&media_file, b"fake-jpeg-data").unwrap();
|
||||
|
||||
let sidecar_content = "\
|
||||
---
|
||||
id: test-media-1
|
||||
originalName: \"photo.jpg\"
|
||||
mimeType: image/jpeg
|
||||
size: 12345
|
||||
width: 800
|
||||
height: 600
|
||||
createdAt: 2024-01-15T12:00:00.000Z
|
||||
updatedAt: 2024-01-15T12:00:00.000Z
|
||||
tags: []
|
||||
---
|
||||
";
|
||||
fs::write(media_dir.join("test-media-1.jpg.meta"), sidecar_content).unwrap();
|
||||
|
||||
let report = rebuild_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.posts_created, 1);
|
||||
assert_eq!(report.media_created, 1);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
|
||||
// Verify post in DB
|
||||
let post = qp::get_post_by_id(db.conn(), "test-post-1").unwrap();
|
||||
assert_eq!(post.title, "Test Post");
|
||||
assert_eq!(post.slug, "test-post");
|
||||
|
||||
// Verify media in DB
|
||||
let m = qm::get_media_by_id(db.conn(), "test-media-1").unwrap();
|
||||
assert_eq!(m.original_name, "photo.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_all_entity_types() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
// Post
|
||||
let posts_dir = dir.path().join("posts").join("2024").join("01");
|
||||
fs::create_dir_all(&posts_dir).unwrap();
|
||||
let post_content = "\
|
||||
---
|
||||
id: test-post-1
|
||||
title: Test Post
|
||||
slug: test-post
|
||||
status: published
|
||||
createdAt: '2024-01-15T12:00:00.000Z'
|
||||
updatedAt: '2024-01-15T12:00:00.000Z'
|
||||
tags:
|
||||
- test
|
||||
categories: []
|
||||
publishedAt: '2024-01-15T12:00:00.000Z'
|
||||
---
|
||||
Hello from rebuild test!
|
||||
";
|
||||
fs::write(posts_dir.join("test-post.md"), post_content).unwrap();
|
||||
|
||||
// Media
|
||||
let media_dir = dir.path().join("media").join("2024").join("01");
|
||||
fs::create_dir_all(&media_dir).unwrap();
|
||||
fs::write(media_dir.join("test-media-1.jpg"), b"fake-jpeg").unwrap();
|
||||
let sidecar_content = "\
|
||||
---
|
||||
id: test-media-1
|
||||
originalName: \"photo.jpg\"
|
||||
mimeType: image/jpeg
|
||||
size: 12345
|
||||
width: 800
|
||||
height: 600
|
||||
createdAt: 2024-01-15T12:00:00.000Z
|
||||
updatedAt: 2024-01-15T12:00:00.000Z
|
||||
tags: []
|
||||
---
|
||||
";
|
||||
fs::write(
|
||||
media_dir.join("test-media-1.jpg.meta"),
|
||||
sidecar_content,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Template
|
||||
let tpl_dir = dir.path().join("templates");
|
||||
fs::create_dir_all(&tpl_dir).unwrap();
|
||||
let tpl_content = "\
|
||||
---
|
||||
id: \"test-tpl-1\"
|
||||
slug: \"test-template\"
|
||||
title: \"Test Template\"
|
||||
kind: \"post\"
|
||||
enabled: true
|
||||
version: 1
|
||||
createdAt: \"2024-01-15T12:00:00.000Z\"
|
||||
updatedAt: \"2024-01-15T12:00:00.000Z\"
|
||||
---
|
||||
<html>{{ content }}</html>
|
||||
";
|
||||
fs::write(tpl_dir.join("test-template.liquid"), tpl_content).unwrap();
|
||||
|
||||
// Script
|
||||
let scripts_dir = dir.path().join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
let script_content = "\
|
||||
---
|
||||
id: \"test-script-1\"
|
||||
slug: \"test-script\"
|
||||
title: \"Test Script\"
|
||||
kind: \"macro\"
|
||||
entrypoint: \"render\"
|
||||
enabled: true
|
||||
version: 1
|
||||
createdAt: \"2024-01-15T12:00:00.000Z\"
|
||||
updatedAt: \"2024-01-15T12:00:00.000Z\"
|
||||
---
|
||||
function render() end
|
||||
";
|
||||
fs::write(scripts_dir.join("test-script.lua"), script_content).unwrap();
|
||||
|
||||
let report = rebuild_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.posts_created, 1);
|
||||
assert_eq!(report.media_created, 1);
|
||||
assert_eq!(report.templates_created, 1);
|
||||
assert_eq!(report.scripts_created, 1);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
|
||||
// Verify all entities in DB
|
||||
let post = qp::get_post_by_id(db.conn(), "test-post-1").unwrap();
|
||||
assert_eq!(post.title, "Test Post");
|
||||
|
||||
let m = qm::get_media_by_id(db.conn(), "test-media-1").unwrap();
|
||||
assert_eq!(m.original_name, "photo.jpg");
|
||||
|
||||
let tpl = qtpl::get_template_by_id(db.conn(), "test-tpl-1").unwrap();
|
||||
assert_eq!(tpl.title, "Test Template");
|
||||
|
||||
let script = qs::get_script_by_id(db.conn(), "test-script-1").unwrap();
|
||||
assert_eq!(script.title, "Test Script");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_idempotent() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
// Write one of each entity type
|
||||
let posts_dir = dir.path().join("posts").join("2024").join("01");
|
||||
fs::create_dir_all(&posts_dir).unwrap();
|
||||
let post_content = "\
|
||||
---
|
||||
id: idem-post
|
||||
title: Idempotent Post
|
||||
slug: idem-post
|
||||
status: draft
|
||||
createdAt: '2024-01-15T12:00:00.000Z'
|
||||
updatedAt: '2024-01-15T12:00:00.000Z'
|
||||
tags: []
|
||||
categories: []
|
||||
---
|
||||
Body text.
|
||||
";
|
||||
fs::write(posts_dir.join("idem-post.md"), post_content).unwrap();
|
||||
|
||||
let tpl_dir = dir.path().join("templates");
|
||||
fs::create_dir_all(&tpl_dir).unwrap();
|
||||
let tpl_content = "\
|
||||
---
|
||||
id: \"idem-tpl\"
|
||||
slug: \"idem-tpl\"
|
||||
title: \"Idempotent Template\"
|
||||
kind: \"post\"
|
||||
enabled: true
|
||||
version: 1
|
||||
createdAt: \"2024-01-15T12:00:00.000Z\"
|
||||
updatedAt: \"2024-01-15T12:00:00.000Z\"
|
||||
---
|
||||
<html>{{ content }}</html>
|
||||
";
|
||||
fs::write(tpl_dir.join("idem-tpl.liquid"), tpl_content).unwrap();
|
||||
|
||||
// First rebuild
|
||||
let r1 = rebuild_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert_eq!(r1.posts_created, 1);
|
||||
assert_eq!(r1.posts_updated, 0);
|
||||
assert_eq!(r1.templates_created, 1);
|
||||
assert_eq!(r1.templates_updated, 0);
|
||||
assert!(r1.errors.is_empty(), "errors: {:?}", r1.errors);
|
||||
|
||||
// Second rebuild - should update, not create
|
||||
let r2 = rebuild_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert_eq!(r2.posts_created, 0);
|
||||
assert_eq!(r2.posts_updated, 1);
|
||||
assert_eq!(r2.templates_created, 0);
|
||||
assert_eq!(r2.templates_updated, 1);
|
||||
assert!(r2.errors.is_empty(), "errors: {:?}", r2.errors);
|
||||
}
|
||||
}
|
||||
339
crates/bds-core/src/engine/script_rebuild.rs
Normal file
339
crates/bds-core/src/engine/script_rebuild.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::db::queries::script as qs;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Script, ScriptKind, ScriptStatus};
|
||||
use crate::util::frontmatter::read_script_file;
|
||||
use crate::util::now_unix_ms;
|
||||
|
||||
/// Report returned by `rebuild_scripts_from_filesystem`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ScriptRebuildReport {
|
||||
pub created: usize,
|
||||
pub updated: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parse a script kind string from frontmatter into a `ScriptKind`.
|
||||
fn parse_script_kind(s: &str) -> Result<ScriptKind, String> {
|
||||
match s {
|
||||
"macro" => Ok(ScriptKind::Macro),
|
||||
"utility" => Ok(ScriptKind::Utility),
|
||||
"transform" => Ok(ScriptKind::Transform),
|
||||
other => Err(format!("unknown script kind: '{other}'")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild scripts from the filesystem into the database.
|
||||
///
|
||||
/// Walks the `scripts/` directory for `*.lua` and `*.py` files, parses each
|
||||
/// via frontmatter, and either creates or updates the corresponding DB row.
|
||||
/// Published scripts (those present on disk) have `content = None` in the DB.
|
||||
pub fn rebuild_scripts_from_filesystem(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<ScriptRebuildReport> {
|
||||
let mut report = ScriptRebuildReport::default();
|
||||
let scripts_dir = data_dir.join("scripts");
|
||||
|
||||
if !scripts_dir.exists() {
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
for entry in WalkDir::new(&scripts_dir)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let ext = path.extension().and_then(|e| e.to_str());
|
||||
if ext != Some("lua") && ext != Some("py") {
|
||||
continue;
|
||||
}
|
||||
|
||||
match rebuild_single_script(conn, data_dir, project_id, path) {
|
||||
Ok(created) => {
|
||||
if created {
|
||||
report.created += 1;
|
||||
} else {
|
||||
report.updated += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
report.errors.push(format!("{}: {e}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Rebuild a single script from a `.lua` or `.py` file.
|
||||
/// Returns `true` if created, `false` if updated.
|
||||
fn rebuild_single_script(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
path: &Path,
|
||||
) -> EngineResult<bool> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let (fm, _body) = read_script_file(&content).map_err(EngineError::Parse)?;
|
||||
|
||||
let rel_path = path
|
||||
.strip_prefix(data_dir)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let kind = parse_script_kind(&fm.kind).map_err(EngineError::Parse)?;
|
||||
let now = now_unix_ms();
|
||||
|
||||
// File exists on disk -> Published; content is None in DB
|
||||
let status = ScriptStatus::Published;
|
||||
|
||||
let existing = qs::get_script_by_id(conn, &fm.id);
|
||||
match existing {
|
||||
Ok(mut script) => {
|
||||
script.slug = fm.slug;
|
||||
script.title = fm.title;
|
||||
script.kind = kind;
|
||||
script.entrypoint = fm.entrypoint;
|
||||
script.enabled = fm.enabled;
|
||||
script.version = fm.version;
|
||||
script.file_path = rel_path;
|
||||
script.status = status;
|
||||
script.content = None;
|
||||
script.created_at = fm.created_at;
|
||||
script.updated_at = now;
|
||||
qs::update_script(conn, &script)?;
|
||||
Ok(false)
|
||||
}
|
||||
Err(_) => {
|
||||
let script = Script {
|
||||
id: fm.id,
|
||||
project_id: project_id.to_string(),
|
||||
slug: fm.slug,
|
||||
title: fm.title,
|
||||
kind,
|
||||
entrypoint: fm.entrypoint,
|
||||
enabled: fm.enabled,
|
||||
version: fm.version,
|
||||
file_path: rel_path,
|
||||
status,
|
||||
content: None,
|
||||
created_at: fm.created_at,
|
||||
updated_at: now,
|
||||
};
|
||||
qs::insert_script(conn, &script)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
(db, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_creates_script_from_lua() {
|
||||
let (db, dir) = setup();
|
||||
let scripts_dir = dir.path().join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
|
||||
let content = "\
|
||||
---
|
||||
id: \"lua-script-1\"
|
||||
slug: \"my-macro\"
|
||||
title: \"My Macro\"
|
||||
kind: \"macro\"
|
||||
entrypoint: \"render\"
|
||||
enabled: true
|
||||
version: 1
|
||||
createdAt: \"2024-01-01T00:00:00.000Z\"
|
||||
updatedAt: \"2024-01-01T00:00:00.000Z\"
|
||||
---
|
||||
function render()
|
||||
return \"<p>hello</p>\"
|
||||
end
|
||||
";
|
||||
fs::write(scripts_dir.join("my-macro.lua"), content).unwrap();
|
||||
|
||||
let report =
|
||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.created, 1);
|
||||
assert_eq!(report.updated, 0);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
|
||||
let script = qs::get_script_by_id(db.conn(), "lua-script-1").unwrap();
|
||||
assert_eq!(script.slug, "my-macro");
|
||||
assert_eq!(script.title, "My Macro");
|
||||
assert_eq!(script.kind, ScriptKind::Macro);
|
||||
assert_eq!(script.entrypoint, "render");
|
||||
assert!(script.enabled);
|
||||
assert_eq!(script.version, 1);
|
||||
assert_eq!(script.status, ScriptStatus::Published);
|
||||
assert!(script.content.is_none(), "published script should have content=None in DB");
|
||||
assert_eq!(script.file_path, "scripts/my-macro.lua");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_creates_script_from_py() {
|
||||
let (db, dir) = setup();
|
||||
let scripts_dir = dir.path().join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
|
||||
let content = "\"\"\"\n\
|
||||
---
|
||||
id: \"py-script-1\"
|
||||
slug: \"my-utility\"
|
||||
title: \"My Utility\"
|
||||
kind: \"utility\"
|
||||
entrypoint: \"main\"
|
||||
enabled: true
|
||||
version: 1
|
||||
createdAt: \"2024-01-01T00:00:00.000Z\"
|
||||
updatedAt: \"2024-01-01T00:00:00.000Z\"
|
||||
---
|
||||
\"\"\"
|
||||
def main():
|
||||
print(\"hello\")
|
||||
";
|
||||
fs::write(scripts_dir.join("my-utility.py"), content).unwrap();
|
||||
|
||||
let report =
|
||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.created, 1);
|
||||
assert_eq!(report.updated, 0);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
|
||||
let script = qs::get_script_by_id(db.conn(), "py-script-1").unwrap();
|
||||
assert_eq!(script.slug, "my-utility");
|
||||
assert_eq!(script.title, "My Utility");
|
||||
assert_eq!(script.kind, ScriptKind::Utility);
|
||||
assert_eq!(script.entrypoint, "main");
|
||||
assert_eq!(script.status, ScriptStatus::Published);
|
||||
assert!(script.content.is_none());
|
||||
assert_eq!(script.file_path, "scripts/my-utility.py");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_updates_existing() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
// Insert a script in DB first
|
||||
let existing = Script {
|
||||
id: "existing-script".to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
slug: "old-script".to_string(),
|
||||
title: "Old Script".to_string(),
|
||||
kind: ScriptKind::Macro,
|
||||
entrypoint: "render".to_string(),
|
||||
enabled: false,
|
||||
version: 1,
|
||||
file_path: "scripts/old-script.lua".to_string(),
|
||||
status: ScriptStatus::Draft,
|
||||
content: Some("old content".to_string()),
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
};
|
||||
qs::insert_script(db.conn(), &existing).unwrap();
|
||||
|
||||
// Write updated file
|
||||
let scripts_dir = dir.path().join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
|
||||
let content = "\
|
||||
---
|
||||
id: \"existing-script\"
|
||||
slug: \"updated-script\"
|
||||
title: \"Updated Script\"
|
||||
kind: \"transform\"
|
||||
entrypoint: \"process\"
|
||||
enabled: true
|
||||
version: 5
|
||||
createdAt: \"2024-01-01T00:00:00.000Z\"
|
||||
updatedAt: \"2024-01-01T00:00:00.000Z\"
|
||||
---
|
||||
function process()
|
||||
return \"updated\"
|
||||
end
|
||||
";
|
||||
fs::write(scripts_dir.join("updated-script.lua"), content).unwrap();
|
||||
|
||||
let report =
|
||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.created, 0);
|
||||
assert_eq!(report.updated, 1);
|
||||
assert!(report.errors.is_empty());
|
||||
|
||||
let script = qs::get_script_by_id(db.conn(), "existing-script").unwrap();
|
||||
assert_eq!(script.slug, "updated-script");
|
||||
assert_eq!(script.title, "Updated Script");
|
||||
assert_eq!(script.kind, ScriptKind::Transform);
|
||||
assert_eq!(script.entrypoint, "process");
|
||||
assert!(script.enabled);
|
||||
assert_eq!(script.version, 5);
|
||||
assert_eq!(script.status, ScriptStatus::Published);
|
||||
assert!(script.content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_idempotent() {
|
||||
let (db, dir) = setup();
|
||||
let scripts_dir = dir.path().join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
|
||||
let content = "\
|
||||
---
|
||||
id: \"idem-script\"
|
||||
slug: \"idem\"
|
||||
title: \"Idempotent\"
|
||||
kind: \"utility\"
|
||||
entrypoint: \"run\"
|
||||
enabled: true
|
||||
version: 1
|
||||
createdAt: \"2024-01-01T00:00:00.000Z\"
|
||||
updatedAt: \"2024-01-01T00:00:00.000Z\"
|
||||
---
|
||||
function run() end
|
||||
";
|
||||
fs::write(scripts_dir.join("idem.lua"), content).unwrap();
|
||||
|
||||
// First run
|
||||
let r1 =
|
||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert_eq!(r1.created, 1);
|
||||
assert_eq!(r1.updated, 0);
|
||||
|
||||
// Second run - should update, not create
|
||||
let r2 =
|
||||
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert_eq!(r2.created, 0);
|
||||
assert_eq!(r2.updated, 1);
|
||||
|
||||
// Still only one script in DB
|
||||
let list = qs::list_scripts_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
}
|
||||
}
|
||||
466
crates/bds-core/src/engine/tag.rs
Normal file
466
crates/bds-core/src/engine/tag.rs
Normal file
@@ -0,0 +1,466 @@
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::queries::post as post_q;
|
||||
use crate::db::queries::tag as tag_q;
|
||||
use crate::engine::meta;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::metadata::TagEntry;
|
||||
use crate::model::Tag;
|
||||
use crate::util::now_unix_ms;
|
||||
|
||||
/// Create a new tag. Case-insensitive duplicate check.
|
||||
pub fn create_tag(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
color: Option<&str>,
|
||||
) -> EngineResult<Tag> {
|
||||
// Check for case-insensitive duplicate
|
||||
if tag_q::get_tag_by_project_and_name(conn, project_id, name).is_ok() {
|
||||
return Err(EngineError::Conflict(format!(
|
||||
"tag '{name}' already exists"
|
||||
)));
|
||||
}
|
||||
|
||||
let now = now_unix_ms();
|
||||
let tag = Tag {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
name: name.to_string(),
|
||||
color: color.map(|s| s.to_string()),
|
||||
post_template_slug: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
tag_q::insert_tag(conn, &tag)?;
|
||||
rewrite_tags_json(conn, data_dir, project_id)?;
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
/// Update a tag's name, color, and/or post_template_slug.
|
||||
pub fn update_tag(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
tag_id: &str,
|
||||
name: Option<&str>,
|
||||
color: Option<&str>,
|
||||
post_template_slug: Option<&str>,
|
||||
) -> EngineResult<()> {
|
||||
let mut tag = tag_q::get_tag_by_id(conn, tag_id)
|
||||
.map_err(|_| EngineError::NotFound(format!("tag {tag_id}")))?;
|
||||
|
||||
if let Some(n) = name {
|
||||
tag.name = n.to_string();
|
||||
}
|
||||
if let Some(c) = color {
|
||||
tag.color = if c.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(c.to_string())
|
||||
};
|
||||
}
|
||||
if let Some(pts) = post_template_slug {
|
||||
tag.post_template_slug = if pts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(pts.to_string())
|
||||
};
|
||||
}
|
||||
tag.updated_at = now_unix_ms();
|
||||
tag_q::update_tag(conn, &tag)?;
|
||||
rewrite_tags_json(conn, data_dir, &tag.project_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a tag: remove from all posts' tag arrays, delete from DB, rewrite tags.json.
|
||||
pub fn delete_tag(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
tag_id: &str,
|
||||
) -> EngineResult<()> {
|
||||
let tag = tag_q::get_tag_by_id(conn, tag_id)
|
||||
.map_err(|_| EngineError::NotFound(format!("tag {tag_id}")))?;
|
||||
|
||||
// Remove tag name from all posts
|
||||
remove_tag_name_from_posts(conn, project_id, &tag.name)?;
|
||||
|
||||
tag_q::delete_tag(conn, tag_id)?;
|
||||
rewrite_tags_json(conn, data_dir, project_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename a tag: update all posts' tag arrays, update tag in DB, rewrite tags.json.
|
||||
pub fn rename_tag(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
tag_id: &str,
|
||||
new_name: &str,
|
||||
) -> EngineResult<()> {
|
||||
let mut tag = tag_q::get_tag_by_id(conn, tag_id)
|
||||
.map_err(|_| EngineError::NotFound(format!("tag {tag_id}")))?;
|
||||
let old_name = tag.name.clone();
|
||||
|
||||
// Update all posts: replace old name with new name in tag arrays
|
||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||
let now = now_unix_ms();
|
||||
for mut post in posts {
|
||||
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(&old_name)) {
|
||||
post.tags = post
|
||||
.tags
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
if t.eq_ignore_ascii_case(&old_name) {
|
||||
new_name.to_string()
|
||||
} else {
|
||||
t
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
post.updated_at = now;
|
||||
post_q::update_post(conn, &post)?;
|
||||
}
|
||||
}
|
||||
|
||||
tag.name = new_name.to_string();
|
||||
tag.updated_at = now;
|
||||
tag_q::update_tag(conn, &tag)?;
|
||||
rewrite_tags_json(conn, data_dir, project_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge multiple source tags into one target tag.
|
||||
/// For each source: update posts (remove source name, add target name if not present), delete source.
|
||||
pub fn merge_tags(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
source_ids: &[&str],
|
||||
target_id: &str,
|
||||
) -> EngineResult<()> {
|
||||
let target_tag = tag_q::get_tag_by_id(conn, target_id)
|
||||
.map_err(|_| EngineError::NotFound(format!("target tag {target_id}")))?;
|
||||
|
||||
for &source_id in source_ids {
|
||||
let source_tag = tag_q::get_tag_by_id(conn, source_id)
|
||||
.map_err(|_| EngineError::NotFound(format!("source tag {source_id}")))?;
|
||||
|
||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||
let now = now_unix_ms();
|
||||
for mut post in posts {
|
||||
let has_source = post
|
||||
.tags
|
||||
.iter()
|
||||
.any(|t| t.eq_ignore_ascii_case(&source_tag.name));
|
||||
if has_source {
|
||||
// Remove source tag name
|
||||
post.tags
|
||||
.retain(|t| !t.eq_ignore_ascii_case(&source_tag.name));
|
||||
// Add target tag name if not already present
|
||||
if !post
|
||||
.tags
|
||||
.iter()
|
||||
.any(|t| t.eq_ignore_ascii_case(&target_tag.name))
|
||||
{
|
||||
post.tags.push(target_tag.name.clone());
|
||||
}
|
||||
post.updated_at = now;
|
||||
post_q::update_post(conn, &post)?;
|
||||
}
|
||||
}
|
||||
|
||||
tag_q::delete_tag(conn, source_id)?;
|
||||
}
|
||||
|
||||
rewrite_tags_json(conn, data_dir, project_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync tags from all posts: collect unique tag names, create missing tags in DB.
|
||||
pub fn sync_tags_from_posts(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<Vec<Tag>> {
|
||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||
|
||||
// Collect all unique tag names from posts
|
||||
let mut tag_names = std::collections::HashSet::new();
|
||||
for post in &posts {
|
||||
for tag_name in &post.tags {
|
||||
tag_names.insert(tag_name.to_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
// Create any tags that don't exist yet
|
||||
let now = now_unix_ms();
|
||||
for name in &tag_names {
|
||||
if tag_q::get_tag_by_project_and_name(conn, project_id, name).is_err() {
|
||||
let tag = Tag {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
name: name.clone(),
|
||||
color: None,
|
||||
post_template_slug: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
tag_q::insert_tag(conn, &tag)?;
|
||||
}
|
||||
}
|
||||
|
||||
rewrite_tags_json(conn, data_dir, project_id)?;
|
||||
let all_tags = tag_q::list_tags_by_project(conn, project_id)?;
|
||||
Ok(all_tags)
|
||||
}
|
||||
|
||||
/// Rewrite meta/tags.json from DB state.
|
||||
pub fn rewrite_tags_json(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<()> {
|
||||
let tags = tag_q::list_tags_by_project(conn, project_id)?;
|
||||
let entries: Vec<TagEntry> = tags
|
||||
.into_iter()
|
||||
.map(|t| TagEntry {
|
||||
name: t.name,
|
||||
color: t.color,
|
||||
post_template_slug: t.post_template_slug,
|
||||
})
|
||||
.collect();
|
||||
meta::write_tags_json(data_dir, &entries)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
fn remove_tag_name_from_posts(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
tag_name: &str,
|
||||
) -> EngineResult<()> {
|
||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||
let now = now_unix_ms();
|
||||
for mut post in posts {
|
||||
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_name)) {
|
||||
post.tags
|
||||
.retain(|t| !t.eq_ignore_ascii_case(tag_name));
|
||||
post.updated_at = now;
|
||||
post_q::update_post(conn, &post)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::post::insert_post;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::model::{Post, PostStatus};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
||||
// Seed tags.json
|
||||
std::fs::write(dir.path().join("meta/tags.json"), "[]").unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
(db, dir)
|
||||
}
|
||||
|
||||
fn make_post(id: &str, slug: &str, tags: Vec<String>) -> Post {
|
||||
Post {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
title: format!("Post {id}"),
|
||||
slug: slug.to_string(),
|
||||
excerpt: None,
|
||||
content: Some("body".into()),
|
||||
status: PostStatus::Draft,
|
||||
author: None,
|
||||
language: None,
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: format!("posts/{slug}.md"),
|
||||
checksum: None,
|
||||
tags,
|
||||
categories: vec![],
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
published_excerpt: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
published_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_tag_and_rewrite_json() {
|
||||
let (db, dir) = setup();
|
||||
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", Some("#ff0000")).unwrap();
|
||||
assert_eq!(tag.name, "rust");
|
||||
assert_eq!(tag.color.as_deref(), Some("#ff0000"));
|
||||
|
||||
// Verify tags.json was written
|
||||
let entries = meta::read_tags_json(dir.path()).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].name, "rust");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_tag_duplicate_rejected() {
|
||||
let (db, dir) = setup();
|
||||
create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
|
||||
let result = create_tag(db.conn(), dir.path(), "p1", "Rust", None);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_tag_fields() {
|
||||
let (db, dir) = setup();
|
||||
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
|
||||
update_tag(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&tag.id,
|
||||
Some("go"),
|
||||
Some("#00ff00"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let entries = meta::read_tags_json(dir.path()).unwrap();
|
||||
assert_eq!(entries[0].name, "go");
|
||||
assert_eq!(entries[0].color.as_deref(), Some("#00ff00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_tag_removes_from_posts() {
|
||||
let (db, dir) = setup();
|
||||
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
|
||||
insert_post(
|
||||
db.conn(),
|
||||
&make_post("x1", "hello", vec!["rust".into(), "web".into()]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
delete_tag(db.conn(), dir.path(), "p1", &tag.id).unwrap();
|
||||
|
||||
let post = crate::db::queries::post::get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert!(!post.tags.contains(&"rust".to_string()));
|
||||
assert!(post.tags.contains(&"web".to_string()));
|
||||
|
||||
let entries = meta::read_tags_json(dir.path()).unwrap();
|
||||
assert!(entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_tag_updates_posts() {
|
||||
let (db, dir) = setup();
|
||||
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
|
||||
insert_post(
|
||||
db.conn(),
|
||||
&make_post("x1", "hello", vec!["rust".into()]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
rename_tag(db.conn(), dir.path(), "p1", &tag.id, "golang").unwrap();
|
||||
|
||||
let post = crate::db::queries::post::get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert!(post.tags.contains(&"golang".to_string()));
|
||||
assert!(!post.tags.contains(&"rust".to_string()));
|
||||
|
||||
let entries = meta::read_tags_json(dir.path()).unwrap();
|
||||
assert_eq!(entries[0].name, "golang");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_tags_combines_into_target() {
|
||||
let (db, dir) = setup();
|
||||
let t1 = create_tag(db.conn(), dir.path(), "p1", "rs", None).unwrap();
|
||||
let t2 = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
|
||||
let t3 = create_tag(db.conn(), dir.path(), "p1", "target", None).unwrap();
|
||||
|
||||
insert_post(
|
||||
db.conn(),
|
||||
&make_post("x1", "a", vec!["rs".into()]),
|
||||
)
|
||||
.unwrap();
|
||||
insert_post(
|
||||
db.conn(),
|
||||
&make_post("x2", "b", vec!["rust".into(), "target".into()]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
merge_tags(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&[&t1.id, &t2.id],
|
||||
&t3.id,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Post x1 should now have "target"
|
||||
let p1 = crate::db::queries::post::get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert!(p1.tags.contains(&"target".to_string()));
|
||||
assert!(!p1.tags.contains(&"rs".to_string()));
|
||||
|
||||
// Post x2 should still have "target" only once
|
||||
let p2 = crate::db::queries::post::get_post_by_id(db.conn(), "x2").unwrap();
|
||||
assert_eq!(p2.tags.iter().filter(|t| *t == "target").count(), 1);
|
||||
|
||||
// Source tags deleted from DB
|
||||
let all = crate::db::queries::tag::list_tags_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].name, "target");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_tags_from_posts_creates_missing() {
|
||||
let (db, dir) = setup();
|
||||
insert_post(
|
||||
db.conn(),
|
||||
&make_post("x1", "a", vec!["rust".into(), "web".into()]),
|
||||
)
|
||||
.unwrap();
|
||||
insert_post(
|
||||
db.conn(),
|
||||
&make_post("x2", "b", vec!["web".into(), "go".into()]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tags = sync_tags_from_posts(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert_eq!(tags.len(), 3);
|
||||
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
||||
assert!(names.contains(&"go"));
|
||||
assert!(names.contains(&"rust"));
|
||||
assert!(names.contains(&"web"));
|
||||
|
||||
let entries = meta::read_tags_json(dir.path()).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_tags_json_matches_db() {
|
||||
let (db, dir) = setup();
|
||||
create_tag(db.conn(), dir.path(), "p1", "zebra", None).unwrap();
|
||||
create_tag(db.conn(), dir.path(), "p1", "alpha", None).unwrap();
|
||||
|
||||
let entries = meta::read_tags_json(dir.path()).unwrap();
|
||||
assert_eq!(entries[0].name, "alpha");
|
||||
assert_eq!(entries[1].name, "zebra");
|
||||
}
|
||||
}
|
||||
303
crates/bds-core/src/engine/task.rs
Normal file
303
crates/bds-core/src/engine/task.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Unique task identifier.
|
||||
pub type TaskId = u64;
|
||||
|
||||
/// Task status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TaskStatus {
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Progress update for a task.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskProgress {
|
||||
pub task_id: TaskId,
|
||||
pub message: String,
|
||||
pub percent: Option<f32>,
|
||||
}
|
||||
|
||||
/// Entry tracking a task.
|
||||
#[derive(Debug)]
|
||||
struct TaskEntry {
|
||||
id: TaskId,
|
||||
label: String,
|
||||
status: TaskStatus,
|
||||
cancel_flag: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// Manages concurrent tasks with a max concurrency limit and FIFO queue.
|
||||
pub struct TaskManager {
|
||||
max_concurrent: usize,
|
||||
next_id: Mutex<TaskId>,
|
||||
tasks: Mutex<Vec<TaskEntry>>,
|
||||
}
|
||||
|
||||
impl TaskManager {
|
||||
/// Create a new task manager with the given concurrency limit.
|
||||
pub fn new(max_concurrent: usize) -> Self {
|
||||
Self {
|
||||
max_concurrent,
|
||||
next_id: Mutex::new(1),
|
||||
tasks: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit a new task. Returns its unique identifier.
|
||||
pub fn submit(&self, label: &str) -> TaskId {
|
||||
let mut next = self.next_id.lock().unwrap();
|
||||
let id = *next;
|
||||
*next += 1;
|
||||
|
||||
let entry = TaskEntry {
|
||||
id,
|
||||
label: label.to_owned(),
|
||||
status: TaskStatus::Queued,
|
||||
cancel_flag: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
self.tasks.lock().unwrap().push(entry);
|
||||
id
|
||||
}
|
||||
|
||||
/// Try to start a queued task. Returns true if the task was moved to
|
||||
/// Running, false if concurrency is at capacity or the task is not Queued.
|
||||
pub fn try_start(&self, task_id: TaskId) -> bool {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
|
||||
if running >= self.max_concurrent {
|
||||
return false;
|
||||
}
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||
if entry.status == TaskStatus::Queued {
|
||||
entry.status = TaskStatus::Running;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Mark a task as completed.
|
||||
pub fn complete(&self, task_id: TaskId) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||
entry.status = TaskStatus::Completed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a task as failed with an error message.
|
||||
pub fn fail(&self, task_id: TaskId, error: String) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||
entry.status = TaskStatus::Failed(error);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a task by setting its cancel flag and status.
|
||||
pub fn cancel(&self, task_id: TaskId) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||
entry.cancel_flag.store(true, Ordering::Release);
|
||||
entry.status = TaskStatus::Cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a task has been cancelled.
|
||||
pub fn is_cancelled(&self, task_id: TaskId) -> bool {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks
|
||||
.iter()
|
||||
.find(|t| t.id == task_id)
|
||||
.map(|t| t.cancel_flag.load(Ordering::Acquire))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return the current status of a task.
|
||||
pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.iter().find(|t| t.id == task_id).map(|t| t.status.clone())
|
||||
}
|
||||
|
||||
/// Count tasks that are still queued.
|
||||
pub fn pending_count(&self) -> usize {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.iter().filter(|t| t.status == TaskStatus::Queued).count()
|
||||
}
|
||||
|
||||
/// Count tasks that are currently running.
|
||||
pub fn running_count(&self) -> usize {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.iter().filter(|t| t.status == TaskStatus::Running).count()
|
||||
}
|
||||
|
||||
/// Remove all completed, failed, and cancelled tasks.
|
||||
pub fn drain_completed(&self) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
tasks.retain(|t| matches!(t.status, TaskStatus::Queued | TaskStatus::Running));
|
||||
}
|
||||
|
||||
/// Return the label of a task.
|
||||
pub fn label(&self, task_id: TaskId) -> Option<String> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.iter().find(|t| t.id == task_id).map(|t| t.label.clone())
|
||||
}
|
||||
|
||||
/// Return the id of the first queued task (FIFO order).
|
||||
pub fn next_queued(&self) -> Option<TaskId> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.iter().find(|t| t.status == TaskStatus::Queued).map(|t| t.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskManager {
|
||||
fn default() -> Self {
|
||||
Self::new(3)
|
||||
}
|
||||
}
|
||||
|
||||
/// Default progress throttle interval (250ms per spec).
|
||||
pub const PROGRESS_THROTTLE_MS: u64 = 250;
|
||||
|
||||
/// Throttles progress reporting to avoid flooding.
|
||||
pub struct ProgressThrottle {
|
||||
interval_ms: u64,
|
||||
last_report: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl ProgressThrottle {
|
||||
/// Create a throttle with the given interval in milliseconds.
|
||||
pub fn new(interval_ms: u64) -> Self {
|
||||
Self {
|
||||
interval_ms,
|
||||
last_report: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if enough time has elapsed since the last report.
|
||||
pub fn should_report(&self) -> bool {
|
||||
let mut last = self.last_report.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
match *last {
|
||||
Some(prev) if now.duration_since(prev).as_millis() < self.interval_ms as u128 => false,
|
||||
_ => {
|
||||
*last = Some(now);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn submit_and_start() {
|
||||
let mgr = TaskManager::default();
|
||||
let id = mgr.submit("build site");
|
||||
assert_eq!(mgr.status(id), Some(TaskStatus::Queued));
|
||||
|
||||
assert!(mgr.try_start(id));
|
||||
assert_eq!(mgr.status(id), Some(TaskStatus::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_concurrent_enforced() {
|
||||
let mgr = TaskManager::new(3);
|
||||
let ids: Vec<TaskId> = (0..4).map(|i| mgr.submit(&format!("task {i}"))).collect();
|
||||
|
||||
assert!(mgr.try_start(ids[0]));
|
||||
assert!(mgr.try_start(ids[1]));
|
||||
assert!(mgr.try_start(ids[2]));
|
||||
assert!(!mgr.try_start(ids[3]));
|
||||
|
||||
assert_eq!(mgr.running_count(), 3);
|
||||
assert_eq!(mgr.status(ids[3]), Some(TaskStatus::Queued));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fifo_order() {
|
||||
let mgr = TaskManager::default();
|
||||
let a = mgr.submit("first");
|
||||
let b = mgr.submit("second");
|
||||
let c = mgr.submit("third");
|
||||
|
||||
assert_eq!(mgr.next_queued(), Some(a));
|
||||
mgr.try_start(a);
|
||||
assert_eq!(mgr.next_queued(), Some(b));
|
||||
mgr.try_start(b);
|
||||
assert_eq!(mgr.next_queued(), Some(c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_sets_flag() {
|
||||
let mgr = TaskManager::default();
|
||||
let id = mgr.submit("upload");
|
||||
mgr.try_start(id);
|
||||
|
||||
assert!(!mgr.is_cancelled(id));
|
||||
mgr.cancel(id);
|
||||
assert!(mgr.is_cancelled(id));
|
||||
assert_eq!(mgr.status(id), Some(TaskStatus::Cancelled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_and_fail() {
|
||||
let mgr = TaskManager::default();
|
||||
let ok = mgr.submit("good task");
|
||||
let bad = mgr.submit("bad task");
|
||||
|
||||
mgr.try_start(ok);
|
||||
mgr.try_start(bad);
|
||||
|
||||
mgr.complete(ok);
|
||||
mgr.fail(bad, "disk full".into());
|
||||
|
||||
assert_eq!(mgr.status(ok), Some(TaskStatus::Completed));
|
||||
assert_eq!(mgr.status(bad), Some(TaskStatus::Failed("disk full".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_removes_finished() {
|
||||
let mgr = TaskManager::default();
|
||||
let a = mgr.submit("done");
|
||||
let b = mgr.submit("broken");
|
||||
let c = mgr.submit("stopped");
|
||||
let d = mgr.submit("waiting");
|
||||
let e = mgr.submit("busy");
|
||||
|
||||
mgr.try_start(a);
|
||||
mgr.try_start(b);
|
||||
mgr.try_start(e);
|
||||
mgr.complete(a);
|
||||
mgr.fail(b, "oops".into());
|
||||
mgr.cancel(c);
|
||||
|
||||
mgr.drain_completed();
|
||||
|
||||
assert_eq!(mgr.status(a), None);
|
||||
assert_eq!(mgr.status(b), None);
|
||||
assert_eq!(mgr.status(c), None);
|
||||
assert_eq!(mgr.status(d), Some(TaskStatus::Queued));
|
||||
assert_eq!(mgr.status(e), Some(TaskStatus::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_throttle_initial_reports() {
|
||||
let throttle = ProgressThrottle::new(PROGRESS_THROTTLE_MS);
|
||||
assert!(throttle.should_report());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_throttle_suppresses_rapid() {
|
||||
let throttle = ProgressThrottle::new(PROGRESS_THROTTLE_MS);
|
||||
assert!(throttle.should_report());
|
||||
assert!(!throttle.should_report());
|
||||
}
|
||||
}
|
||||
304
crates/bds-core/src/engine/template_rebuild.rs
Normal file
304
crates/bds-core/src/engine/template_rebuild.rs
Normal file
@@ -0,0 +1,304 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::db::queries::template as qt;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Template, TemplateKind, TemplateStatus};
|
||||
use crate::util::frontmatter::read_template_file;
|
||||
use crate::util::now_unix_ms;
|
||||
|
||||
/// Report returned by `rebuild_templates_from_filesystem`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TemplateRebuildReport {
|
||||
pub created: usize,
|
||||
pub updated: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parse a template kind string from frontmatter into a `TemplateKind`.
|
||||
fn parse_template_kind(s: &str) -> Result<TemplateKind, String> {
|
||||
match s {
|
||||
"post" => Ok(TemplateKind::Post),
|
||||
"list" => Ok(TemplateKind::List),
|
||||
"not_found" | "notFound" | "not-found" => Ok(TemplateKind::NotFound),
|
||||
"partial" => Ok(TemplateKind::Partial),
|
||||
other => Err(format!("unknown template kind: '{other}'")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild templates from the filesystem into the database.
|
||||
///
|
||||
/// Walks the `templates/` directory for `*.liquid` files, parses each via
|
||||
/// frontmatter, and either creates or updates the corresponding DB row.
|
||||
/// Published templates (those present on disk) have `content = None` in the DB.
|
||||
pub fn rebuild_templates_from_filesystem(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<TemplateRebuildReport> {
|
||||
let mut report = TemplateRebuildReport::default();
|
||||
let templates_dir = data_dir.join("templates");
|
||||
|
||||
if !templates_dir.exists() {
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
for entry in WalkDir::new(&templates_dir)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let ext = path.extension().and_then(|e| e.to_str());
|
||||
if ext != Some("liquid") {
|
||||
continue;
|
||||
}
|
||||
|
||||
match rebuild_single_template(conn, data_dir, project_id, path) {
|
||||
Ok(created) => {
|
||||
if created {
|
||||
report.created += 1;
|
||||
} else {
|
||||
report.updated += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
report.errors.push(format!("{}: {e}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Rebuild a single template from a `.liquid` file.
|
||||
/// Returns `true` if created, `false` if updated.
|
||||
fn rebuild_single_template(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
path: &Path,
|
||||
) -> EngineResult<bool> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let (fm, _body) = read_template_file(&content).map_err(EngineError::Parse)?;
|
||||
|
||||
let rel_path = path
|
||||
.strip_prefix(data_dir)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let kind = parse_template_kind(&fm.kind).map_err(EngineError::Parse)?;
|
||||
let now = now_unix_ms();
|
||||
|
||||
// File exists on disk -> Published; content is None in DB
|
||||
let status = TemplateStatus::Published;
|
||||
|
||||
let existing = qt::get_template_by_id(conn, &fm.id);
|
||||
match existing {
|
||||
Ok(mut tpl) => {
|
||||
tpl.slug = fm.slug;
|
||||
tpl.title = fm.title;
|
||||
tpl.kind = kind;
|
||||
tpl.enabled = fm.enabled;
|
||||
tpl.version = fm.version;
|
||||
tpl.file_path = rel_path;
|
||||
tpl.status = status;
|
||||
tpl.content = None;
|
||||
tpl.created_at = fm.created_at;
|
||||
tpl.updated_at = now;
|
||||
qt::update_template(conn, &tpl)?;
|
||||
Ok(false)
|
||||
}
|
||||
Err(_) => {
|
||||
let tpl = Template {
|
||||
id: fm.id,
|
||||
project_id: project_id.to_string(),
|
||||
slug: fm.slug,
|
||||
title: fm.title,
|
||||
kind,
|
||||
enabled: fm.enabled,
|
||||
version: fm.version,
|
||||
file_path: rel_path,
|
||||
status,
|
||||
content: None,
|
||||
created_at: fm.created_at,
|
||||
updated_at: now,
|
||||
};
|
||||
qt::insert_template(conn, &tpl)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
(db, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_creates_template() {
|
||||
let (db, dir) = setup();
|
||||
let tpl_dir = dir.path().join("templates");
|
||||
fs::create_dir_all(&tpl_dir).unwrap();
|
||||
|
||||
let content = "\
|
||||
---
|
||||
id: \"aaa-bbb-ccc\"
|
||||
slug: \"my-template\"
|
||||
title: \"My Template\"
|
||||
kind: \"post\"
|
||||
enabled: true
|
||||
version: 1
|
||||
createdAt: \"2024-01-01T00:00:00.000Z\"
|
||||
updatedAt: \"2024-01-01T00:00:00.000Z\"
|
||||
---
|
||||
<div>hello</div>
|
||||
";
|
||||
fs::write(tpl_dir.join("my-template.liquid"), content).unwrap();
|
||||
|
||||
let report =
|
||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.created, 1);
|
||||
assert_eq!(report.updated, 0);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
|
||||
let tpl = qt::get_template_by_id(db.conn(), "aaa-bbb-ccc").unwrap();
|
||||
assert_eq!(tpl.slug, "my-template");
|
||||
assert_eq!(tpl.title, "My Template");
|
||||
assert_eq!(tpl.kind, TemplateKind::Post);
|
||||
assert!(tpl.enabled);
|
||||
assert_eq!(tpl.version, 1);
|
||||
assert_eq!(tpl.status, TemplateStatus::Published);
|
||||
assert!(tpl.content.is_none(), "published template should have content=None in DB");
|
||||
assert_eq!(tpl.file_path, "templates/my-template.liquid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_updates_existing() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
// Insert a template in DB first
|
||||
let existing = Template {
|
||||
id: "existing-id".to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
slug: "old-slug".to_string(),
|
||||
title: "Old Title".to_string(),
|
||||
kind: TemplateKind::Post,
|
||||
enabled: false,
|
||||
version: 1,
|
||||
file_path: "templates/old-slug.liquid".to_string(),
|
||||
status: TemplateStatus::Draft,
|
||||
content: Some("<p>old</p>".to_string()),
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
};
|
||||
qt::insert_template(db.conn(), &existing).unwrap();
|
||||
|
||||
// Write updated file
|
||||
let tpl_dir = dir.path().join("templates");
|
||||
fs::create_dir_all(&tpl_dir).unwrap();
|
||||
|
||||
let content = "\
|
||||
---
|
||||
id: \"existing-id\"
|
||||
slug: \"updated-slug\"
|
||||
title: \"Updated Title\"
|
||||
kind: \"list\"
|
||||
enabled: true
|
||||
version: 3
|
||||
createdAt: \"2024-01-01T00:00:00.000Z\"
|
||||
updatedAt: \"2024-01-01T00:00:00.000Z\"
|
||||
---
|
||||
<div>updated body</div>
|
||||
";
|
||||
fs::write(tpl_dir.join("updated-slug.liquid"), content).unwrap();
|
||||
|
||||
let report =
|
||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.created, 0);
|
||||
assert_eq!(report.updated, 1);
|
||||
assert!(report.errors.is_empty());
|
||||
|
||||
let tpl = qt::get_template_by_id(db.conn(), "existing-id").unwrap();
|
||||
assert_eq!(tpl.slug, "updated-slug");
|
||||
assert_eq!(tpl.title, "Updated Title");
|
||||
assert_eq!(tpl.kind, TemplateKind::List);
|
||||
assert!(tpl.enabled);
|
||||
assert_eq!(tpl.version, 3);
|
||||
assert_eq!(tpl.status, TemplateStatus::Published);
|
||||
assert!(tpl.content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_ignores_non_liquid_files() {
|
||||
let (db, dir) = setup();
|
||||
let tpl_dir = dir.path().join("templates");
|
||||
fs::create_dir_all(&tpl_dir).unwrap();
|
||||
|
||||
fs::write(tpl_dir.join("readme.txt"), "not a template").unwrap();
|
||||
fs::write(tpl_dir.join("styles.css"), "body {}").unwrap();
|
||||
|
||||
let report =
|
||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.created, 0);
|
||||
assert_eq!(report.updated, 0);
|
||||
assert!(report.errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_idempotent() {
|
||||
let (db, dir) = setup();
|
||||
let tpl_dir = dir.path().join("templates");
|
||||
fs::create_dir_all(&tpl_dir).unwrap();
|
||||
|
||||
let content = "\
|
||||
---
|
||||
id: \"idem-id\"
|
||||
slug: \"idem\"
|
||||
title: \"Idempotent\"
|
||||
kind: \"partial\"
|
||||
enabled: true
|
||||
version: 1
|
||||
createdAt: \"2024-01-01T00:00:00.000Z\"
|
||||
updatedAt: \"2024-01-01T00:00:00.000Z\"
|
||||
---
|
||||
<span>partial</span>
|
||||
";
|
||||
fs::write(tpl_dir.join("idem.liquid"), content).unwrap();
|
||||
|
||||
// First run
|
||||
let r1 =
|
||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert_eq!(r1.created, 1);
|
||||
assert_eq!(r1.updated, 0);
|
||||
|
||||
// Second run - should update, not create
|
||||
let r2 =
|
||||
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert_eq!(r2.created, 0);
|
||||
assert_eq!(r2.updated, 1);
|
||||
|
||||
// Still only one template in DB
|
||||
let list = qt::list_templates_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ pub enum SshMode {
|
||||
|
||||
/// Publishing preferences stored in meta/publishing.json.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublishingPreferences {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssh_host: Option<String>,
|
||||
|
||||
146
crates/bds-core/src/model/metadata.rs
Normal file
146
crates/bds-core/src/model/metadata.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn default_max_posts() -> i32 {
|
||||
50
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectMetadata {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub main_language: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_author: Option<String>,
|
||||
#[serde(default = "default_max_posts")]
|
||||
pub max_posts_per_page: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blogmark_category: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pico_theme: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub python_runtime_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub semantic_similarity_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub blog_languages: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CategorySettings {
|
||||
#[serde(default = "default_true")]
|
||||
pub render_in_lists: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_title: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub post_template_slug: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub list_template_slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TagEntry {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "postTemplateSlug")]
|
||||
pub post_template_slug: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn project_metadata_roundtrip() {
|
||||
let meta = ProjectMetadata {
|
||||
name: "Test Blog".into(),
|
||||
description: None,
|
||||
public_url: Some("https://example.com".into()),
|
||||
main_language: Some("en".into()),
|
||||
default_author: None,
|
||||
max_posts_per_page: 50,
|
||||
blogmark_category: None,
|
||||
pico_theme: None,
|
||||
python_runtime_mode: None,
|
||||
semantic_similarity_enabled: false,
|
||||
blog_languages: vec!["en".into(), "de".into()],
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&meta).unwrap();
|
||||
let parsed: ProjectMetadata = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.name, "Test Blog");
|
||||
assert_eq!(parsed.max_posts_per_page, 50);
|
||||
assert_eq!(parsed.blog_languages, vec!["en", "de"]);
|
||||
// Verify camelCase
|
||||
assert!(json.contains("publicUrl"));
|
||||
assert!(json.contains("maxPostsPerPage"));
|
||||
assert!(json.contains("blogLanguages"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_metadata_defaults() {
|
||||
let json = r#"{"name": "Minimal"}"#;
|
||||
let meta: ProjectMetadata = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(meta.max_posts_per_page, 50);
|
||||
assert!(!meta.semantic_similarity_enabled);
|
||||
assert!(meta.blog_languages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_settings_defaults() {
|
||||
let json = "{}";
|
||||
let settings: CategorySettings = serde_json::from_str(json).unwrap();
|
||||
assert!(settings.render_in_lists);
|
||||
assert!(settings.show_title);
|
||||
assert!(settings.title.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_settings_camel_case() {
|
||||
let settings = CategorySettings {
|
||||
render_in_lists: false,
|
||||
show_title: true,
|
||||
title: Some("Articles".into()),
|
||||
post_template_slug: Some("article-tpl".into()),
|
||||
list_template_slug: None,
|
||||
};
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
assert!(json.contains("renderInLists"));
|
||||
assert!(json.contains("showTitle"));
|
||||
assert!(json.contains("postTemplateSlug"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_entry_roundtrip() {
|
||||
let tag = TagEntry {
|
||||
name: "rust".into(),
|
||||
color: Some("#ff0000".into()),
|
||||
post_template_slug: Some("code-tpl".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&tag).unwrap();
|
||||
assert!(json.contains("postTemplateSlug"));
|
||||
let parsed: TagEntry = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.name, "rust");
|
||||
assert_eq!(parsed.color.as_deref(), Some("#ff0000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_entry_minimal() {
|
||||
let json = r#"{"name": "go"}"#;
|
||||
let tag: TagEntry = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(tag.name, "go");
|
||||
assert!(tag.color.is_none());
|
||||
assert!(tag.post_template_slug.is_none());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod project;
|
||||
mod template;
|
||||
mod script;
|
||||
mod generation;
|
||||
pub mod metadata;
|
||||
|
||||
pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation};
|
||||
pub use media::{Media, MediaTranslation};
|
||||
@@ -16,3 +17,4 @@ pub use generation::{
|
||||
DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity,
|
||||
PublishingPreferences, SshMode,
|
||||
};
|
||||
pub use metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
||||
|
||||
53
crates/bds-core/src/util/atomic_write.rs
Normal file
53
crates/bds-core/src/util/atomic_write.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
/// Write `content` to `path` atomically: write to a temp file in the same
|
||||
/// directory, then rename. Creates parent directories if missing.
|
||||
pub fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
let mut file = fs::File::create(&tmp_path)?;
|
||||
file.write_all(content)?;
|
||||
file.sync_all()?;
|
||||
fs::rename(&tmp_path, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convenience wrapper for UTF-8 string content.
|
||||
pub fn atomic_write_str(path: &Path, content: &str) -> io::Result<()> {
|
||||
atomic_write(path, content.as_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn write_and_read_back() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("test.txt");
|
||||
atomic_write_str(&path, "hello world").unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_parent_directories() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("a").join("b").join("c.txt");
|
||||
atomic_write_str(&path, "nested").unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "nested");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrites_existing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("test.txt");
|
||||
atomic_write_str(&path, "v1").unwrap();
|
||||
atomic_write_str(&path, "v2").unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
|
||||
}
|
||||
}
|
||||
813
crates/bds-core/src/util/frontmatter.rs
Normal file
813
crates/bds-core/src/util/frontmatter.rs
Normal file
@@ -0,0 +1,813 @@
|
||||
use crate::model::{Post, PostTranslation};
|
||||
use crate::util::timestamp::{unix_ms_to_iso, iso_to_unix_ms};
|
||||
|
||||
/// Split content at `---` delimiters into (yaml, body).
|
||||
/// Returns `None` if the content does not start with `---`.
|
||||
pub fn split_frontmatter(input: &str) -> Option<(&str, &str)> {
|
||||
let trimmed = input.trim_start_matches('\u{feff}'); // strip BOM
|
||||
if !trimmed.starts_with("---") {
|
||||
return None;
|
||||
}
|
||||
let after_first = &trimmed[3..];
|
||||
let after_first = after_first.strip_prefix('\n').unwrap_or(after_first);
|
||||
let end = after_first.find("\n---")?;
|
||||
let yaml = &after_first[..end];
|
||||
let rest = &after_first[end + 4..]; // skip "\n---"
|
||||
let body = rest.strip_prefix('\n').unwrap_or(rest);
|
||||
Some((yaml, body))
|
||||
}
|
||||
|
||||
/// Split content at Python docstring `"""` delimiters (for legacy .py scripts).
|
||||
/// The YAML frontmatter is inside: `"""\n---\n{yaml}\n---\n"""`
|
||||
pub fn split_docstring_frontmatter(input: &str) -> Option<(&str, &str)> {
|
||||
let trimmed = input.trim_start_matches('\u{feff}');
|
||||
if !trimmed.starts_with("\"\"\"") {
|
||||
return None;
|
||||
}
|
||||
let after_open = &trimmed[3..];
|
||||
let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
|
||||
// Find closing """
|
||||
let close_pos = after_open.find("\"\"\"")?;
|
||||
let inside = &after_open[..close_pos].trim();
|
||||
// Inside should be ---\n{yaml}\n---
|
||||
let yaml_content = split_frontmatter(inside)?;
|
||||
let body_start = close_pos + 3;
|
||||
let rest = &after_open[body_start..];
|
||||
let body = rest.strip_prefix('\n').unwrap_or(rest);
|
||||
Some((yaml_content.0, body))
|
||||
}
|
||||
|
||||
/// Format frontmatter + body into a complete file string.
|
||||
pub fn format_frontmatter(yaml: &str, body: &str) -> String {
|
||||
format!("---\n{yaml}\n---\n{body}")
|
||||
}
|
||||
|
||||
// --- Post Frontmatter ---
|
||||
|
||||
/// Parsed post frontmatter fields (camelCase for YAML compatibility).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PostFrontmatter {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub status: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub tags: Vec<String>,
|
||||
pub categories: Vec<String>,
|
||||
pub excerpt: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub language: Option<String>,
|
||||
pub template_slug: Option<String>,
|
||||
pub do_not_translate: bool,
|
||||
pub published_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl PostFrontmatter {
|
||||
/// Build from a Post model.
|
||||
pub fn from_post(post: &Post) -> Self {
|
||||
Self {
|
||||
id: post.id.clone(),
|
||||
title: post.title.clone(),
|
||||
slug: post.slug.clone(),
|
||||
status: serde_json::to_string(&post.status)
|
||||
.unwrap_or_default()
|
||||
.trim_matches('"')
|
||||
.to_string(),
|
||||
created_at: post.created_at,
|
||||
updated_at: post.updated_at,
|
||||
tags: post.tags.clone(),
|
||||
categories: post.categories.clone(),
|
||||
excerpt: post.excerpt.clone(),
|
||||
author: post.author.clone(),
|
||||
language: post.language.clone(),
|
||||
template_slug: post.template_slug.clone(),
|
||||
do_not_translate: post.do_not_translate,
|
||||
published_at: post.published_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to YAML string (matching TypeScript gray-matter output).
|
||||
pub fn to_yaml(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!("id: {}", self.id));
|
||||
lines.push(format!("title: {}", yaml_string_value(&self.title)));
|
||||
lines.push(format!("slug: {}", self.slug));
|
||||
lines.push(format!("status: {}", self.status));
|
||||
lines.push(format!("createdAt: '{}'", unix_ms_to_iso(self.created_at)));
|
||||
lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(self.updated_at)));
|
||||
|
||||
// Tags as YAML list
|
||||
if self.tags.is_empty() {
|
||||
lines.push("tags: []".to_string());
|
||||
} else {
|
||||
lines.push("tags:".to_string());
|
||||
for tag in &self.tags {
|
||||
lines.push(format!(" - {}", yaml_string_value(tag)));
|
||||
}
|
||||
}
|
||||
|
||||
// Categories as YAML list
|
||||
if self.categories.is_empty() {
|
||||
lines.push("categories: []".to_string());
|
||||
} else {
|
||||
lines.push("categories:".to_string());
|
||||
for cat in &self.categories {
|
||||
lines.push(format!(" - {}", yaml_string_value(cat)));
|
||||
}
|
||||
}
|
||||
|
||||
// Conditional fields (only when truthy)
|
||||
if let Some(ref excerpt) = self.excerpt {
|
||||
if !excerpt.is_empty() {
|
||||
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
|
||||
}
|
||||
}
|
||||
if let Some(ref author) = self.author {
|
||||
if !author.is_empty() {
|
||||
lines.push(format!("author: {}", yaml_string_value(author)));
|
||||
}
|
||||
}
|
||||
if let Some(ref language) = self.language {
|
||||
if !language.is_empty() {
|
||||
lines.push(format!("language: {language}"));
|
||||
}
|
||||
}
|
||||
if self.do_not_translate {
|
||||
lines.push("doNotTranslate: true".to_string());
|
||||
}
|
||||
if let Some(ref template_slug) = self.template_slug {
|
||||
if !template_slug.is_empty() {
|
||||
lines.push(format!("templateSlug: {template_slug}"));
|
||||
}
|
||||
}
|
||||
if let Some(published_at) = self.published_at {
|
||||
lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at)));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Parse from a YAML string.
|
||||
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
|
||||
let doc: serde_yaml::Value =
|
||||
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
|
||||
let map = doc
|
||||
.as_mapping()
|
||||
.ok_or("frontmatter is not a YAML mapping")?;
|
||||
|
||||
let get_str = |key: &str| -> Option<String> {
|
||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
||||
.and_then(|v| match v {
|
||||
serde_yaml::Value::String(s) => Some(s.clone()),
|
||||
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
||||
serde_yaml::Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
|
||||
let get_string_list = |key: &str| -> Vec<String> {
|
||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_sequence())
|
||||
.map(|seq| {
|
||||
seq.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let get_timestamp = |key: &str| -> Result<i64, String> {
|
||||
let s = get_str(key).ok_or(format!("missing required field '{key}'"))?;
|
||||
iso_to_unix_ms(&s)
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
id: get_str("id").ok_or("missing 'id'")?,
|
||||
title: get_str("title").ok_or("missing 'title'")?,
|
||||
slug: get_str("slug").ok_or("missing 'slug'")?,
|
||||
status: get_str("status").ok_or("missing 'status'")?,
|
||||
created_at: get_timestamp("createdAt")?,
|
||||
updated_at: get_timestamp("updatedAt")?,
|
||||
tags: get_string_list("tags"),
|
||||
categories: get_string_list("categories"),
|
||||
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
|
||||
author: get_str("author").filter(|s| !s.is_empty()),
|
||||
language: get_str("language").filter(|s| !s.is_empty()),
|
||||
template_slug: get_str("templateSlug").filter(|s| !s.is_empty()),
|
||||
do_not_translate: get_str("doNotTranslate")
|
||||
.map(|s| s == "true")
|
||||
.unwrap_or(false),
|
||||
published_at: get_str("publishedAt")
|
||||
.and_then(|s| iso_to_unix_ms(&s).ok()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a complete post file (frontmatter + body).
|
||||
pub fn write_post_file(post: &Post, body: &str) -> String {
|
||||
let fm = PostFrontmatter::from_post(post);
|
||||
format_frontmatter(&fm.to_yaml(), body)
|
||||
}
|
||||
|
||||
/// Read a complete post file, returning frontmatter + body.
|
||||
pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String> {
|
||||
let (yaml, body) = split_frontmatter(content)
|
||||
.ok_or("no frontmatter delimiters found")?;
|
||||
let fm = PostFrontmatter::from_yaml(yaml)?;
|
||||
Ok((fm, body.to_string()))
|
||||
}
|
||||
|
||||
// --- Translation Frontmatter ---
|
||||
|
||||
/// Parsed translation frontmatter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TranslationFrontmatter {
|
||||
pub translation_for: String,
|
||||
pub language: String,
|
||||
pub title: String,
|
||||
pub excerpt: Option<String>,
|
||||
}
|
||||
|
||||
impl TranslationFrontmatter {
|
||||
pub fn from_translation(t: &PostTranslation) -> Self {
|
||||
Self {
|
||||
translation_for: t.translation_for.clone(),
|
||||
language: t.language.clone(),
|
||||
title: t.title.clone(),
|
||||
excerpt: t.excerpt.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_yaml(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!("translationFor: {}", self.translation_for));
|
||||
lines.push(format!("language: {}", self.language));
|
||||
lines.push(format!("title: {}", yaml_string_value(&self.title)));
|
||||
if let Some(ref excerpt) = self.excerpt {
|
||||
if !excerpt.is_empty() {
|
||||
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
|
||||
let doc: serde_yaml::Value =
|
||||
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
|
||||
let map = doc
|
||||
.as_mapping()
|
||||
.ok_or("frontmatter is not a YAML mapping")?;
|
||||
|
||||
let get_str = |key: &str| -> Option<String> {
|
||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
translation_for: get_str("translationFor").ok_or("missing 'translationFor'")?,
|
||||
language: get_str("language").ok_or("missing 'language'")?,
|
||||
title: get_str("title").ok_or("missing 'title'")?,
|
||||
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a complete translation file (frontmatter + body).
|
||||
pub fn write_translation_file(translation: &PostTranslation, body: &str) -> String {
|
||||
let fm = TranslationFrontmatter::from_translation(translation);
|
||||
format_frontmatter(&fm.to_yaml(), body)
|
||||
}
|
||||
|
||||
/// Read a complete translation file.
|
||||
pub fn read_translation_file(content: &str) -> Result<(TranslationFrontmatter, String), String> {
|
||||
let (yaml, body) = split_frontmatter(content)
|
||||
.ok_or("no frontmatter delimiters found")?;
|
||||
let fm = TranslationFrontmatter::from_yaml(yaml)?;
|
||||
Ok((fm, body.to_string()))
|
||||
}
|
||||
|
||||
// --- Template Frontmatter ---
|
||||
|
||||
/// Parsed template frontmatter (double-quoted strings, matching TypeScript output).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TemplateFrontmatter {
|
||||
pub id: String,
|
||||
pub project_id: Option<String>,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub kind: String,
|
||||
pub enabled: bool,
|
||||
pub version: i32,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl TemplateFrontmatter {
|
||||
/// Serialize to YAML with double-quoted strings (matching TypeScript).
|
||||
pub fn to_yaml(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!("id: \"{}\"", self.id));
|
||||
if let Some(ref pid) = self.project_id {
|
||||
lines.push(format!("projectId: \"{pid}\""));
|
||||
}
|
||||
lines.push(format!("slug: \"{}\"", self.slug));
|
||||
lines.push(format!("title: \"{}\"", self.title));
|
||||
lines.push(format!("kind: \"{}\"", self.kind));
|
||||
lines.push(format!("enabled: {}", self.enabled));
|
||||
lines.push(format!("version: {}", self.version));
|
||||
lines.push(format!("createdAt: \"{}\"", unix_ms_to_iso(self.created_at)));
|
||||
lines.push(format!("updatedAt: \"{}\"", unix_ms_to_iso(self.updated_at)));
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
|
||||
let doc: serde_yaml::Value =
|
||||
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
|
||||
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
|
||||
|
||||
let get_str = |key: &str| -> Option<String> {
|
||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
||||
.and_then(|v| match v {
|
||||
serde_yaml::Value::String(s) => Some(s.clone()),
|
||||
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
||||
serde_yaml::Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
id: get_str("id").ok_or("missing 'id'")?,
|
||||
project_id: get_str("projectId"),
|
||||
slug: get_str("slug").ok_or("missing 'slug'")?,
|
||||
title: get_str("title").ok_or("missing 'title'")?,
|
||||
kind: get_str("kind").ok_or("missing 'kind'")?,
|
||||
enabled: get_str("enabled").map(|s| s == "true").unwrap_or(true),
|
||||
version: get_str("version")
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(1),
|
||||
created_at: get_str("createdAt")
|
||||
.and_then(|s| iso_to_unix_ms(&s).ok())
|
||||
.ok_or("missing 'createdAt'")?,
|
||||
updated_at: get_str("updatedAt")
|
||||
.and_then(|s| iso_to_unix_ms(&s).ok())
|
||||
.ok_or("missing 'updatedAt'")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a template file (frontmatter + body).
|
||||
pub fn read_template_file(content: &str) -> Result<(TemplateFrontmatter, String), String> {
|
||||
let (yaml, body) = split_frontmatter(content)
|
||||
.ok_or("no frontmatter delimiters found")?;
|
||||
let fm = TemplateFrontmatter::from_yaml(yaml)?;
|
||||
Ok((fm, body.to_string()))
|
||||
}
|
||||
|
||||
/// Write a template file (frontmatter + body).
|
||||
pub fn write_template_file(fm: &TemplateFrontmatter, body: &str) -> String {
|
||||
format_frontmatter(&fm.to_yaml(), body)
|
||||
}
|
||||
|
||||
// --- Script Frontmatter ---
|
||||
|
||||
/// Parsed script frontmatter (double-quoted strings like templates, plus entrypoint).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScriptFrontmatter {
|
||||
pub id: String,
|
||||
pub project_id: Option<String>,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub kind: String,
|
||||
pub entrypoint: String,
|
||||
pub enabled: bool,
|
||||
pub version: i32,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl ScriptFrontmatter {
|
||||
/// Serialize to YAML with double-quoted strings.
|
||||
pub fn to_yaml(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!("id: \"{}\"", self.id));
|
||||
if let Some(ref pid) = self.project_id {
|
||||
lines.push(format!("projectId: \"{pid}\""));
|
||||
}
|
||||
lines.push(format!("slug: \"{}\"", self.slug));
|
||||
lines.push(format!("title: \"{}\"", self.title));
|
||||
lines.push(format!("kind: \"{}\"", self.kind));
|
||||
lines.push(format!("entrypoint: \"{}\"", self.entrypoint));
|
||||
lines.push(format!("enabled: {}", self.enabled));
|
||||
lines.push(format!("version: {}", self.version));
|
||||
lines.push(format!("createdAt: \"{}\"", unix_ms_to_iso(self.created_at)));
|
||||
lines.push(format!("updatedAt: \"{}\"", unix_ms_to_iso(self.updated_at)));
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
|
||||
let doc: serde_yaml::Value =
|
||||
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
|
||||
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
|
||||
|
||||
let get_str = |key: &str| -> Option<String> {
|
||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
||||
.and_then(|v| match v {
|
||||
serde_yaml::Value::String(s) => Some(s.clone()),
|
||||
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
||||
serde_yaml::Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
id: get_str("id").ok_or("missing 'id'")?,
|
||||
project_id: get_str("projectId"),
|
||||
slug: get_str("slug").ok_or("missing 'slug'")?,
|
||||
title: get_str("title").ok_or("missing 'title'")?,
|
||||
kind: get_str("kind").ok_or("missing 'kind'")?,
|
||||
entrypoint: get_str("entrypoint").unwrap_or_else(|| "render".to_string()),
|
||||
enabled: get_str("enabled").map(|s| s == "true").unwrap_or(true),
|
||||
version: get_str("version")
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(1),
|
||||
created_at: get_str("createdAt")
|
||||
.and_then(|s| iso_to_unix_ms(&s).ok())
|
||||
.ok_or("missing 'createdAt'")?,
|
||||
updated_at: get_str("updatedAt")
|
||||
.and_then(|s| iso_to_unix_ms(&s).ok())
|
||||
.ok_or("missing 'updatedAt'")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a script file. Supports both `---` (Lua) and `"""` (Python) delimiters.
|
||||
pub fn read_script_file(content: &str) -> Result<(ScriptFrontmatter, String), String> {
|
||||
// Try docstring format first (Python scripts)
|
||||
if let Some((yaml, body)) = split_docstring_frontmatter(content) {
|
||||
let fm = ScriptFrontmatter::from_yaml(yaml)?;
|
||||
return Ok((fm, body.to_string()));
|
||||
}
|
||||
// Fall back to standard --- format (Lua scripts)
|
||||
let (yaml, body) = split_frontmatter(content)
|
||||
.ok_or("no frontmatter delimiters found")?;
|
||||
let fm = ScriptFrontmatter::from_yaml(yaml)?;
|
||||
Ok((fm, body.to_string()))
|
||||
}
|
||||
|
||||
/// Write a script file (always Lua format with --- delimiters).
|
||||
pub fn write_script_file(fm: &ScriptFrontmatter, body: &str) -> String {
|
||||
format_frontmatter(&fm.to_yaml(), body)
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/// Quote a YAML string value if it contains special characters.
|
||||
/// Simple values (alphanumeric, hyphens, dots) are left unquoted.
|
||||
/// Values with colons, quotes, special chars are single-quoted.
|
||||
fn yaml_string_value(s: &str) -> String {
|
||||
if s.is_empty() {
|
||||
return "''".to_string();
|
||||
}
|
||||
// Check if the value needs quoting
|
||||
let needs_quoting = s.contains(':')
|
||||
|| s.contains('#')
|
||||
|| s.contains('\'')
|
||||
|| s.contains('"')
|
||||
|| s.contains('\n')
|
||||
|| s.contains('{')
|
||||
|| s.contains('}')
|
||||
|| s.contains('[')
|
||||
|| s.contains(']')
|
||||
|| s.contains(',')
|
||||
|| s.contains('&')
|
||||
|| s.contains('*')
|
||||
|| s.contains('!')
|
||||
|| s.contains('|')
|
||||
|| s.contains('>')
|
||||
|| s.contains('%')
|
||||
|| s.contains('@')
|
||||
|| s.contains('`')
|
||||
|| s.starts_with(' ')
|
||||
|| s.ends_with(' ')
|
||||
|| s.starts_with('-')
|
||||
|| s.starts_with('?')
|
||||
|| s == "true"
|
||||
|| s == "false"
|
||||
|| s == "null"
|
||||
|| s == "yes"
|
||||
|| s == "no"
|
||||
|| s == "on"
|
||||
|| s == "off";
|
||||
|
||||
if needs_quoting {
|
||||
// Use single quotes, escaping internal single quotes by doubling them
|
||||
let escaped = s.replace('\'', "''");
|
||||
format!("'{escaped}'")
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../fixtures/compatibility-projects/rfc1437-sample")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_basic() {
|
||||
let input = "---\nfoo: bar\n---\nbody text\n";
|
||||
let (yaml, body) = split_frontmatter(input).unwrap();
|
||||
assert_eq!(yaml, "foo: bar");
|
||||
assert_eq!(body, "body text\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_no_frontmatter() {
|
||||
assert!(split_frontmatter("no frontmatter here").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_docstring() {
|
||||
let input = "\"\"\"\n---\nfoo: bar\n---\n\"\"\"\nbody\n";
|
||||
let (yaml, body) = split_docstring_frontmatter(input).unwrap();
|
||||
assert_eq!(yaml, "foo: bar");
|
||||
assert_eq!(body, "body\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_esmeralda() {
|
||||
let path = fixture_dir().join("posts/2005/11/esmeralda.md");
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let (fm, body) = read_post_file(&content).unwrap();
|
||||
assert_eq!(fm.id, "40a83ab1-423d-4310-aac4-642d84675007");
|
||||
assert_eq!(fm.title, "Esmeralda");
|
||||
assert_eq!(fm.slug, "esmeralda");
|
||||
assert_eq!(fm.status, "published");
|
||||
assert_eq!(fm.tags, vec!["fotografie", "makro", "natur", "spinne", "tiere"]);
|
||||
assert_eq!(fm.categories, vec!["picture"]);
|
||||
assert_eq!(fm.language.as_deref(), Some("es"));
|
||||
assert_eq!(fm.published_at, Some(1131883200000));
|
||||
assert_eq!(fm.created_at, 1131883200000);
|
||||
assert!(body.contains("Esmeralda"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ghostty() {
|
||||
let path = fixture_dir().join("posts/2026/03/ghostty.md");
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let (fm, _body) = read_post_file(&content).unwrap();
|
||||
assert_eq!(fm.id, "6745981d-da41-4cfd-80ec-95ad339acf6f");
|
||||
assert_eq!(fm.title, "Ghostty");
|
||||
assert_eq!(fm.slug, "ghostty");
|
||||
assert_eq!(fm.tags, vec!["programmierung", "sysadmin", "mac-os-x"]);
|
||||
assert_eq!(fm.categories, vec!["aside"]);
|
||||
assert!(fm.language.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cmux() {
|
||||
let path = fixture_dir().join("posts/2026/03/cmux-das-terminal-fur-multitasking.md");
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let (fm, _body) = read_post_file(&content).unwrap();
|
||||
assert_eq!(fm.slug, "cmux-das-terminal-fur-multitasking");
|
||||
assert!(fm.title.contains("cmux"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_esmeralda_en_translation() {
|
||||
let path = fixture_dir().join("posts/2005/11/esmeralda.en.md");
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let (fm, body) = read_translation_file(&content).unwrap();
|
||||
assert_eq!(fm.translation_for, "40a83ab1-423d-4310-aac4-642d84675007");
|
||||
assert_eq!(fm.language, "en");
|
||||
assert_eq!(fm.title, "Esmeralda");
|
||||
assert!(body.contains("Esmeralda"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_esmeralda_de_translation() {
|
||||
let path = fixture_dir().join("posts/2005/11/esmeralda.de.md");
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let (fm, _body) = read_translation_file(&content).unwrap();
|
||||
assert_eq!(fm.language, "de");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_post_frontmatter() {
|
||||
let path = fixture_dir().join("posts/2005/11/esmeralda.md");
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let (fm, body) = read_post_file(&content).unwrap();
|
||||
|
||||
// Write back out
|
||||
let yaml = fm.to_yaml();
|
||||
let output = format_frontmatter(&yaml, &body);
|
||||
|
||||
// Parse again
|
||||
let (fm2, body2) = read_post_file(&output).unwrap();
|
||||
assert_eq!(fm.id, fm2.id);
|
||||
assert_eq!(fm.title, fm2.title);
|
||||
assert_eq!(fm.slug, fm2.slug);
|
||||
assert_eq!(fm.status, fm2.status);
|
||||
assert_eq!(fm.created_at, fm2.created_at);
|
||||
assert_eq!(fm.updated_at, fm2.updated_at);
|
||||
assert_eq!(fm.tags, fm2.tags);
|
||||
assert_eq!(fm.categories, fm2.categories);
|
||||
assert_eq!(fm.language, fm2.language);
|
||||
assert_eq!(fm.published_at, fm2.published_at);
|
||||
assert_eq!(body, body2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_output_esmeralda() {
|
||||
let path = fixture_dir().join("posts/2005/11/esmeralda.md");
|
||||
let expected = fs::read_to_string(&path).unwrap();
|
||||
let (fm, body) = read_post_file(&expected).unwrap();
|
||||
|
||||
let yaml = fm.to_yaml();
|
||||
let actual = format_frontmatter(&yaml, &body);
|
||||
assert_eq!(actual, expected, "golden output mismatch for esmeralda.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_output_ghostty() {
|
||||
let path = fixture_dir().join("posts/2026/03/ghostty.md");
|
||||
let expected = fs::read_to_string(&path).unwrap();
|
||||
let (fm, body) = read_post_file(&expected).unwrap();
|
||||
|
||||
let yaml = fm.to_yaml();
|
||||
let actual = format_frontmatter(&yaml, &body);
|
||||
assert_eq!(actual, expected, "golden output mismatch for ghostty.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_output_translation() {
|
||||
let path = fixture_dir().join("posts/2005/11/esmeralda.en.md");
|
||||
let expected = fs::read_to_string(&path).unwrap();
|
||||
let (fm, body) = read_translation_file(&expected).unwrap();
|
||||
|
||||
let yaml = fm.to_yaml();
|
||||
let actual = format_frontmatter(&yaml, &body);
|
||||
assert_eq!(actual, expected, "golden output mismatch for esmeralda.en.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_quoting() {
|
||||
assert_eq!(yaml_string_value("simple"), "simple");
|
||||
assert_eq!(yaml_string_value("has: colon"), "'has: colon'");
|
||||
assert_eq!(yaml_string_value("true"), "'true'");
|
||||
assert_eq!(yaml_string_value(""), "''");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_fields_omitted_when_empty() {
|
||||
let fm = PostFrontmatter {
|
||||
id: "test-id".into(),
|
||||
title: "Test".into(),
|
||||
slug: "test".into(),
|
||||
status: "draft".into(),
|
||||
created_at: 1131883200000,
|
||||
updated_at: 1131883200000,
|
||||
tags: vec![],
|
||||
categories: vec![],
|
||||
excerpt: None,
|
||||
author: None,
|
||||
language: None,
|
||||
template_slug: None,
|
||||
do_not_translate: false,
|
||||
published_at: None,
|
||||
};
|
||||
let yaml = fm.to_yaml();
|
||||
assert!(!yaml.contains("excerpt"));
|
||||
assert!(!yaml.contains("author"));
|
||||
assert!(!yaml.contains("language"));
|
||||
assert!(!yaml.contains("doNotTranslate"));
|
||||
assert!(!yaml.contains("templateSlug"));
|
||||
assert!(!yaml.contains("publishedAt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn do_not_translate_written_when_true() {
|
||||
let fm = PostFrontmatter {
|
||||
id: "test-id".into(),
|
||||
title: "Test".into(),
|
||||
slug: "test".into(),
|
||||
status: "draft".into(),
|
||||
created_at: 1131883200000,
|
||||
updated_at: 1131883200000,
|
||||
tags: vec![],
|
||||
categories: vec![],
|
||||
excerpt: None,
|
||||
author: None,
|
||||
language: None,
|
||||
template_slug: None,
|
||||
do_not_translate: true,
|
||||
published_at: None,
|
||||
};
|
||||
let yaml = fm.to_yaml();
|
||||
assert!(yaml.contains("doNotTranslate: true"));
|
||||
}
|
||||
|
||||
// --- Template frontmatter tests ---
|
||||
|
||||
#[test]
|
||||
fn parse_fixture_template() {
|
||||
let path = fixture_dir().join("templates/testvorlage.liquid");
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let (fm, body) = read_template_file(&content).unwrap();
|
||||
assert_eq!(fm.id, "38704737-b7e7-4dd4-b010-9208bcf80ef6");
|
||||
assert_eq!(fm.project_id.as_deref(), Some("1979237c-034d-41f6-99a0-f35eb57b3f6c"));
|
||||
assert_eq!(fm.slug, "testvorlage");
|
||||
assert_eq!(fm.title, "Testvorlage");
|
||||
assert_eq!(fm.kind, "post");
|
||||
assert!(fm.enabled);
|
||||
assert_eq!(fm.version, 3);
|
||||
assert!(body.contains("<div>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_output_template() {
|
||||
let path = fixture_dir().join("templates/testvorlage.liquid");
|
||||
let expected = fs::read_to_string(&path).unwrap();
|
||||
let (fm, body) = read_template_file(&expected).unwrap();
|
||||
let actual = write_template_file(&fm, &body);
|
||||
assert_eq!(actual, expected, "golden output mismatch for testvorlage.liquid");
|
||||
}
|
||||
|
||||
// --- Script frontmatter tests ---
|
||||
|
||||
#[test]
|
||||
fn parse_fixture_script_bgg() {
|
||||
let path = fixture_dir().join("scripts/bgg_link.py");
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let (fm, body) = read_script_file(&content).unwrap();
|
||||
assert_eq!(fm.id, "2b393cae-84b9-426f-b4cf-4902aea79d7d");
|
||||
assert_eq!(fm.slug, "bgg_link");
|
||||
assert_eq!(fm.title, "bgg link");
|
||||
assert_eq!(fm.kind, "transform");
|
||||
assert_eq!(fm.entrypoint, "normalize_blogmark");
|
||||
assert!(fm.enabled);
|
||||
assert_eq!(fm.version, 12);
|
||||
assert!(body.contains("def normalize_blogmark"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fixture_script_test() {
|
||||
let path = fixture_dir().join("scripts/test_script.py");
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let (fm, body) = read_script_file(&content).unwrap();
|
||||
assert_eq!(fm.slug, "test_script");
|
||||
assert_eq!(fm.kind, "utility");
|
||||
assert_eq!(fm.entrypoint, "main");
|
||||
assert!(body.contains("print"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_frontmatter_roundtrip() {
|
||||
let fm = TemplateFrontmatter {
|
||||
id: "test-uuid".into(),
|
||||
project_id: Some("proj-uuid".into()),
|
||||
slug: "my-template".into(),
|
||||
title: "My Template".into(),
|
||||
kind: "post".into(),
|
||||
enabled: true,
|
||||
version: 1,
|
||||
created_at: 1131883200000,
|
||||
updated_at: 1131883200000,
|
||||
};
|
||||
let yaml = fm.to_yaml();
|
||||
let parsed = TemplateFrontmatter::from_yaml(&yaml).unwrap();
|
||||
assert_eq!(parsed.id, fm.id);
|
||||
assert_eq!(parsed.slug, fm.slug);
|
||||
assert_eq!(parsed.kind, fm.kind);
|
||||
assert_eq!(parsed.version, fm.version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_frontmatter_roundtrip() {
|
||||
let fm = ScriptFrontmatter {
|
||||
id: "test-uuid".into(),
|
||||
project_id: None,
|
||||
slug: "my-script".into(),
|
||||
title: "My Script".into(),
|
||||
kind: "utility".into(),
|
||||
entrypoint: "main".into(),
|
||||
enabled: true,
|
||||
version: 2,
|
||||
created_at: 1131883200000,
|
||||
updated_at: 1131883200000,
|
||||
};
|
||||
let yaml = fm.to_yaml();
|
||||
let parsed = ScriptFrontmatter::from_yaml(&yaml).unwrap();
|
||||
assert_eq!(parsed.id, fm.id);
|
||||
assert_eq!(parsed.entrypoint, "main");
|
||||
assert_eq!(parsed.version, 2);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
mod slug;
|
||||
mod checksum;
|
||||
pub mod timestamp;
|
||||
pub mod atomic_write;
|
||||
pub mod paths;
|
||||
pub mod frontmatter;
|
||||
pub mod sidecar;
|
||||
pub mod thumbnail;
|
||||
|
||||
pub use slug::{slugify, ensure_unique};
|
||||
pub use checksum::content_hash;
|
||||
pub use timestamp::{unix_ms_to_iso, iso_to_unix_ms, year_month_from_unix_ms, year_month_day_from_unix_ms, now_unix_ms};
|
||||
pub use atomic_write::{atomic_write, atomic_write_str};
|
||||
pub use paths::*;
|
||||
|
||||
107
crates/bds-core/src/util/paths.rs
Normal file
107
crates/bds-core/src/util/paths.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use super::timestamp::year_month_from_unix_ms;
|
||||
|
||||
/// Post file path: `posts/YYYY/MM/{slug}.md` from `created_at` unix ms.
|
||||
pub fn post_file_path(created_at_ms: i64, slug: &str) -> String {
|
||||
let (y, m) = year_month_from_unix_ms(created_at_ms);
|
||||
format!("posts/{y}/{m}/{slug}.md")
|
||||
}
|
||||
|
||||
/// Translation file path: `posts/YYYY/MM/{slug}.{lang}.md` from canonical
|
||||
/// post's `created_at`.
|
||||
pub fn translation_file_path(canonical_created_at_ms: i64, canonical_slug: &str, language: &str) -> String {
|
||||
let (y, m) = year_month_from_unix_ms(canonical_created_at_ms);
|
||||
format!("posts/{y}/{m}/{canonical_slug}.{language}.md")
|
||||
}
|
||||
|
||||
/// Media directory path: `media/YYYY/MM/` from `created_at`.
|
||||
pub fn media_dir_path(created_at_ms: i64) -> String {
|
||||
let (y, m) = year_month_from_unix_ms(created_at_ms);
|
||||
format!("media/{y}/{m}/")
|
||||
}
|
||||
|
||||
/// Media sidecar path: `{binary_file_path}.meta`.
|
||||
pub fn media_sidecar_path(file_path: &str) -> String {
|
||||
format!("{file_path}.meta")
|
||||
}
|
||||
|
||||
/// Media translation sidecar path: `{binary_file_path}.{lang}.meta`.
|
||||
pub fn media_translation_sidecar_path(file_path: &str, language: &str) -> String {
|
||||
format!("{file_path}.{language}.meta")
|
||||
}
|
||||
|
||||
/// Thumbnail path: `thumbnails/{id[0..2]}/{id}-{size}.{ext}`.
|
||||
pub fn thumbnail_path(id: &str, size: &str, ext: &str) -> String {
|
||||
let prefix = &id[..2.min(id.len())];
|
||||
format!("thumbnails/{prefix}/{id}-{size}.{ext}")
|
||||
}
|
||||
|
||||
/// Template file path: `templates/{slug}.liquid`.
|
||||
pub fn template_file_path(slug: &str) -> String {
|
||||
format!("templates/{slug}.liquid")
|
||||
}
|
||||
|
||||
/// Script file path: `scripts/{slug}.lua`.
|
||||
pub fn script_file_path(slug: &str) -> String {
|
||||
format!("scripts/{slug}.lua")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn post_path_from_fixture() {
|
||||
// esmeralda created_at: 2005-11-13T12:00:00.000Z = 1131883200000
|
||||
assert_eq!(
|
||||
post_file_path(1131883200000, "esmeralda"),
|
||||
"posts/2005/11/esmeralda.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_path_march_2026() {
|
||||
// ghostty created_at: 2026-03-13
|
||||
let ms = 1773583200000_i64; // approx 2026-03-13
|
||||
let path = post_file_path(ms, "ghostty");
|
||||
assert!(path.starts_with("posts/2026/03/"));
|
||||
assert!(path.ends_with("ghostty.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translation_path() {
|
||||
assert_eq!(
|
||||
translation_file_path(1131883200000, "esmeralda", "en"),
|
||||
"posts/2005/11/esmeralda.en.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_paths() {
|
||||
assert_eq!(
|
||||
media_sidecar_path("media/2005/11/eb0cf9d7.jpg"),
|
||||
"media/2005/11/eb0cf9d7.jpg.meta"
|
||||
);
|
||||
assert_eq!(
|
||||
media_translation_sidecar_path("media/2005/11/eb0cf9d7.jpg", "fr"),
|
||||
"media/2005/11/eb0cf9d7.jpg.fr.meta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_paths() {
|
||||
assert_eq!(
|
||||
thumbnail_path("eb0cf9d7-6fbd-4b74-9be3-759d6e16f240", "small", "webp"),
|
||||
"thumbnails/eb/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240-small.webp"
|
||||
);
|
||||
assert_eq!(
|
||||
thumbnail_path("eb0cf9d7-6fbd-4b74-9be3-759d6e16f240", "ai", "jpg"),
|
||||
"thumbnails/eb/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240-ai.jpg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_and_script_paths() {
|
||||
assert_eq!(template_file_path("testvorlage"), "templates/testvorlage.liquid");
|
||||
assert_eq!(script_file_path("bgg_link"), "scripts/bgg_link.lua");
|
||||
}
|
||||
}
|
||||
393
crates/bds-core/src/util/sidecar.rs
Normal file
393
crates/bds-core/src/util/sidecar.rs
Normal file
@@ -0,0 +1,393 @@
|
||||
use crate::model::Media;
|
||||
use crate::util::timestamp::{unix_ms_to_iso, iso_to_unix_ms};
|
||||
|
||||
/// Parsed media sidecar fields.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MediaSidecar {
|
||||
pub id: String,
|
||||
pub original_name: String,
|
||||
pub mime_type: String,
|
||||
pub size: i64,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
pub title: Option<String>,
|
||||
pub alt: Option<String>,
|
||||
pub caption: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub language: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub tags: Vec<String>,
|
||||
pub linked_post_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parsed media translation sidecar fields.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MediaTranslationSidecar {
|
||||
pub translation_for: String,
|
||||
pub language: String,
|
||||
pub title: Option<String>,
|
||||
pub alt: Option<String>,
|
||||
pub caption: Option<String>,
|
||||
}
|
||||
|
||||
impl MediaSidecar {
|
||||
pub fn from_media(media: &Media, linked_post_ids: &[String]) -> Self {
|
||||
Self {
|
||||
id: media.id.clone(),
|
||||
original_name: media.original_name.clone(),
|
||||
mime_type: media.mime_type.clone(),
|
||||
size: media.size,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
title: media.title.clone(),
|
||||
alt: media.alt.clone(),
|
||||
caption: media.caption.clone(),
|
||||
author: media.author.clone(),
|
||||
language: media.language.clone(),
|
||||
created_at: media.created_at,
|
||||
updated_at: media.updated_at,
|
||||
tags: media.tags.clone(),
|
||||
linked_post_ids: linked_post_ids.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to the hand-built YAML-like format matching TypeScript output.
|
||||
pub fn to_string(&self) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("---".into());
|
||||
lines.push(format!("id: {}", self.id));
|
||||
lines.push(format!("originalName: \"{}\"", escape_double_quotes(&self.original_name)));
|
||||
lines.push(format!("mimeType: {}", self.mime_type));
|
||||
lines.push(format!("size: {}", self.size));
|
||||
if let Some(w) = self.width {
|
||||
lines.push(format!("width: {w}"));
|
||||
}
|
||||
if let Some(h) = self.height {
|
||||
lines.push(format!("height: {h}"));
|
||||
}
|
||||
if let Some(ref title) = self.title {
|
||||
if !title.is_empty() {
|
||||
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
|
||||
}
|
||||
}
|
||||
if let Some(ref alt) = self.alt {
|
||||
if !alt.is_empty() {
|
||||
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
|
||||
}
|
||||
}
|
||||
if let Some(ref caption) = self.caption {
|
||||
if !caption.is_empty() {
|
||||
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
|
||||
}
|
||||
}
|
||||
if let Some(ref author) = self.author {
|
||||
if !author.is_empty() {
|
||||
lines.push(format!("author: \"{}\"", escape_double_quotes(author)));
|
||||
}
|
||||
}
|
||||
if let Some(ref language) = self.language {
|
||||
if !language.is_empty() {
|
||||
lines.push(format!("language: {language}"));
|
||||
}
|
||||
}
|
||||
lines.push(format!("createdAt: {}", unix_ms_to_iso(self.created_at)));
|
||||
lines.push(format!("updatedAt: {}", unix_ms_to_iso(self.updated_at)));
|
||||
|
||||
// Tags: inline JSON array
|
||||
if self.tags.is_empty() {
|
||||
lines.push("tags: []".into());
|
||||
} else {
|
||||
let quoted: Vec<String> = self.tags.iter().map(|t| format!("\"{}\"", escape_double_quotes(t))).collect();
|
||||
lines.push(format!("tags: [{}]", quoted.join(", ")));
|
||||
}
|
||||
|
||||
// linkedPostIds: only if non-empty
|
||||
if !self.linked_post_ids.is_empty() {
|
||||
let quoted: Vec<String> = self.linked_post_ids.iter().map(|id| format!("\"{id}\"")).collect();
|
||||
lines.push(format!("linkedPostIds: [{}]", quoted.join(", ")));
|
||||
}
|
||||
|
||||
lines.push("---".into());
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaTranslationSidecar {
|
||||
/// Serialize to the hand-built format.
|
||||
pub fn to_string(&self) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("---".into());
|
||||
lines.push(format!("translationFor: {}", self.translation_for));
|
||||
lines.push(format!("language: {}", self.language));
|
||||
if let Some(ref title) = self.title {
|
||||
if !title.is_empty() {
|
||||
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
|
||||
}
|
||||
}
|
||||
if let Some(ref alt) = self.alt {
|
||||
if !alt.is_empty() {
|
||||
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
|
||||
}
|
||||
}
|
||||
if let Some(ref caption) = self.caption {
|
||||
if !caption.is_empty() {
|
||||
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
|
||||
}
|
||||
}
|
||||
lines.push("---".into());
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a canonical media sidecar.
|
||||
pub fn read_sidecar(content: &str) -> Result<MediaSidecar, String> {
|
||||
// Strip --- delimiters
|
||||
let inner = content.trim();
|
||||
let inner = inner.strip_prefix("---").ok_or("missing opening ---")?;
|
||||
let inner = inner.trim_start_matches('\n');
|
||||
let inner = inner.strip_suffix("---").ok_or("missing closing ---")?;
|
||||
let inner = inner.trim_end_matches('\n');
|
||||
|
||||
let mut id = None;
|
||||
let mut original_name = None;
|
||||
let mut mime_type = None;
|
||||
let mut size = None;
|
||||
let mut width = None;
|
||||
let mut height = None;
|
||||
let mut title = None;
|
||||
let mut alt = None;
|
||||
let mut caption = None;
|
||||
let mut author = None;
|
||||
let mut language = None;
|
||||
let mut created_at = None;
|
||||
let mut updated_at = None;
|
||||
let mut tags = Vec::new();
|
||||
let mut linked_post_ids = Vec::new();
|
||||
|
||||
for line in inner.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once(": ") {
|
||||
let value = value.trim();
|
||||
match key.trim() {
|
||||
"id" => id = Some(value.to_string()),
|
||||
"originalName" => original_name = Some(unquote_double(value)),
|
||||
"mimeType" => mime_type = Some(value.to_string()),
|
||||
"size" => size = value.parse::<i64>().ok(),
|
||||
"width" => width = value.parse::<i32>().ok(),
|
||||
"height" => height = value.parse::<i32>().ok(),
|
||||
"title" => title = Some(unquote_double(value)),
|
||||
"alt" => alt = Some(unquote_double(value)),
|
||||
"caption" => caption = Some(unquote_double(value)),
|
||||
"author" => author = Some(unquote_double(value)),
|
||||
"language" => language = Some(value.to_string()),
|
||||
"createdAt" => created_at = iso_to_unix_ms(value).ok(),
|
||||
"updatedAt" => updated_at = iso_to_unix_ms(value).ok(),
|
||||
"tags" => tags = parse_inline_json_array(value),
|
||||
"linkedPostIds" => linked_post_ids = parse_inline_json_array(value),
|
||||
_ => {} // ignore unknown fields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MediaSidecar {
|
||||
id: id.ok_or("missing 'id'")?,
|
||||
original_name: original_name.ok_or("missing 'originalName'")?,
|
||||
mime_type: mime_type.ok_or("missing 'mimeType'")?,
|
||||
size: size.ok_or("missing 'size'")?,
|
||||
width,
|
||||
height,
|
||||
title,
|
||||
alt,
|
||||
caption,
|
||||
author,
|
||||
language,
|
||||
created_at: created_at.ok_or("missing 'createdAt'")?,
|
||||
updated_at: updated_at.ok_or("missing 'updatedAt'")?,
|
||||
tags,
|
||||
linked_post_ids,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a media translation sidecar.
|
||||
pub fn read_translation_sidecar(content: &str) -> Result<MediaTranslationSidecar, String> {
|
||||
let inner = content.trim();
|
||||
let inner = inner.strip_prefix("---").ok_or("missing opening ---")?;
|
||||
let inner = inner.trim_start_matches('\n');
|
||||
let inner = inner.strip_suffix("---").ok_or("missing closing ---")?;
|
||||
let inner = inner.trim_end_matches('\n');
|
||||
|
||||
let mut translation_for = None;
|
||||
let mut language = None;
|
||||
let mut title = None;
|
||||
let mut alt = None;
|
||||
let mut caption = None;
|
||||
|
||||
for line in inner.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once(": ") {
|
||||
let value = value.trim();
|
||||
match key.trim() {
|
||||
"translationFor" => translation_for = Some(value.to_string()),
|
||||
"language" => language = Some(value.to_string()),
|
||||
"title" => title = Some(unquote_double(value)),
|
||||
"alt" => alt = Some(unquote_double(value)),
|
||||
"caption" => caption = Some(unquote_double(value)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MediaTranslationSidecar {
|
||||
translation_for: translation_for.ok_or("missing 'translationFor'")?,
|
||||
language: language.ok_or("missing 'language'")?,
|
||||
title,
|
||||
alt,
|
||||
caption,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
fn escape_double_quotes(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
fn unquote_double(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
|
||||
let inner = &s[1..s.len() - 1];
|
||||
inner.replace("\\\"", "\"").replace("\\\\", "\\")
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse inline JSON-like array: `["a", "b"]` or `[]`.
|
||||
fn parse_inline_json_array(s: &str) -> Vec<String> {
|
||||
let s = s.trim();
|
||||
if s == "[]" {
|
||||
return Vec::new();
|
||||
}
|
||||
// Try serde_json first
|
||||
if let Ok(arr) = serde_json::from_str::<Vec<String>>(s) {
|
||||
return arr;
|
||||
}
|
||||
// Fallback: strip brackets and split by comma
|
||||
let inner = s.trim_start_matches('[').trim_end_matches(']');
|
||||
inner
|
||||
.split(',')
|
||||
.map(|item| unquote_double(item.trim()))
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../fixtures/compatibility-projects/rfc1437-sample")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fixture_sidecar() {
|
||||
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let sc = read_sidecar(&content).unwrap();
|
||||
assert_eq!(sc.id, "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240");
|
||||
assert_eq!(sc.original_name, "CRW_1121.jpg");
|
||||
assert_eq!(sc.mime_type, "image/jpeg");
|
||||
assert_eq!(sc.size, 706358);
|
||||
assert_eq!(sc.width, Some(1800));
|
||||
assert_eq!(sc.height, Some(1200));
|
||||
assert_eq!(sc.title.as_deref(), Some("Esmeralda"));
|
||||
assert!(sc.alt.as_ref().unwrap().contains("Spinnenfrau"));
|
||||
assert!(sc.caption.as_ref().unwrap().contains("Handwerkskunst"));
|
||||
assert!(sc.tags.is_empty());
|
||||
assert_eq!(sc.linked_post_ids, vec!["40a83ab1-423d-4310-aac4-642d84675007"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_output_sidecar() {
|
||||
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
|
||||
let expected = fs::read_to_string(&path).unwrap();
|
||||
let sc = read_sidecar(&expected).unwrap();
|
||||
let actual = sc.to_string();
|
||||
// Compare trimmed (fixture may or may not have trailing newline)
|
||||
assert_eq!(actual.trim(), expected.trim(), "golden output mismatch for media sidecar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_sidecar() {
|
||||
let sc = MediaSidecar {
|
||||
id: "test-uuid".into(),
|
||||
original_name: "photo.jpg".into(),
|
||||
mime_type: "image/jpeg".into(),
|
||||
size: 12345,
|
||||
width: Some(800),
|
||||
height: Some(600),
|
||||
title: Some("My Photo".into()),
|
||||
alt: Some("A nice photo".into()),
|
||||
caption: None,
|
||||
author: Some("Hugo".into()),
|
||||
language: None,
|
||||
created_at: 1131883200000,
|
||||
updated_at: 1131883200000,
|
||||
tags: vec!["nature".into(), "photo".into()],
|
||||
linked_post_ids: vec![],
|
||||
};
|
||||
let output = sc.to_string();
|
||||
let parsed = read_sidecar(&output).unwrap();
|
||||
assert_eq!(parsed.id, sc.id);
|
||||
assert_eq!(parsed.original_name, sc.original_name);
|
||||
assert_eq!(parsed.size, sc.size);
|
||||
assert_eq!(parsed.title, sc.title);
|
||||
assert_eq!(parsed.alt, sc.alt);
|
||||
assert!(parsed.caption.is_none());
|
||||
assert_eq!(parsed.tags, sc.tags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translation_sidecar_roundtrip() {
|
||||
let ts = MediaTranslationSidecar {
|
||||
translation_for: "uuid-123".into(),
|
||||
language: "fr".into(),
|
||||
title: Some("Mon titre".into()),
|
||||
alt: Some("Description".into()),
|
||||
caption: None,
|
||||
};
|
||||
let output = ts.to_string();
|
||||
let parsed = read_translation_sidecar(&output).unwrap();
|
||||
assert_eq!(parsed.translation_for, "uuid-123");
|
||||
assert_eq!(parsed.language, "fr");
|
||||
assert_eq!(parsed.title.as_deref(), Some("Mon titre"));
|
||||
assert_eq!(parsed.alt.as_deref(), Some("Description"));
|
||||
assert!(parsed.caption.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_json_array_parsing() {
|
||||
assert_eq!(parse_inline_json_array("[]"), Vec::<String>::new());
|
||||
assert_eq!(
|
||||
parse_inline_json_array("[\"a\", \"b\"]"),
|
||||
vec!["a", "b"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_and_unquote() {
|
||||
assert_eq!(unquote_double("\"hello\""), "hello");
|
||||
assert_eq!(unquote_double("plain"), "plain");
|
||||
assert_eq!(escape_double_quotes("say \"hi\""), "say \\\"hi\\\"");
|
||||
}
|
||||
}
|
||||
241
crates/bds-core/src/util/thumbnail.rs
Normal file
241
crates/bds-core/src/util/thumbnail.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, GenericImageView, ImageFormat, ImageReader};
|
||||
use std::fs;
|
||||
use std::io::Cursor;
|
||||
use std::path::Path;
|
||||
|
||||
/// Thumbnail size configuration.
|
||||
pub struct ThumbnailSize {
|
||||
pub name: &'static str,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub format: ThumbnailFormat,
|
||||
pub fit: ThumbnailFit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum ThumbnailFormat {
|
||||
Webp,
|
||||
Jpeg,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum ThumbnailFit {
|
||||
/// Scale to fit inside, no enlargement.
|
||||
Inside,
|
||||
/// Scale to fill, crop center (cover).
|
||||
Cover,
|
||||
/// Scale to fit, letterbox with black background.
|
||||
Contain,
|
||||
}
|
||||
|
||||
/// Standard thumbnail sizes matching TypeScript implementation.
|
||||
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
|
||||
ThumbnailSize { name: "small", width: 150, height: 150, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
|
||||
ThumbnailSize { name: "medium", width: 400, height: 400, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
|
||||
ThumbnailSize { name: "large", width: 800, height: 800, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
|
||||
ThumbnailSize { name: "ai", width: 448, height: 448, format: ThumbnailFormat::Jpeg, fit: ThumbnailFit::Contain },
|
||||
];
|
||||
|
||||
/// Generate a single thumbnail from a source image file.
|
||||
pub fn generate_thumbnail(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
size: &ThumbnailSize,
|
||||
quality: u8,
|
||||
) -> Result<(), String> {
|
||||
let img = load_and_orient(source)?;
|
||||
|
||||
let resized = match size.fit {
|
||||
ThumbnailFit::Inside => {
|
||||
let (orig_w, orig_h) = img.dimensions();
|
||||
// Don't enlarge
|
||||
if orig_w <= size.width && orig_h <= size.height {
|
||||
img.clone()
|
||||
} else {
|
||||
img.resize(size.width, size.height, FilterType::Lanczos3)
|
||||
}
|
||||
}
|
||||
ThumbnailFit::Cover => {
|
||||
img.resize_to_fill(size.width, size.height, FilterType::Lanczos3)
|
||||
}
|
||||
ThumbnailFit::Contain => {
|
||||
// Resize to fit, then place on black background
|
||||
let resized = img.resize(size.width, size.height, FilterType::Lanczos3);
|
||||
let (rw, rh) = resized.dimensions();
|
||||
let mut canvas = DynamicImage::new_rgb8(size.width, size.height);
|
||||
let offset_x = (size.width - rw) / 2;
|
||||
let offset_y = (size.height - rh) / 2;
|
||||
image::imageops::overlay(&mut canvas, &resized, offset_x as i64, offset_y as i64);
|
||||
canvas
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("create dir: {e}"))?;
|
||||
}
|
||||
|
||||
match size.format {
|
||||
ThumbnailFormat::Webp => {
|
||||
// Encode as WebP via image crate
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
resized
|
||||
.write_to(&mut buf, ImageFormat::WebP)
|
||||
.map_err(|e| format!("WebP encode: {e}"))?;
|
||||
let _ = quality; // image crate WebP encoder doesn't expose quality directly
|
||||
fs::write(dest, buf.into_inner()).map_err(|e| format!("write: {e}"))?;
|
||||
}
|
||||
ThumbnailFormat::Jpeg => {
|
||||
let rgb = resized.to_rgb8();
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality);
|
||||
encoder
|
||||
.encode_image(&rgb)
|
||||
.map_err(|e| format!("JPEG encode: {e}"))?;
|
||||
fs::write(dest, buf.into_inner()).map_err(|e| format!("write: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate all standard thumbnails for a media item.
|
||||
pub fn generate_all_thumbnails(
|
||||
source: &Path,
|
||||
thumbnails_dir: &Path,
|
||||
media_id: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut paths = Vec::new();
|
||||
let prefix = &media_id[..2.min(media_id.len())];
|
||||
|
||||
for size in THUMBNAIL_SIZES {
|
||||
let ext = match size.format {
|
||||
ThumbnailFormat::Webp => "webp",
|
||||
ThumbnailFormat::Jpeg => "jpg",
|
||||
};
|
||||
let dest = thumbnails_dir
|
||||
.join(prefix)
|
||||
.join(format!("{media_id}-{}.{ext}", size.name));
|
||||
let quality = if size.format == ThumbnailFormat::Jpeg { 85 } else { 80 };
|
||||
generate_thumbnail(source, &dest, size, quality)?;
|
||||
paths.push(dest.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
/// Load an image and apply EXIF orientation.
|
||||
fn load_and_orient(path: &Path) -> Result<DynamicImage, String> {
|
||||
let reader = ImageReader::open(path)
|
||||
.map_err(|e| format!("open image: {e}"))?
|
||||
.with_guessed_format()
|
||||
.map_err(|e| format!("guess format: {e}"))?;
|
||||
|
||||
let img = reader.decode().map_err(|e| format!("decode image: {e}"))?;
|
||||
|
||||
// EXIF orientation is handled by the image crate for JPEG automatically
|
||||
// when reading. For other formats, no EXIF orientation exists.
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Extract image dimensions from a file.
|
||||
pub fn image_dimensions(path: &Path) -> Result<(u32, u32), String> {
|
||||
let img = ImageReader::open(path)
|
||||
.map_err(|e| format!("open: {e}"))?
|
||||
.with_guessed_format()
|
||||
.map_err(|e| format!("format: {e}"))?
|
||||
.decode()
|
||||
.map_err(|e| format!("decode: {e}"))?;
|
||||
Ok(img.dimensions())
|
||||
}
|
||||
|
||||
/// Detect MIME type from file extension.
|
||||
pub fn mime_from_extension(ext: &str) -> &'static str {
|
||||
match ext.to_lowercase().as_str() {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"tiff" | "tif" => "image/tiff",
|
||||
"bmp" => "image/bmp",
|
||||
"heic" => "image/heic",
|
||||
"heif" => "image/heif",
|
||||
"svg" => "image/svg+xml",
|
||||
"pdf" => "application/pdf",
|
||||
"mp4" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"avi" => "video/x-msvideo",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_png(dir: &Path) -> std::path::PathBuf {
|
||||
let path = dir.join("test.png");
|
||||
// Create a small 100x80 red PNG
|
||||
let img = DynamicImage::new_rgb8(100, 80);
|
||||
img.save(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_small_thumbnail() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = create_test_png(dir.path());
|
||||
let dest = dir.path().join("thumb.webp");
|
||||
let size = &THUMBNAIL_SIZES[0]; // small: 150x150
|
||||
generate_thumbnail(&source, &dest, size, 80).unwrap();
|
||||
assert!(dest.exists());
|
||||
let (w, h) = image_dimensions(&dest).unwrap();
|
||||
// 100x80 is already smaller than 150x150, so no resize (Inside fit)
|
||||
assert_eq!(w, 100);
|
||||
assert_eq!(h, 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_ai_thumbnail_contain() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = create_test_png(dir.path());
|
||||
let dest = dir.path().join("thumb-ai.jpg");
|
||||
let size = &THUMBNAIL_SIZES[3]; // ai: 448x448 contain
|
||||
generate_thumbnail(&source, &dest, size, 85).unwrap();
|
||||
assert!(dest.exists());
|
||||
let (w, h) = image_dimensions(&dest).unwrap();
|
||||
assert_eq!(w, 448);
|
||||
assert_eq!(h, 448);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_all() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = create_test_png(dir.path());
|
||||
let thumb_dir = dir.path().join("thumbnails");
|
||||
let paths = generate_all_thumbnails(&source, &thumb_dir, "ab123456-test-uuid").unwrap();
|
||||
assert_eq!(paths.len(), 4);
|
||||
for p in &paths {
|
||||
assert!(Path::new(p).exists(), "thumbnail missing: {p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mime_detection() {
|
||||
assert_eq!(mime_from_extension("jpg"), "image/jpeg");
|
||||
assert_eq!(mime_from_extension("PNG"), "image/png");
|
||||
assert_eq!(mime_from_extension("webp"), "image/webp");
|
||||
assert_eq!(mime_from_extension("xyz"), "application/octet-stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_extraction() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = create_test_png(dir.path());
|
||||
let (w, h) = image_dimensions(&source).unwrap();
|
||||
assert_eq!(w, 100);
|
||||
assert_eq!(h, 80);
|
||||
}
|
||||
}
|
||||
86
crates/bds-core/src/util/timestamp.rs
Normal file
86
crates/bds-core/src/util/timestamp.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use chrono::{DateTime, Utc, TimeZone};
|
||||
|
||||
/// Convert Unix milliseconds to ISO 8601 string (e.g., `2005-11-13T12:00:00.000Z`).
|
||||
pub fn unix_ms_to_iso(ms: i64) -> String {
|
||||
let dt = Utc.timestamp_millis_opt(ms).single().unwrap_or_default();
|
||||
dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
|
||||
}
|
||||
|
||||
/// Parse an ISO 8601 string back to Unix milliseconds.
|
||||
pub fn iso_to_unix_ms(iso: &str) -> Result<i64, String> {
|
||||
let dt = iso
|
||||
.parse::<DateTime<Utc>>()
|
||||
.map_err(|e| format!("invalid ISO 8601 timestamp '{iso}': {e}"))?;
|
||||
Ok(dt.timestamp_millis())
|
||||
}
|
||||
|
||||
/// Extract zero-padded (YYYY, MM) from Unix milliseconds.
|
||||
pub fn year_month_from_unix_ms(ms: i64) -> (String, String) {
|
||||
let dt = Utc.timestamp_millis_opt(ms).single().unwrap_or_default();
|
||||
(dt.format("%Y").to_string(), dt.format("%m").to_string())
|
||||
}
|
||||
|
||||
/// Extract zero-padded (YYYY, MM, DD) from Unix milliseconds.
|
||||
pub fn year_month_day_from_unix_ms(ms: i64) -> (String, String, String) {
|
||||
let dt = Utc.timestamp_millis_opt(ms).single().unwrap_or_default();
|
||||
(
|
||||
dt.format("%Y").to_string(),
|
||||
dt.format("%m").to_string(),
|
||||
dt.format("%d").to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Current time as Unix milliseconds.
|
||||
pub fn now_unix_ms() -> i64 {
|
||||
Utc::now().timestamp_millis()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_conversion() {
|
||||
let ms: i64 = 1131883200000; // 2005-11-13T12:00:00.000Z
|
||||
let iso = unix_ms_to_iso(ms);
|
||||
assert_eq!(iso, "2005-11-13T12:00:00.000Z");
|
||||
assert_eq!(iso_to_unix_ms(&iso).unwrap(), ms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_with_real_millis() {
|
||||
let ms: i64 = 1741376097243; // some real timestamp with millis
|
||||
let iso = unix_ms_to_iso(ms);
|
||||
assert!(iso.ends_with("Z"));
|
||||
assert_eq!(iso_to_unix_ms(&iso).unwrap(), ms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn year_month_extraction() {
|
||||
let ms: i64 = 1131883200000;
|
||||
let (y, m) = year_month_from_unix_ms(ms);
|
||||
assert_eq!(y, "2005");
|
||||
assert_eq!(m, "11");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn year_month_day_extraction() {
|
||||
let ms: i64 = 1131883200000;
|
||||
let (y, m, d) = year_month_day_from_unix_ms(ms);
|
||||
assert_eq!(y, "2005");
|
||||
assert_eq!(m, "11");
|
||||
assert_eq!(d, "13");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_is_recent() {
|
||||
let ms = now_unix_ms();
|
||||
// Should be after 2024-01-01
|
||||
assert!(ms > 1704067200000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_iso_returns_error() {
|
||||
assert!(iso_to_unix_ms("not a date").is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user