feat: implementation of M1

This commit is contained in:
2026-04-04 14:18:09 +02:00
parent fd744573dd
commit 2d07ac7866
44 changed files with 12197 additions and 30 deletions

View 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());
}
}

View 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(_)));
}
}

View 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"));
}
}

View 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"));
}
}

View 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");
}
}

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View 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()));
}
}

View 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());
}
}

View 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);
}
}

View 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);
}
}

View 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");
}
}

View 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());
}
}

View 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);
}
}