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>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user