fix: more deviations from spec fixed

This commit is contained in:
2026-04-05 08:57:02 +02:00
parent 75cb19a604
commit ac2afc7d50
17 changed files with 161 additions and 67 deletions

View File

@@ -31,6 +31,13 @@ pub struct MediaRebuildReport {
pub errors: Vec<String>,
}
/// Supported image MIME types for import (per media_processing.allium).
const SUPPORTED_IMAGE_TYPES: &[&str] = &[
"image/jpeg", "image/png", "image/gif",
"image/webp", "image/tiff", "image/bmp",
"image/heic", "image/heif",
];
/// Import a media file (image, etc.) into the project.
pub fn import_media(
conn: &Connection,
@@ -45,14 +52,21 @@ pub fn import_media(
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
// Validate file type per spec
let ext = Path::new(original_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin");
let mime_type = mime_from_extension(ext).to_string();
if !SUPPORTED_IMAGE_TYPES.contains(&mime_type.as_str()) {
return Err(EngineError::Validation(format!(
"unsupported file type: {mime_type} (file: {original_name})"
)));
}
let id = Uuid::new_v4().to_string();
let now = now_unix_ms();
let filename = format!("{id}.{ext}");
// Compute target directory and copy file
@@ -65,14 +79,11 @@ pub fn import_media(
}
fs::copy(source_path, &abs_file_path)?;
// Try to get image dimensions (silently ignore errors for non-image files)
// Get image dimensions
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;
@@ -542,7 +553,7 @@ fn rebuild_canonical_media(
let now = now_unix_ms();
let existing = qm::get_media_by_id(conn, &sc.id);
match existing {
let created = match existing {
Ok(mut media) => {
// Update existing media
media.original_name = sc.original_name;
@@ -561,11 +572,11 @@ fn rebuild_canonical_media(
media.tags = sc.tags;
media.updated_at = now;
qm::update_media(conn, &media)?;
Ok(false)
false
}
Err(_) => {
let media = Media {
id: sc.id,
id: sc.id.clone(),
project_id: project_id.to_string(),
filename,
original_name: sc.original_name,
@@ -586,9 +597,17 @@ fn rebuild_canonical_media(
updated_at: now,
};
qm::insert_media(conn, &media)?;
Ok(true)
true
}
};
// Regenerate thumbnails if the binary file exists
if abs_file.exists() {
let thumbnails_dir = data_dir.join("thumbnails");
let _ = generate_all_thumbnails(&abs_file, &thumbnails_dir, &sc.id);
}
Ok(created)
}
/// Rebuild a translation from a `*.{lang}.meta` sidecar. Returns true if created, false if updated.

View File

@@ -126,7 +126,6 @@ pub fn add_category(data_dir: &Path, category: &str) -> EngineResult<()> {
CategorySettings {
render_in_lists: true,
show_title: true,
title: None,
post_template_slug: None,
list_template_slug: None,
},
@@ -176,7 +175,6 @@ mod tests {
max_posts_per_page: 25,
blogmark_category: None,
pico_theme: None,
python_runtime_mode: None,
semantic_similarity_enabled: false,
blog_languages: vec!["en".into()],
};
@@ -209,7 +207,6 @@ mod tests {
CategorySettings {
render_in_lists: true,
show_title: true,
title: None,
post_template_slug: None,
list_template_slug: None,
},
@@ -299,7 +296,6 @@ mod tests {
CategorySettings {
render_in_lists: true,
show_title: true,
title: None,
post_template_slug: None,
list_template_slug: None,
},

View File

@@ -43,8 +43,8 @@ pub fn create_post(
template_slug: Option<&str>,
) -> EngineResult<Post> {
let id = Uuid::new_v4().to_string();
let raw_title = if title.is_empty() { "untitled" } else { title };
let base_slug = slugify(raw_title);
let slug_source = if title.is_empty() { "untitled" } else { title };
let base_slug = slugify(slug_source);
let base_slug = if base_slug.is_empty() {
"untitled".to_string()
} else {
@@ -58,7 +58,7 @@ pub fn create_post(
let post = Post {
id,
project_id: project_id.to_string(),
title: raw_title.to_string(),
title: title.to_string(),
slug,
excerpt: None,
content: content.map(|s| s.to_string()),
@@ -341,6 +341,12 @@ pub fn delete_post(
params![post_id],
)?;
// Delete post-media associations
conn.execute(
"DELETE FROM post_media WHERE post_id = ?1",
params![post_id],
)?;
// Remove from FTS
fts::remove_post_from_index(conn, post_id)?;

View File

@@ -111,8 +111,8 @@ pub fn list_projects(conn: &Connection) -> EngineResult<Vec<Project>> {
/// Delete a project row (cascading handled by queries).
/// Rejects deletion of the default project and the currently active project.
/// Optionally cleans up the project data directory.
pub fn delete_project(conn: &Connection, project_id: &str, data_dir: Option<&Path>) -> EngineResult<()> {
/// Only cleans up the internal project data directory (not external custom paths).
pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Option<&Path>) -> EngineResult<()> {
// Cannot delete the default project
if project_id == DEFAULT_PROJECT_ID {
return Err(EngineError::Validation(
@@ -129,12 +129,20 @@ pub fn delete_project(conn: &Connection, project_id: &str, data_dir: Option<&Pat
}
}
// Only delete internal data directory, never external custom data_path.
// The caller must pass the internal path only.
let project = q::get_project_by_id(conn, project_id)
.map_err(|_| EngineError::NotFound(format!("project {project_id}")))?;
let is_custom_path = project.data_path.is_some();
q::delete_project(conn, project_id)?;
// Clean up filesystem if path provided
if let Some(dir) = data_dir {
if dir.exists() {
let _ = fs::remove_dir_all(dir);
// Clean up internal filesystem only (not custom external paths per spec)
if !is_custom_path {
if let Some(dir) = internal_data_dir {
if dir.exists() {
let _ = fs::remove_dir_all(dir);
}
}
}
@@ -164,7 +172,6 @@ fn write_default_meta_files(data_dir: &Path, project_name: &str) -> EngineResult
max_posts_per_page: 50,
blogmark_category: None,
pico_theme: None,
python_runtime_mode: None,
semantic_similarity_enabled: false,
blog_languages: Vec::new(),
};
@@ -291,11 +298,18 @@ mod tests {
#[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, Some(&p_path)).unwrap();
// Project with no custom data_path → uses internal directory
let p = create_project(
db.conn(),
"P",
None,
)
.unwrap();
let internal_dir = dir.path().join("projects").join(&p.id);
let _ = std::fs::create_dir_all(&internal_dir);
delete_project(db.conn(), &p.id, Some(&internal_dir)).unwrap();
assert!(list_projects(db.conn()).unwrap().is_empty());
assert!(!p_path.exists(), "project directory should be cleaned up");
assert!(!internal_dir.exists(), "internal directory should be cleaned up");
}
#[test]

View File

@@ -196,15 +196,19 @@ pub fn sync_tags_from_posts(
) -> 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();
// Collect all unique tag names from posts (preserve original casing per spec).
// Use a case-insensitive set to avoid duplicates while keeping the first-seen casing.
let mut seen_lower = std::collections::HashSet::new();
let mut tag_names = Vec::new();
for post in &posts {
for tag_name in &post.tags {
tag_names.insert(tag_name.to_lowercase());
if seen_lower.insert(tag_name.to_lowercase()) {
tag_names.push(tag_name.clone());
}
}
}
// Create any tags that don't exist yet
// Create any tags that don't exist yet (using original casing)
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() {

View File

@@ -114,6 +114,7 @@ impl TaskManager {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
if matches!(entry.status, TaskStatus::Running) {
entry.message = Some(error.clone());
entry.status = TaskStatus::Failed(error);
}
}