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

@@ -0,0 +1,6 @@
[
"article",
"aside",
"page",
"picture"
]

View File

@@ -0,0 +1,6 @@
{
"name": "P",
"maxPostsPerPage": 50,
"semanticSimilarityEnabled": false,
"blogLanguages": []
}

View File

@@ -182,17 +182,26 @@ pub struct PostSearchFilters<'a> {
pub offset: Option<usize>,
}
/// Search posts with filters. Returns matching post IDs.
/// Search result envelope with pagination metadata per spec.
#[derive(Debug)]
pub struct SearchResults {
pub post_ids: Vec<String>,
pub total: usize,
pub offset: usize,
pub limit: usize,
}
/// Search posts with filters. Returns matching post IDs with pagination metadata.
pub fn search_posts_filtered(
conn: &Connection,
query: &str,
language: &str,
filters: &PostSearchFilters,
) -> rusqlite::Result<Vec<String>> {
) -> rusqlite::Result<SearchResults> {
// Get FTS matches first
let fts_ids = search_posts(conn, query, language)?;
if fts_ids.is_empty() {
return Ok(vec![]);
return Ok(SearchResults { post_ids: vec![], total: 0, offset: filters.offset.unwrap_or(0), limit: filters.limit.unwrap_or(0) });
}
// Apply filters by querying posts table
@@ -210,6 +219,28 @@ pub fn search_posts_filtered(
param_idx += 1;
}
if let Some(tags) = filters.tags {
// Filter posts whose JSON tags array contains ALL specified tags (case-insensitive)
for tag in tags {
sql.push_str(&format!(
"AND EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE LOWER(json_each.value) = LOWER(?{param_idx})) "
));
params.push(Box::new(tag.clone()));
param_idx += 1;
}
}
if let Some(categories) = filters.categories {
// Filter posts whose JSON categories array contains ALL specified categories (case-insensitive)
for cat in categories {
sql.push_str(&format!(
"AND EXISTS (SELECT 1 FROM json_each(posts.categories) WHERE LOWER(json_each.value) = LOWER(?{param_idx})) "
));
params.push(Box::new(cat.clone()));
param_idx += 1;
}
}
if let Some(year) = filters.year {
// Filter by year from created_at (unix ms)
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
@@ -234,13 +265,22 @@ pub fn search_posts_filtered(
let _ = param_idx; // suppress unused warning
// First get total count (without LIMIT/OFFSET)
let count_sql = sql.replace("SELECT id FROM posts", "SELECT COUNT(*) FROM posts");
let total: usize = {
let mut stmt = conn.prepare(&count_sql)?;
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
stmt.query_row(params_refs.as_slice(), |row| row.get::<_, usize>(0))?
};
sql.push_str("ORDER BY created_at DESC ");
if let Some(limit) = filters.limit {
let offset = filters.offset.unwrap_or(0);
let limit = filters.limit.unwrap_or(total);
if filters.limit.is_some() {
sql.push_str(&format!("LIMIT {limit} "));
if let Some(offset) = filters.offset {
sql.push_str(&format!("OFFSET {offset} "));
}
sql.push_str(&format!("OFFSET {offset} "));
}
let mut stmt = conn.prepare(&sql)?;
@@ -248,7 +288,8 @@ pub fn search_posts_filtered(
let rows = stmt.query_map(params_refs.as_slice(), |row| {
row.get::<_, String>(0)
})?;
rows.collect()
let post_ids: Vec<String> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(SearchResults { post_ids, total, offset, limit })
}
/// Search media by full-text query. Returns matching media IDs.
@@ -524,7 +565,8 @@ mod tests {
..Default::default()
};
let results = search_posts_filtered(db.conn(), "post", "en", &filters).unwrap();
assert_eq!(results, vec!["post1"]);
assert_eq!(results.post_ids, vec!["post1"]);
assert_eq!(results.total, 1);
}
#[test]
@@ -557,7 +599,8 @@ mod tests {
..Default::default()
};
let results = search_posts_filtered(db.conn(), "year", "en", &filters).unwrap();
assert_eq!(results, vec!["p2024"]);
assert_eq!(results.post_ids, vec!["p2024"]);
assert_eq!(results.total, 1);
}
#[test]
@@ -583,6 +626,9 @@ mod tests {
..Default::default()
};
let results = search_posts_filtered(db.conn(), "searchable", "en", &filters).unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results.post_ids.len(), 2);
assert_eq!(results.total, 5);
assert_eq!(results.offset, 1);
assert_eq!(results.limit, 2);
}
}

View File

