fix: more deviations from spec fixed
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
"article",
|
||||||
|
"aside",
|
||||||
|
"page",
|
||||||
|
"picture"
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "P",
|
||||||
|
"maxPostsPerPage": 50,
|
||||||
|
"semanticSimilarityEnabled": false,
|
||||||
|
"blogLanguages": []
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -182,17 +182,26 @@ pub struct PostSearchFilters<'a> {
|
|||||||
pub offset: Option<usize>,
|
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(
|
pub fn search_posts_filtered(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
query: &str,
|
query: &str,
|
||||||
language: &str,
|
language: &str,
|
||||||
filters: &PostSearchFilters,
|
filters: &PostSearchFilters,
|
||||||
) -> rusqlite::Result<Vec<String>> {
|
) -> rusqlite::Result<SearchResults> {
|
||||||
// Get FTS matches first
|
// Get FTS matches first
|
||||||
let fts_ids = search_posts(conn, query, language)?;
|
let fts_ids = search_posts(conn, query, language)?;
|
||||||
if fts_ids.is_empty() {
|
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
|
// Apply filters by querying posts table
|
||||||
@@ -210,6 +219,28 @@ pub fn search_posts_filtered(
|
|||||||
param_idx += 1;
|
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 {
|
if let Some(year) = filters.year {
|
||||||
// Filter by year from created_at (unix ms)
|
// 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();
|
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
|
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 ");
|
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} "));
|
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)?;
|
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| {
|
let rows = stmt.query_map(params_refs.as_slice(), |row| {
|
||||||
row.get::<_, String>(0)
|
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.
|
/// Search media by full-text query. Returns matching media IDs.
|
||||||
@@ -524,7 +565,8 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let results = search_posts_filtered(db.conn(), "post", "en", &filters).unwrap();
|
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]
|
#[test]
|
||||||
@@ -557,7 +599,8 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let results = search_posts_filtered(db.conn(), "year", "en", &filters).unwrap();
|
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]
|
#[test]
|
||||||
@@ -583,6 +626,9 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let results = search_posts_filtered(db.conn(), "searchable", "en", &filters).unwrap();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<()> {
|
pub fn set_active_project(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||||
conn.execute("UPDATE projects SET is_active = 0 WHERE is_active = 1", [])?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
conn.execute(
|
tx.execute("UPDATE projects SET is_active = 0 WHERE is_active = 1", [])?;
|
||||||
|
tx.execute(
|
||||||
"UPDATE projects SET is_active = 1 WHERE id = ?1",
|
"UPDATE projects SET is_active = 1 WHERE id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
)?;
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ pub struct MediaRebuildReport {
|
|||||||
pub errors: Vec<String>,
|
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.
|
/// Import a media file (image, etc.) into the project.
|
||||||
pub fn import_media(
|
pub fn import_media(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
@@ -45,14 +52,21 @@ pub fn import_media(
|
|||||||
language: Option<&str>,
|
language: Option<&str>,
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
) -> EngineResult<Media> {
|
) -> EngineResult<Media> {
|
||||||
let id = Uuid::new_v4().to_string();
|
// Validate file type per spec
|
||||||
let now = now_unix_ms();
|
|
||||||
|
|
||||||
// Derive extension from original_name
|
|
||||||
let ext = Path::new(original_name)
|
let ext = Path::new(original_name)
|
||||||
.extension()
|
.extension()
|
||||||
.and_then(|e| e.to_str())
|
.and_then(|e| e.to_str())
|
||||||
.unwrap_or("bin");
|
.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}");
|
let filename = format!("{id}.{ext}");
|
||||||
|
|
||||||
// Compute target directory and copy file
|
// Compute target directory and copy file
|
||||||
@@ -65,14 +79,11 @@ pub fn import_media(
|
|||||||
}
|
}
|
||||||
fs::copy(source_path, &abs_file_path)?;
|
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)
|
let (width, height) = image_dimensions(&abs_file_path)
|
||||||
.map(|(w, h)| (Some(w as i32), Some(h as i32)))
|
.map(|(w, h)| (Some(w as i32), Some(h as i32)))
|
||||||
.unwrap_or((None, None));
|
.unwrap_or((None, None));
|
||||||
|
|
||||||
// Detect MIME type from extension
|
|
||||||
let mime_type = mime_from_extension(ext).to_string();
|
|
||||||
|
|
||||||
// Get file size
|
// Get file size
|
||||||
let file_size = fs::metadata(&abs_file_path)?.len() as i64;
|
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 now = now_unix_ms();
|
||||||
|
|
||||||
let existing = qm::get_media_by_id(conn, &sc.id);
|
let existing = qm::get_media_by_id(conn, &sc.id);
|
||||||
match existing {
|
let created = match existing {
|
||||||
Ok(mut media) => {
|
Ok(mut media) => {
|
||||||
// Update existing media
|
// Update existing media
|
||||||
media.original_name = sc.original_name;
|
media.original_name = sc.original_name;
|
||||||
@@ -561,11 +572,11 @@ fn rebuild_canonical_media(
|
|||||||
media.tags = sc.tags;
|
media.tags = sc.tags;
|
||||||
media.updated_at = now;
|
media.updated_at = now;
|
||||||
qm::update_media(conn, &media)?;
|
qm::update_media(conn, &media)?;
|
||||||
Ok(false)
|
false
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let media = Media {
|
let media = Media {
|
||||||
id: sc.id,
|
id: sc.id.clone(),
|
||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
filename,
|
filename,
|
||||||
original_name: sc.original_name,
|
original_name: sc.original_name,
|
||||||
@@ -586,9 +597,17 @@ fn rebuild_canonical_media(
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
qm::insert_media(conn, &media)?;
|
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.
|
/// Rebuild a translation from a `*.{lang}.meta` sidecar. Returns true if created, false if updated.
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ pub fn add_category(data_dir: &Path, category: &str) -> EngineResult<()> {
|
|||||||
CategorySettings {
|
CategorySettings {
|
||||||
render_in_lists: true,
|
render_in_lists: true,
|
||||||
show_title: true,
|
show_title: true,
|
||||||
title: None,
|
|
||||||
post_template_slug: None,
|
post_template_slug: None,
|
||||||
list_template_slug: None,
|
list_template_slug: None,
|
||||||
},
|
},
|
||||||
@@ -176,7 +175,6 @@ mod tests {
|
|||||||
max_posts_per_page: 25,
|
max_posts_per_page: 25,
|
||||||
blogmark_category: None,
|
blogmark_category: None,
|
||||||
pico_theme: None,
|
pico_theme: None,
|
||||||
python_runtime_mode: None,
|
|
||||||
semantic_similarity_enabled: false,
|
semantic_similarity_enabled: false,
|
||||||
blog_languages: vec!["en".into()],
|
blog_languages: vec!["en".into()],
|
||||||
};
|
};
|
||||||
@@ -209,7 +207,6 @@ mod tests {
|
|||||||
CategorySettings {
|
CategorySettings {
|
||||||
render_in_lists: true,
|
render_in_lists: true,
|
||||||
show_title: true,
|
show_title: true,
|
||||||
title: None,
|
|
||||||
post_template_slug: None,
|
post_template_slug: None,
|
||||||
list_template_slug: None,
|
list_template_slug: None,
|
||||||
},
|
},
|
||||||
@@ -299,7 +296,6 @@ mod tests {
|
|||||||
CategorySettings {
|
CategorySettings {
|
||||||
render_in_lists: true,
|
render_in_lists: true,
|
||||||
show_title: true,
|
show_title: true,
|
||||||
title: None,
|
|
||||||
post_template_slug: None,
|
post_template_slug: None,
|
||||||
list_template_slug: None,
|
list_template_slug: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ pub fn create_post(
|
|||||||
template_slug: Option<&str>,
|
template_slug: Option<&str>,
|
||||||
) -> EngineResult<Post> {
|
) -> EngineResult<Post> {
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let raw_title = if title.is_empty() { "untitled" } else { title };
|
let slug_source = if title.is_empty() { "untitled" } else { title };
|
||||||
let base_slug = slugify(raw_title);
|
let base_slug = slugify(slug_source);
|
||||||
let base_slug = if base_slug.is_empty() {
|
let base_slug = if base_slug.is_empty() {
|
||||||
"untitled".to_string()
|
"untitled".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -58,7 +58,7 @@ pub fn create_post(
|
|||||||
let post = Post {
|
let post = Post {
|
||||||
id,
|
id,
|
||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
title: raw_title.to_string(),
|
title: title.to_string(),
|
||||||
slug,
|
slug,
|
||||||
excerpt: None,
|
excerpt: None,
|
||||||
content: content.map(|s| s.to_string()),
|
content: content.map(|s| s.to_string()),
|
||||||
@@ -341,6 +341,12 @@ pub fn delete_post(
|
|||||||
params![post_id],
|
params![post_id],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// Delete post-media associations
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM post_media WHERE post_id = ?1",
|
||||||
|
params![post_id],
|
||||||
|
)?;
|
||||||
|
|
||||||
// Remove from FTS
|
// Remove from FTS
|
||||||
fts::remove_post_from_index(conn, post_id)?;
|
fts::remove_post_from_index(conn, post_id)?;
|
||||||
|
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ pub fn list_projects(conn: &Connection) -> EngineResult<Vec<Project>> {
|
|||||||
|
|
||||||
/// Delete a project row (cascading handled by queries).
|
/// Delete a project row (cascading handled by queries).
|
||||||
/// Rejects deletion of the default project and the currently active project.
|
/// Rejects deletion of the default project and the currently active project.
|
||||||
/// Optionally cleans up the project data directory.
|
/// Only cleans up the internal project data directory (not external custom paths).
|
||||||
pub fn delete_project(conn: &Connection, project_id: &str, data_dir: Option<&Path>) -> EngineResult<()> {
|
pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Option<&Path>) -> EngineResult<()> {
|
||||||
// Cannot delete the default project
|
// Cannot delete the default project
|
||||||
if project_id == DEFAULT_PROJECT_ID {
|
if project_id == DEFAULT_PROJECT_ID {
|
||||||
return Err(EngineError::Validation(
|
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)?;
|
q::delete_project(conn, project_id)?;
|
||||||
|
|
||||||
// Clean up filesystem if path provided
|
// Clean up internal filesystem only (not custom external paths per spec)
|
||||||
if let Some(dir) = data_dir {
|
if !is_custom_path {
|
||||||
if dir.exists() {
|
if let Some(dir) = internal_data_dir {
|
||||||
let _ = fs::remove_dir_all(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,
|
max_posts_per_page: 50,
|
||||||
blogmark_category: None,
|
blogmark_category: None,
|
||||||
pico_theme: None,
|
pico_theme: None,
|
||||||
python_runtime_mode: None,
|
|
||||||
semantic_similarity_enabled: false,
|
semantic_similarity_enabled: false,
|
||||||
blog_languages: Vec::new(),
|
blog_languages: Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -291,11 +298,18 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn delete_project_removes_row() {
|
fn delete_project_removes_row() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
let p_path = dir.path().join("p");
|
// Project with no custom data_path → uses internal directory
|
||||||
let p = create_project(db.conn(), "P", Some(p_path.to_str().unwrap())).unwrap();
|
let p = create_project(
|
||||||
delete_project(db.conn(), &p.id, Some(&p_path)).unwrap();
|
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!(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]
|
#[test]
|
||||||
|
|||||||
@@ -196,15 +196,19 @@ pub fn sync_tags_from_posts(
|
|||||||
) -> EngineResult<Vec<Tag>> {
|
) -> EngineResult<Vec<Tag>> {
|
||||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||||
|
|
||||||
// Collect all unique tag names from posts
|
// Collect all unique tag names from posts (preserve original casing per spec).
|
||||||
let mut tag_names = std::collections::HashSet::new();
|
// 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 post in &posts {
|
||||||
for tag_name in &post.tags {
|
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();
|
let now = now_unix_ms();
|
||||||
for name in &tag_names {
|
for name in &tag_names {
|
||||||
if tag_q::get_tag_by_project_and_name(conn, project_id, name).is_err() {
|
if tag_q::get_tag_by_project_and_name(conn, project_id, name).is_err() {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ impl TaskManager {
|
|||||||
let mut tasks = self.tasks.lock().unwrap();
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||||
if matches!(entry.status, TaskStatus::Running) {
|
if matches!(entry.status, TaskStatus::Running) {
|
||||||
|
entry.message = Some(error.clone());
|
||||||
entry.status = TaskStatus::Failed(error);
|
entry.status = TaskStatus::Failed(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ pub struct ProjectMetadata {
|
|||||||
pub blogmark_category: Option<String>,
|
pub blogmark_category: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub pico_theme: Option<String>,
|
pub pico_theme: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub python_runtime_mode: Option<String>,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub semantic_similarity_enabled: bool,
|
pub semantic_similarity_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -55,8 +53,6 @@ pub struct CategorySettings {
|
|||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub show_title: bool,
|
pub show_title: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub title: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub post_template_slug: Option<String>,
|
pub post_template_slug: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub list_template_slug: Option<String>,
|
pub list_template_slug: Option<String>,
|
||||||
@@ -86,7 +82,6 @@ mod tests {
|
|||||||
max_posts_per_page: 50,
|
max_posts_per_page: 50,
|
||||||
blogmark_category: None,
|
blogmark_category: None,
|
||||||
pico_theme: None,
|
pico_theme: None,
|
||||||
python_runtime_mode: None,
|
|
||||||
semantic_similarity_enabled: false,
|
semantic_similarity_enabled: false,
|
||||||
blog_languages: vec!["en".into(), "de".into()],
|
blog_languages: vec!["en".into(), "de".into()],
|
||||||
};
|
};
|
||||||
@@ -116,7 +111,6 @@ mod tests {
|
|||||||
let settings: CategorySettings = serde_json::from_str(json).unwrap();
|
let settings: CategorySettings = serde_json::from_str(json).unwrap();
|
||||||
assert!(settings.render_in_lists);
|
assert!(settings.render_in_lists);
|
||||||
assert!(settings.show_title);
|
assert!(settings.show_title);
|
||||||
assert!(settings.title.is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -124,7 +118,6 @@ mod tests {
|
|||||||
let settings = CategorySettings {
|
let settings = CategorySettings {
|
||||||
render_in_lists: false,
|
render_in_lists: false,
|
||||||
show_title: true,
|
show_title: true,
|
||||||
title: Some("Articles".into()),
|
|
||||||
post_template_slug: Some("article-tpl".into()),
|
post_template_slug: Some("article-tpl".into()),
|
||||||
list_template_slug: None,
|
list_template_slug: None,
|
||||||
};
|
};
|
||||||
@@ -163,7 +156,7 @@ mod tests {
|
|||||||
name: "Test".into(),
|
name: "Test".into(),
|
||||||
description: None, public_url: None, main_language: None,
|
description: None, public_url: None, main_language: None,
|
||||||
default_author: None, max_posts_per_page: 50, blogmark_category: 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![],
|
semantic_similarity_enabled: false, blog_languages: vec![],
|
||||||
};
|
};
|
||||||
assert!(meta.validate().is_ok());
|
assert!(meta.validate().is_ok());
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ pub enum ThumbnailFit {
|
|||||||
/// Standard thumbnail sizes matching spec: small/medium/large are
|
/// Standard thumbnail sizes matching spec: small/medium/large are
|
||||||
/// width-constrained aspect-preserving; AI is letterboxed on black.
|
/// width-constrained aspect-preserving; AI is letterboxed on black.
|
||||||
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
|
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
|
||||||
ThumbnailSize { name: "small", width: 150, height: 150, 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: 400, 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: 800, 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 },
|
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 dir = TempDir::new().unwrap();
|
||||||
let source = create_test_png(dir.path());
|
let source = create_test_png(dir.path());
|
||||||
let dest = dir.path().join("thumb.webp");
|
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();
|
generate_thumbnail(&source, &dest, size, 80).unwrap();
|
||||||
assert!(dest.exists());
|
assert!(dest.exists());
|
||||||
let (w, h) = image_dimensions(&dest).unwrap();
|
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!(w, 100);
|
||||||
assert_eq!(h, 80);
|
assert_eq!(h, 80);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ impl BdsApp {
|
|||||||
.and_then(|p| p.data_path.as_ref())
|
.and_then(|p| p.data_path.as_ref())
|
||||||
.map(PathBuf::from);
|
.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() {
|
let init_task = if projects.is_empty() {
|
||||||
if let Some(ref db) = db {
|
if let Some(ref db) = db {
|
||||||
let default_data = dirs::data_dir()
|
let default_data = dirs::data_dir()
|
||||||
@@ -196,13 +196,11 @@ impl BdsApp {
|
|||||||
.join("bds")
|
.join("bds")
|
||||||
.join("projects")
|
.join("projects")
|
||||||
.join("my-blog");
|
.join("my-blog");
|
||||||
match engine::project::create_project(
|
match engine::project::ensure_default_project(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
"My Blog",
|
Some(&default_data),
|
||||||
Some(default_data.to_str().unwrap_or("my-blog")),
|
|
||||||
) {
|
) {
|
||||||
Ok(project) => {
|
Ok(project) => {
|
||||||
let _ = engine::project::set_active_project(db.conn(), &project.id);
|
|
||||||
Task::done(Message::ProjectsLoaded(vec![project]))
|
Task::done(Message::ProjectsLoaded(vec![project]))
|
||||||
}
|
}
|
||||||
Err(_) => Task::none(),
|
Err(_) => Task::none(),
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ entity Post {
|
|||||||
language: String? -- ISO 639-1 code
|
language: String? -- ISO 639-1 code
|
||||||
do_not_translate: Boolean
|
do_not_translate: Boolean
|
||||||
|
|
||||||
-- Legacy columns (read-only, no longer written)
|
-- Published snapshot columns (written on publish for diff detection)
|
||||||
published_title: String?
|
published_title: String?
|
||||||
published_content: String?
|
published_content: String?
|
||||||
published_tags: String?
|
published_tags: String?
|
||||||
@@ -58,7 +58,7 @@ entity PostTranslation {
|
|||||||
title: String
|
title: String
|
||||||
excerpt: String?
|
excerpt: String?
|
||||||
content: String? -- Draft body (null when published)
|
content: String? -- Draft body (null when published)
|
||||||
status: draft | published | archived
|
status: draft | published
|
||||||
created_at: Timestamp
|
created_at: Timestamp
|
||||||
updated_at: Timestamp
|
updated_at: Timestamp
|
||||||
published_at: Timestamp?
|
published_at: Timestamp?
|
||||||
|
|||||||
Reference in New Issue
Block a user