feat: implementation of M1
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user