@@ -46,11 +46,13 @@ pub fn get_active_project(conn: &Connection) -> rusqlite::Result<Project> {
}
pub fn set_active_project(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute("UPDATE projects SET is_active = 0 WHERE is_active = 1", [])?;
conn.execute(
let tx = conn.unchecked_transaction()?;
tx.execute("UPDATE projects SET is_active = 0 WHERE is_active = 1", [])?;
tx.execute(
"UPDATE projects SET is_active = 1 WHERE id = ?1",
params![id],
)?;
tx.commit()?;
Ok(())
}

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

View File

@@ -26,8 +26,6 @@ pub struct ProjectMetadata {
pub blogmark_category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pico_theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub python_runtime_mode: Option<String>,
#[serde(default)]
pub semantic_similarity_enabled: bool,
#[serde(default)]
@@ -55,8 +53,6 @@ pub struct CategorySettings {
#[serde(default = "default_true")]
pub show_title: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub post_template_slug: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub list_template_slug: Option<String>,
@@ -86,7 +82,6 @@ mod tests {
max_posts_per_page: 50,
blogmark_category: None,
pico_theme: None,
python_runtime_mode: None,
semantic_similarity_enabled: false,
blog_languages: vec!["en".into(), "de".into()],
};
@@ -116,7 +111,6 @@ mod tests {
let settings: CategorySettings = serde_json::from_str(json).unwrap();
assert!(settings.render_in_lists);
assert!(settings.show_title);
assert!(settings.title.is_none());
}
#[test]
@@ -124,7 +118,6 @@ mod tests {
let settings = CategorySettings {
render_in_lists: false,
show_title: true,
title: Some("Articles".into()),
post_template_slug: Some("article-tpl".into()),
list_template_slug: None,
};
@@ -163,7 +156,7 @@ mod tests {
name: "Test".into(),
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,
pico_theme: None,
semantic_similarity_enabled: false, blog_languages: vec![],
};
assert!(meta.validate().is_ok());

View File

@@ -32,9 +32,9 @@ pub enum ThumbnailFit {
/// Standard thumbnail sizes matching spec: small/medium/large are
/// width-constrained aspect-preserving; AI is letterboxed on black.
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
ThumbnailSize { name: "small", width: 150, height: 150, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "medium", width: 400, height: 400, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "large", width: 800, height: 800, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "small", width: 150, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "medium", width: 400, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "large", width: 800, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "ai", width: 448, height: 448, format: ThumbnailFormat::Jpeg, fit: ThumbnailFit::Contain },
];
@@ -276,11 +276,11 @@ mod tests {
let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path());
let dest = dir.path().join("thumb.webp");
let size = &THUMBNAIL_SIZES[0]; // small: 150 Inside
let size = &THUMBNAIL_SIZES[0]; // small: 150 width-constrained
generate_thumbnail(&source, &dest, size, 80).unwrap();
assert!(dest.exists());
let (w, h) = image_dimensions(&dest).unwrap();
// 100x80 fits inside 150x150 without enlargement
// 100x80 is smaller than 150 wide, no enlargement
assert_eq!(w, 100);
assert_eq!(h, 80);
}

View File

@@ -188,7 +188,7 @@ impl BdsApp {
.and_then(|p| p.data_path.as_ref())
.map(PathBuf::from);
// If no projects exist, create a default one
// If no projects exist, ensure the default project per spec
let init_task = if projects.is_empty() {
if let Some(ref db) = db {
let default_data = dirs::data_dir()
@@ -196,13 +196,11 @@ impl BdsApp {
.join("bds")
.join("projects")
.join("my-blog");
match engine::project::create_project(
match engine::project::ensure_default_project(
db.conn(),
"My Blog",
Some(default_data.to_str().unwrap_or("my-blog")),
Some(&default_data),
) {
Ok(project) => {
let _ = engine::project::set_active_project(db.conn(), &project.id);
Task::done(Message::ProjectsLoaded(vec![project]))
}
Err(_) => Task::none(),

View File

@@ -42,7 +42,7 @@ entity Post {
language: String? -- ISO 639-1 code
do_not_translate: Boolean
-- Legacy columns (read-only, no longer written)
-- Published snapshot columns (written on publish for diff detection)
published_title: String?
published_content: String?
published_tags: String?
@@ -58,7 +58,7 @@ entity PostTranslation {
title: String
excerpt: String?
content: String? -- Draft body (null when published)
status: draft | published | archived
status: draft | published
created_at: Timestamp
updated_at: Timestamp
published_at: Timestamp?