From 6955defbcf90596b3233579917c5ca221efe58df Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 9 Apr 2026 16:11:24 +0200 Subject: [PATCH] fix: and evren more gaps in M3 --- crates/bds-core/src/db/queries/post.rs | 45 +- crates/bds-ui/src/app.rs | 703 ++++++++++++++++++---- crates/bds-ui/src/state/sidebar_filter.rs | 18 + crates/bds-ui/src/views/media_editor.rs | 69 ++- crates/bds-ui/src/views/post_editor.rs | 29 +- crates/bds-ui/src/views/sidebar.rs | 126 +++- locales/ui/de.json | 5 + locales/ui/en.json | 5 + locales/ui/es.json | 5 + locales/ui/fr.json | 5 + locales/ui/it.json | 5 + 11 files changed, 889 insertions(+), 126 deletions(-) diff --git a/crates/bds-core/src/db/queries/post.rs b/crates/bds-core/src/db/queries/post.rs index 4fe5235..40cc365 100644 --- a/crates/bds-core/src/db/queries/post.rs +++ b/crates/bds-core/src/db/queries/post.rs @@ -209,10 +209,18 @@ pub fn count_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite:: pub struct PostFilterParams { /// FTS search query (empty = no search filter). pub search_query: String, + /// Exact status filter. + pub status: Option, + /// Exact language filter. + pub language: Option, /// Year filter from calendar archive. pub year: Option, /// Month filter (1-12) from calendar archive. pub month: Option, + /// Inclusive start timestamp filter. + pub from: Option, + /// Inclusive end timestamp filter. + pub to: Option, /// Tag filter (post must have ALL of these tags). pub tags: Vec, /// Category filter (post must have at least one of these categories). @@ -228,7 +236,11 @@ pub struct PostFilterParams { impl PostFilterParams { pub fn has_active_filters(&self) -> bool { !self.search_query.is_empty() + || self.status.is_some() + || self.language.is_some() || self.year.is_some() + || self.from.is_some() + || self.to.is_some() || !self.tags.is_empty() || !self.categories.is_empty() } @@ -270,6 +282,18 @@ pub fn list_posts_filtered( param_values.push(Box::new(pattern)); } + if let Some(status) = &filters.status { + let idx = param_values.len() + 1; + filter_conditions.push(format!("(p.status = ?{idx})")); + param_values.push(Box::new(status.clone())); + } + + if let Some(language) = &filters.language { + let idx = param_values.len() + 1; + filter_conditions.push(format!("(p.language = ?{idx} OR p.language IS NULL)")); + param_values.push(Box::new(language.clone())); + } + if let Some(year) = filters.year { // created_at is unix ms; compute year range let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1) @@ -331,10 +355,27 @@ pub fn list_posts_filtered( param_values.push(Box::new(pattern)); } - // If there are active filter conditions, apply them only to non-draft posts + if let Some(from) = filters.from { + let idx = param_values.len() + 1; + filter_conditions.push(format!("(p.created_at >= ?{idx})")); + param_values.push(Box::new(from)); + } + + if let Some(to) = filters.to { + let idx = param_values.len() + 1; + filter_conditions.push(format!("(p.created_at <= ?{idx})")); + param_values.push(Box::new(to)); + } + + // Without an explicit status filter, drafts remain visible regardless of the + // rest of the active filters. if !filter_conditions.is_empty() { let combined = filter_conditions.join(" AND "); - conditions.push(format!("(p.status = 'draft' OR ({combined}))")); + if filters.status.is_some() { + conditions.push(format!("({combined})")); + } else { + conditions.push(format!("(p.status = 'draft' OR ({combined}))")); + } } let where_clause = conditions.join(" AND "); diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 9fec1f5..557f3c3 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -97,8 +97,12 @@ pub enum Message { // Sidebar filters PostSearchChanged(String), TogglePostFilterPanel, + SetPostStatusFilter(Option), + SetPostLanguageFilter(Option), SetPostCalendarYear(Option), SetPostCalendarMonth(Option), + SetPostFromDate(String), + SetPostToDate(String), TogglePostTagFilter(String), TogglePostCategoryFilter(String), ClearPostFilters, @@ -152,6 +156,12 @@ pub enum Message { LoadMorePosts, LoadMoreMedia, + CreatePost, + CreatePage, + CreateMedia, + CreateScript, + CreateTemplate, + Noop, InitMenuBar, } @@ -347,6 +357,7 @@ fn format_bytes(size: i64) -> String { #[cfg(test)] mod tests { use super::{month_abbreviation, persist_media_editor_state_impl, persist_post_editor_state_impl, save_editor_settings_state_impl, save_script_editor_state_impl, save_template_editor_state_impl, BdsApp, Message, PersistedMediaState, PersistedPostState, PostStatus, SettingsMsg, POST_AUTO_SAVE_DELAY_MS}; + use crate::state::sidebar_filter::PostFilter; use crate::views::media_editor::{MediaEditorMsg, MediaEditorState}; use crate::views::post_editor::PostEditorState; use crate::views::script_editor::ScriptEditorState; @@ -914,6 +925,156 @@ mod tests { "picture".to_string(), ]); } + + #[test] + fn create_script_opens_editor_and_refreshes_sidebar() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + + let _ = app.update(Message::CreateScript); + + assert_eq!(app.sidebar_scripts.len(), 1); + assert_eq!(app.tabs.len(), 1); + assert_eq!(app.tabs[0].tab_type, crate::state::tabs::TabType::Scripts); + assert_eq!(app.active_tab.as_deref(), Some(app.sidebar_scripts[0].id.as_str())); + let editor = app + .script_editors + .get(&app.sidebar_scripts[0].id) + .expect("script editor should be hydrated"); + assert_eq!(editor.entrypoint, "render"); + assert!(editor.content.contains("new script")); + } + + #[test] + fn create_template_opens_editor_and_refreshes_sidebar() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + + let _ = app.update(Message::CreateTemplate); + + assert_eq!(app.sidebar_templates.len(), 1); + assert_eq!(app.tabs.len(), 1); + assert_eq!(app.tabs[0].tab_type, crate::state::tabs::TabType::Templates); + assert_eq!(app.active_tab.as_deref(), Some(app.sidebar_templates[0].id.as_str())); + let editor = app + .template_editors + .get(&app.sidebar_templates[0].id) + .expect("template editor should be hydrated"); + assert!(editor.content.is_empty()); + } + + #[test] + fn query_sidebar_posts_filters_status_language_and_date_range() { + let (_db, project, tmp) = setup(); + let db_path = tmp.path().join("sidebar.db"); + let mut db = Database::open(&db_path).unwrap(); + db.migrate().unwrap(); + ensure_fts_tables(db.conn()).unwrap(); + insert_project(db.conn(), &project).unwrap(); + + let draft = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Draft German", + Some("Body"), + vec![], + vec!["article".to_string()], + None, + Some("de"), + None, + ) + .unwrap(); + let published = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Published English", + Some("Body"), + vec![], + vec!["article".to_string()], + None, + Some("en"), + None, + ) + .unwrap(); + + let mut published_saved = bds_core::db::queries::post::get_post_by_id(db.conn(), &published.id).unwrap(); + published_saved.status = PostStatus::Published; + published_saved.created_at = chrono::Utc + .with_ymd_and_hms(2026, 4, 12, 8, 0, 0) + .single() + .unwrap() + .timestamp_millis(); + bds_core::db::queries::post::update_post(db.conn(), &published_saved).unwrap(); + + let mut draft_saved = bds_core::db::queries::post::get_post_by_id(db.conn(), &draft.id).unwrap(); + draft_saved.created_at = chrono::Utc + .with_ymd_and_hms(2025, 1, 5, 8, 0, 0) + .single() + .unwrap() + .timestamp_millis(); + bds_core::db::queries::post::update_post(db.conn(), &draft_saved).unwrap(); + + let filter = PostFilter { + status_filter: Some("published".to_string()), + language_filter: Some("en".to_string()), + from_date: "2026-04-01".to_string(), + to_date: "2026-04-30".to_string(), + ..PostFilter::default() + }; + + let posts = BdsApp::query_sidebar_posts_blocking( + db_path.as_path(), + &project.id, + "en", + &filter, + false, + 50, + 0, + ); + + assert_eq!(posts.len(), 1); + assert_eq!(posts[0].id, published.id); + } + + #[test] + fn regenerate_project_thumbnails_recreates_missing_files() { + let (db, project, tmp) = setup(); + let source = tmp.path().join("tiny.png"); + std::fs::write(&source, tiny_png_bytes()).unwrap(); + let media = media::import_media( + db.conn(), + tmp.path(), + &project.id, + &source, + "tiny.png", + Some("Tiny"), + Some("Alt"), + None, + None, + Some("en"), + vec![], + ) + .unwrap(); + + let small_thumb = tmp + .path() + .join(bds_core::util::paths::thumbnail_path(&media.id, "small", "webp")); + std::fs::remove_file(&small_thumb).unwrap(); + assert!(!small_thumb.exists()); + + let regenerated = BdsApp::regenerate_project_thumbnails( + &db, + tmp.path(), + &project.id, + |_index, _total, _name| {}, + ) + .unwrap(); + + assert_eq!(regenerated, 1); + assert!(small_thumb.exists()); + } } // ─────────────────────────────────────────────────────────── @@ -1218,6 +1379,14 @@ impl BdsApp { self.panel_visible = !self.panel_visible; Task::none() } + Message::CreatePost => self.create_sidebar_post(false), + Message::CreatePage => self.create_sidebar_post(true), + Message::CreateMedia => crate::platform::dialog::pick_media_files( + t(self.ui_locale, "dialog.importMedia"), + t(self.ui_locale, "dialog.imageFilter"), + ), + Message::CreateScript => self.create_sidebar_script(), + Message::CreateTemplate => self.create_sidebar_template(), Message::OpenSettingsSection(section) => { self.sidebar_view = SidebarView::Settings; self.sidebar_visible = true; @@ -1656,6 +1825,22 @@ impl BdsApp { filter.filter_panel_visible = !filter.filter_panel_visible; Task::none() } + Message::SetPostStatusFilter(status) => { + let filter = match self.sidebar_view { + SidebarView::Pages => &mut self.page_filter, + _ => &mut self.post_filter, + }; + filter.status_filter = status; + self.refresh_sidebar_posts() + } + Message::SetPostLanguageFilter(language) => { + let filter = match self.sidebar_view { + SidebarView::Pages => &mut self.page_filter, + _ => &mut self.post_filter, + }; + filter.language_filter = language; + self.refresh_sidebar_posts() + } Message::SetPostCalendarYear(year) => { let filter = match self.sidebar_view { SidebarView::Pages => &mut self.page_filter, @@ -1673,6 +1858,22 @@ impl BdsApp { filter.calendar.selected_month = month; self.refresh_sidebar_posts() } + Message::SetPostFromDate(value) => { + let filter = match self.sidebar_view { + SidebarView::Pages => &mut self.page_filter, + _ => &mut self.post_filter, + }; + filter.from_date = value; + self.refresh_sidebar_posts() + } + Message::SetPostToDate(value) => { + let filter = match self.sidebar_view { + SidebarView::Pages => &mut self.page_filter, + _ => &mut self.post_filter, + }; + filter.to_date = value; + self.refresh_sidebar_posts() + } Message::TogglePostTagFilter(tag) => { let filter = match self.sidebar_view { SidebarView::Pages => &mut self.page_filter, @@ -2451,43 +2652,7 @@ impl BdsApp { fn dispatch_menu_action(&mut self, action: MenuAction) -> Task { match action { // File - MenuAction::NewPost => { - if let (Some(db), Some(project), Some(data_dir)) = - (&self.db, &self.active_project, &self.data_dir) - { - let display_title = t(self.ui_locale, "post.untitled"); - match engine::post::create_post( - db.conn(), - data_dir, - &project.id, - "", - Some(""), - Vec::new(), - Vec::new(), - None, - None, - None, - ) { - Ok(post) => { - let tab = Tab { - id: post.id.clone(), - tab_type: TabType::Post, - title: display_title.to_string(), - is_transient: true, - is_dirty: false, - }; - let idx = tabs::open_tab(&mut self.tabs, tab); - if let Some(t) = self.tabs.get(idx) { - self.active_tab = Some(t.id.clone()); - } - } - Err(e) => { - self.add_output(&format!("Failed to create post: {e}")); - } - } - } - Task::none() - } + MenuAction::NewPost => self.create_sidebar_post(false), MenuAction::ImportMedia => crate::platform::dialog::pick_media_files( t(self.ui_locale, "dialog.importMedia"), t(self.ui_locale, "dialog.imageFilter"), @@ -2601,6 +2766,133 @@ impl BdsApp { self.enforce_panel_tab_fallback(); } + fn create_sidebar_post(&mut self, is_page: bool) -> Task { + let (Some(db), Some(project), Some(data_dir)) = + (&self.db, &self.active_project, &self.data_dir) + else { + return Task::none(); + }; + + let categories = if is_page { + vec!["page".to_string()] + } else { + Vec::new() + }; + + match engine::post::create_post( + db.conn(), + data_dir, + &project.id, + "", + Some(""), + Vec::new(), + categories, + None, + None, + None, + ) { + Ok(post) => { + let tab = Tab { + id: post.id.clone(), + tab_type: TabType::Post, + title: t(self.ui_locale, "post.untitled"), + is_transient: true, + is_dirty: false, + }; + let idx = tabs::open_tab(&mut self.tabs, tab); + if let Some(tab) = self.tabs.get(idx).cloned() { + self.active_tab = Some(tab.id.clone()); + self.load_editor_for_tab(&tab); + } + self.sync_menu_state(); + self.refresh_counts() + } + Err(error) => { + self.notify(ToastLevel::Error, &format!("Failed to create post: {error}")); + Task::none() + } + } + } + + fn create_sidebar_script(&mut self) -> Task { + let (Some(db), Some(project)) = (&self.db, &self.active_project) else { + return Task::none(); + }; + + match engine::script::create_script( + db.conn(), + &project.id, + &t(self.ui_locale, "editor.untitled"), + bds_core::model::ScriptKind::Utility, + "print(\"new script\")", + Some("render"), + ) { + Ok(script) => { + let tab = Tab { + id: script.id.clone(), + tab_type: TabType::Scripts, + title: if script.title.is_empty() { + t(self.ui_locale, "editor.untitled") + } else { + script.title.clone() + }, + is_transient: true, + is_dirty: false, + }; + let idx = tabs::open_tab(&mut self.tabs, tab); + if let Some(tab) = self.tabs.get(idx).cloned() { + self.active_tab = Some(tab.id.clone()); + self.load_editor_for_tab(&tab); + } + self.sync_menu_state(); + self.refresh_counts() + } + Err(error) => { + self.notify(ToastLevel::Error, &format!("Failed to create script: {error}")); + Task::none() + } + } + } + + fn create_sidebar_template(&mut self) -> Task { + let (Some(db), Some(project)) = (&self.db, &self.active_project) else { + return Task::none(); + }; + + match engine::template::create_template( + db.conn(), + &project.id, + &t(self.ui_locale, "editor.untitled"), + bds_core::model::TemplateKind::Post, + "", + ) { + Ok(template) => { + let tab = Tab { + id: template.id.clone(), + tab_type: TabType::Templates, + title: if template.title.is_empty() { + t(self.ui_locale, "editor.untitled") + } else { + template.title.clone() + }, + is_transient: true, + is_dirty: false, + }; + let idx = tabs::open_tab(&mut self.tabs, tab); + if let Some(tab) = self.tabs.get(idx).cloned() { + self.active_tab = Some(tab.id.clone()); + self.load_editor_for_tab(&tab); + } + self.sync_menu_state(); + self.refresh_counts() + } + Err(error) => { + self.notify(ToastLevel::Error, &format!("Failed to create template: {error}")); + Task::none() + } + } + } + fn refresh_task_snapshots(&mut self) { self.task_snapshots = self .task_manager @@ -2901,36 +3193,27 @@ impl BdsApp { return Task::none(); }; - use bds_core::db::queries::post::PostFilterParams; - let db_path = self.db_path.clone(); let project_id = project.id.clone(); + let content_language = self.content_language.clone(); let filter = match self.sidebar_view { - SidebarView::Pages => &self.page_filter, - _ => &self.post_filter, + SidebarView::Pages => self.page_filter.clone(), + _ => self.post_filter.clone(), }; let is_pages = self.sidebar_view == SidebarView::Pages; - let params = PostFilterParams { - search_query: filter.search_query.clone(), - year: filter.calendar.selected_year, - month: filter.calendar.selected_month, - tags: filter.tag_filter.clone(), - categories: filter.category_filter.clone(), - exclude_pages: !is_pages, - pages_only: is_pages, - }; - Task::perform( async move { tokio::task::spawn_blocking(move || { - let db = Database::open(&db_path).ok(); - db.and_then(|db| { - bds_core::db::queries::post::list_posts_filtered( - db.conn(), &project_id, ¶ms, - Self::SIDEBAR_PAGE_SIZE + 1, 0, - ).ok() - }).unwrap_or_default() + Self::query_sidebar_posts_blocking( + &db_path, + &project_id, + &content_language, + &filter, + is_pages, + Self::SIDEBAR_PAGE_SIZE + 1, + 0, + ) }).await.unwrap_or_default() }, Message::SidebarPostsLoaded, @@ -2991,43 +3274,161 @@ impl BdsApp { return Task::none(); }; - use bds_core::db::queries::post::PostFilterParams; - let db_path = self.db_path.clone(); let project_id = project.id.clone(); + let content_language = self.content_language.clone(); let offset = self.sidebar_posts.len() as i64; let filter = match self.sidebar_view { - SidebarView::Pages => &self.page_filter, - _ => &self.post_filter, + SidebarView::Pages => self.page_filter.clone(), + _ => self.post_filter.clone(), }; let is_pages = self.sidebar_view == SidebarView::Pages; - let params = PostFilterParams { - search_query: filter.search_query.clone(), - year: filter.calendar.selected_year, - month: filter.calendar.selected_month, - tags: filter.tag_filter.clone(), - categories: filter.category_filter.clone(), - exclude_pages: !is_pages, - pages_only: is_pages, - }; - Task::perform( async move { tokio::task::spawn_blocking(move || { - let db = Database::open(&db_path).ok(); - db.and_then(|db| { - bds_core::db::queries::post::list_posts_filtered( - db.conn(), &project_id, ¶ms, - Self::SIDEBAR_PAGE_SIZE + 1, offset, - ).ok() - }).unwrap_or_default() + Self::query_sidebar_posts_blocking( + &db_path, + &project_id, + &content_language, + &filter, + is_pages, + Self::SIDEBAR_PAGE_SIZE + 1, + offset, + ) }).await.unwrap_or_default() }, Message::SidebarPostsAppended, ) } + fn parse_filter_date(input: &str, end_of_day: bool) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + let date = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d").ok()?; + let time = if end_of_day { + date.and_hms_opt(23, 59, 59)? + } else { + date.and_hms_opt(0, 0, 0)? + }; + Some(time.and_utc().timestamp_millis()) + } + + fn build_post_filter_params( + filter: &PostFilter, + is_pages: bool, + ) -> bds_core::db::queries::post::PostFilterParams { + bds_core::db::queries::post::PostFilterParams { + search_query: filter.search_query.clone(), + status: filter.status_filter.clone(), + language: filter.language_filter.clone(), + year: filter.calendar.selected_year, + month: filter.calendar.selected_month, + from: Self::parse_filter_date(&filter.from_date, false), + to: Self::parse_filter_date(&filter.to_date, true), + tags: filter.tag_filter.clone(), + categories: filter.category_filter.clone(), + exclude_pages: !is_pages, + pages_only: is_pages, + } + } + + fn query_sidebar_posts_blocking( + db_path: &Path, + project_id: &str, + content_language: &str, + filter: &PostFilter, + is_pages: bool, + limit: i64, + offset: i64, + ) -> Vec { + let Ok(db) = Database::open(db_path) else { + return Vec::new(); + }; + + let params = Self::build_post_filter_params(filter, is_pages); + if filter.search_query.trim().is_empty() { + return bds_core::db::queries::post::list_posts_filtered( + db.conn(), + project_id, + ¶ms, + limit, + offset, + ) + .unwrap_or_default(); + } + + let fts_filters = bds_core::db::fts::PostSearchFilters { + status: params.status.as_deref(), + tags: (!params.tags.is_empty()).then_some(params.tags.as_slice()), + categories: (!params.categories.is_empty()).then_some(params.categories.as_slice()), + language: params.language.as_deref(), + year: params.year, + month: params.month, + from: params.from, + to: params.to, + limit: Some(limit as usize), + offset: Some(offset as usize), + ..Default::default() + }; + + let ids = bds_core::db::fts::search_posts_filtered( + db.conn(), + ¶ms.search_query, + content_language, + &fts_filters, + ) + .map(|results| results.post_ids) + .unwrap_or_default(); + + ids.into_iter() + .filter_map(|post_id| bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok()) + .filter(|post| post.project_id == project_id) + .filter(|post| { + let is_page_post = post + .categories + .iter() + .any(|category| category.eq_ignore_ascii_case("page")); + if is_pages { + is_page_post + } else { + !is_page_post + } + }) + .collect() + } + + fn regenerate_project_thumbnails( + db: &Database, + data_dir: &Path, + project_id: &str, + mut progress: impl FnMut(usize, usize, &str), + ) -> Result { + let media = bds_core::db::queries::media::list_media_by_project(db.conn(), project_id) + .map_err(|e| e.to_string())?; + let total = media.len(); + let thumbnails_dir = data_dir.join("thumbnails"); + let mut regenerated = 0usize; + + for (index, item) in media.iter().enumerate() { + progress(index, total, &item.original_name); + if !item.mime_type.starts_with("image/") { + continue; + } + let source = data_dir.join(&item.file_path); + if !source.exists() { + continue; + } + bds_core::util::thumbnail::generate_all_thumbnails(&source, &thumbnails_dir, &item.id) + .map_err(|e| e.to_string())?; + regenerated += 1; + } + + Ok(regenerated) + } + /// Load more media (append to existing sidebar data). fn load_more_sidebar_media(&mut self) -> Task { let (Some(_), Some(project)) = (&self.db, &self.active_project) else { @@ -3094,6 +3495,7 @@ impl BdsApp { ).unwrap_or_default(); self.post_filter.available_tags = all_tags.clone(); self.post_filter.available_categories = all_cats.clone(); + self.post_filter.available_languages = self.blog_languages.clone(); self.post_filter.calendar_years = Self::build_calendar_tree(&post_cal); // Calendar counts for pages only @@ -3102,6 +3504,7 @@ impl BdsApp { ).unwrap_or_default(); self.page_filter.available_tags = all_tags; self.page_filter.available_categories = all_cats; + self.page_filter.available_languages = self.blog_languages.clone(); self.page_filter.calendar_years = Self::build_calendar_tree(&page_cal); // Media filter metadata @@ -4591,34 +4994,126 @@ impl BdsApp { SettingsMsg::SemanticSimilarityChanged(value) => { state.semantic_similarity_enabled = value; } - SettingsMsg::RebuildPosts => { return Task::done(Message::RebuildDatabase); } - SettingsMsg::RebuildMedia => { return Task::done(Message::RebuildDatabase); } - SettingsMsg::RebuildScripts => { return Task::done(Message::RebuildDatabase); } - SettingsMsg::RebuildTemplates => { return Task::done(Message::RebuildDatabase); } + SettingsMsg::RebuildPosts => { + return self.spawn_engine_task( + "settings.rebuildPosts", + |db_path, project_id, data_dir, _tm, _tid| { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + let report = engine::post::rebuild_posts_from_filesystem( + db.conn(), + &data_dir, + &project_id, + ) + .map_err(|e| e.to_string())?; + Ok(format!( + "created={}, updated={}, translations={}", + report.posts_created, + report.posts_updated, + report.translations_created + report.translations_updated, + )) + }, + ); + } + SettingsMsg::RebuildMedia => { + return self.spawn_engine_task( + "settings.rebuildMedia", + |db_path, project_id, data_dir, _tm, _tid| { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + let report = engine::media::rebuild_media_from_filesystem( + db.conn(), + &data_dir, + &project_id, + ) + .map_err(|e| e.to_string())?; + Ok(format!( + "created={}, updated={}, translations={}", + report.media_created, + report.media_updated, + report.translations_created + report.translations_updated, + )) + }, + ); + } + SettingsMsg::RebuildScripts => { + return self.spawn_engine_task( + "settings.rebuildScripts", + |db_path, project_id, data_dir, _tm, _tid| { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + let report = engine::script_rebuild::rebuild_scripts_from_filesystem( + db.conn(), + &data_dir, + &project_id, + ) + .map_err(|e| e.to_string())?; + Ok(format!( + "created={}, updated={}, errors={}", + report.created, + report.updated, + report.errors.len(), + )) + }, + ); + } + SettingsMsg::RebuildTemplates => { + return self.spawn_engine_task( + "settings.rebuildTemplates", + |db_path, project_id, data_dir, _tm, _tid| { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + let report = engine::template_rebuild::rebuild_templates_from_filesystem( + db.conn(), + &data_dir, + &project_id, + ) + .map_err(|e| e.to_string())?; + Ok(format!( + "created={}, updated={}, errors={}", + report.created, + report.updated, + report.errors.len(), + )) + }, + ); + } SettingsMsg::RebuildLinks => { - if let (Some(db), Some(data_dir), Some(project)) = - (&self.db, &self.data_dir, &self.active_project) - { - match bds_core::engine::post::rebuild_all_links( - db.conn(), data_dir, &project.id, - ) { - Ok(count) => { - self.notify( - ToastLevel::Info, - &format!("Rebuilt {} links", count), - ); - } - Err(e) => { - self.notify( - ToastLevel::Error, - &format!("Failed to rebuild links: {e}"), - ); - } - } - } + return self.spawn_engine_task( + "settings.rebuildLinks", + |db_path, project_id, data_dir, _tm, _tid| { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + let rebuilt = bds_core::engine::post::rebuild_all_links( + db.conn(), + &data_dir, + &project_id, + ) + .map_err(|e| e.to_string())?; + Ok(format!("rebuilt={rebuilt}")) + }, + ); } SettingsMsg::RegenerateThumbnails => { - self.notify(ToastLevel::Info, &t(self.ui_locale, "settings.regeneratingThumbnails")); + return self.spawn_engine_task( + "settings.regenerateThumbnails", + |db_path, project_id, data_dir, tm, tid| { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + let regenerated = Self::regenerate_project_thumbnails( + &db, + &data_dir, + &project_id, + |index, total, name| { + let progress = if total > 0 { + index as f32 / total as f32 + } else { + 1.0 + }; + tm.report_progress( + tid, + Some(progress), + Some(format!("Regenerating: {name}")), + ); + }, + )?; + Ok(format!("regenerated={regenerated}")) + }, + ); } SettingsMsg::OpenDataFolder => { if let Some(ref dir) = self.data_dir { diff --git a/crates/bds-ui/src/state/sidebar_filter.rs b/crates/bds-ui/src/state/sidebar_filter.rs index 9943931..9c9caa4 100644 --- a/crates/bds-ui/src/state/sidebar_filter.rs +++ b/crates/bds-ui/src/state/sidebar_filter.rs @@ -35,8 +35,16 @@ pub struct PostFilter { pub search_query: String, /// Whether the collapsible filter panel is visible. pub filter_panel_visible: bool, + /// Exact status filter. + pub status_filter: Option, + /// Exact language filter. + pub language_filter: Option, /// Year/month archive filter. pub calendar: CalendarFilter, + /// Inclusive start date input (`YYYY-MM-DD`). + pub from_date: String, + /// Inclusive end date input (`YYYY-MM-DD`). + pub to_date: String, /// Selected tag names (multi-select). pub tag_filter: Vec, /// Selected category names (multi-select). @@ -47,6 +55,8 @@ pub struct PostFilter { pub available_tags: Vec, /// All available categories for the chip selector. pub available_categories: Vec, + /// Available languages for the chip selector. + pub available_languages: Vec, } /// Filter state for the Media sidebar. @@ -70,7 +80,11 @@ impl PostFilter { /// Returns true if any filter is active (for "Clear All" visibility). pub fn has_active_filters(&self) -> bool { !self.search_query.is_empty() + || self.status_filter.is_some() + || self.language_filter.is_some() || self.calendar.selected_year.is_some() + || !self.from_date.trim().is_empty() + || !self.to_date.trim().is_empty() || !self.tag_filter.is_empty() || !self.category_filter.is_empty() } @@ -78,7 +92,11 @@ impl PostFilter { /// Reset all filters to defaults. pub fn clear(&mut self) { self.search_query.clear(); + self.status_filter = None; + self.language_filter = None; self.calendar = CalendarFilter::default(); + self.from_date.clear(); + self.to_date.clear(); self.tag_filter.clear(); self.category_filter.clear(); } diff --git a/crates/bds-ui/src/views/media_editor.rs b/crates/bds-ui/src/views/media_editor.rs index a1bc9f3..c208078 100644 --- a/crates/bds-ui/src/views/media_editor.rs +++ b/crates/bds-ui/src/views/media_editor.rs @@ -156,9 +156,6 @@ impl MediaEditorState { /// Build translation flags for the view. pub fn translation_flags(&self) -> Vec { - if self.translation_drafts.is_empty() { - return Vec::new(); - } let mut flags = Vec::new(); let canon = &self.canonical_language; let canon_locale = i18n::normalize_language(canon); @@ -168,15 +165,29 @@ impl MediaEditorState { status: "canonical".to_string(), is_active: self.active_language == *canon, }); - let mut langs: Vec<&String> = self.translation_drafts.keys().collect(); + let mut langs: Vec = self + .blog_languages + .iter() + .filter(|lang| **lang != *canon) + .cloned() + .collect(); + for lang in self.translation_drafts.keys() { + if lang != canon && !langs.contains(lang) { + langs.push(lang.clone()); + } + } langs.sort(); for lang in langs { - let locale = i18n::normalize_language(lang); + let locale = i18n::normalize_language(&lang); flags.push(TranslationFlag { language: lang.clone(), flag_emoji: locale.flag_emoji().to_string(), - status: "translation".to_string(), - is_active: self.active_language == **lang, + status: if self.translation_drafts.contains_key(&lang) { + "translation".to_string() + } else { + "missing".to_string() + }, + is_active: self.active_language == lang, }); } flags @@ -448,6 +459,50 @@ pub fn view<'a>( .into() } +#[cfg(test)] +mod tests { + use super::MediaEditorState; + use bds_core::model::Media; + + fn make_media() -> Media { + Media { + id: "m1".to_string(), + project_id: "proj".to_string(), + filename: "image.png".to_string(), + original_name: "image.png".to_string(), + mime_type: "image/png".to_string(), + size: 10, + width: Some(1), + height: Some(1), + title: Some("Image".to_string()), + alt: Some("Alt".to_string()), + caption: None, + author: None, + language: Some("en".to_string()), + file_path: "media/test/image.png".to_string(), + sidecar_path: "media/test/image.png.meta".to_string(), + checksum: None, + tags: Vec::new(), + created_at: 0, + updated_at: 0, + } + } + + #[test] + fn translation_flags_include_configured_languages_without_translations() { + let state = MediaEditorState::from_media( + &make_media(), + &["en".to_string(), "de".to_string()], + &[], + Vec::new(), + ); + + let flags = state.translation_flags(); + let languages = flags.into_iter().map(|flag| flag.language).collect::>(); + assert_eq!(languages, vec!["en".to_string(), "de".to_string()]); + } +} + fn no_preview<'a>() -> Element<'a, Message> { container( text("No preview available") diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 578c048..137855d 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -276,14 +276,9 @@ impl PostEditorState { } /// Build the translation flags list for the view. - /// Flags are driven by the post's actual translations, not blog-level languages. pub fn translation_flags(&self) -> Vec { - if self.translation_drafts.is_empty() { - return Vec::new(); - } let mut flags = Vec::new(); - // Canonical language first (always shown when translations exist) let canon = &self.canonical_language; let canon_locale = i18n::normalize_language(canon); flags.push(TranslationFlag { @@ -293,20 +288,30 @@ impl PostEditorState { is_active: self.active_language == *canon, }); - // Each existing translation for this post - let mut langs: Vec<&String> = self.translation_drafts.keys().collect(); + let mut langs: Vec = self + .blog_languages + .iter() + .filter(|lang| **lang != *canon) + .cloned() + .collect(); + for lang in self.translation_drafts.keys() { + if lang != canon && !langs.contains(lang) { + langs.push(lang.clone()); + } + } langs.sort(); for lang in langs { - let locale = i18n::normalize_language(lang); - let status = match self.translation_drafts[lang].status { - PostStatus::Published => "published", - _ => "draft", + let locale = i18n::normalize_language(&lang); + let status = match self.translation_drafts.get(&lang).map(|draft| &draft.status) { + Some(PostStatus::Published) => "published", + Some(_) => "draft", + None => "missing", }; flags.push(TranslationFlag { language: lang.clone(), flag_emoji: locale.flag_emoji().to_string(), status: status.to_string(), - is_active: self.active_language == **lang, + is_active: self.active_language == lang, }); } diff --git a/crates/bds-ui/src/views/sidebar.rs b/crates/bds-ui/src/views/sidebar.rs index 25d65f6..a9ffe45 100644 --- a/crates/bds-ui/src/views/sidebar.rs +++ b/crates/bds-ui/src/views/sidebar.rs @@ -386,6 +386,50 @@ fn chip_selector( iced::widget::Column::with_children(items).spacing(2).into() } +fn single_select_chip_selector( + label: &str, + available: &[(String, String)], + selected: Option<&str>, + on_toggle: impl Fn(Option) -> Message + 'static + Clone, + locale: UiLocale, +) -> Element<'static, Message> { + let muted = Color::from_rgb(0.50, 0.50, 0.55); + let mut items: Vec> = Vec::new(); + + items.push( + text(t(locale, label)) + .size(10) + .shaping(Shaping::Advanced) + .color(muted) + .into(), + ); + + let chips = available + .iter() + .map(|(value, display)| { + let is_selected = selected == Some(value.as_str()); + let style_fn = if is_selected { chip_selected_style } else { chip_style }; + let value = value.clone(); + let on_toggle = on_toggle.clone(); + button(text(display.clone()).size(10).shaping(Shaping::Advanced)) + .on_press(if is_selected { + on_toggle(None) + } else { + on_toggle(Some(value)) + }) + .padding([2, 6]) + .style(style_fn) + .into() + }) + .collect::>>(); + + if !chips.is_empty() { + items.push(row(chips).spacing(4).into()); + } + + iced::widget::Column::with_children(items).spacing(2).into() +} + /// Build the filter panel for Posts/Pages view. fn post_filter_panel( filter: &PostFilter, @@ -394,6 +438,35 @@ fn post_filter_panel( ) -> Element<'static, Message> { let mut sections: Vec> = Vec::new(); + sections.push(single_select_chip_selector( + "sidebar.filter.status", + &[ + ("draft".to_string(), t(locale, "sidebar.drafts")), + ("published".to_string(), t(locale, "sidebar.published")), + ("archived".to_string(), t(locale, "sidebar.archived")), + ], + filter.status_filter.as_deref(), + Message::SetPostStatusFilter, + locale, + )); + sections.push(Space::with_height(4.0).into()); + + if !filter.available_languages.is_empty() { + let languages = filter + .available_languages + .iter() + .map(|lang| (lang.clone(), lang.to_uppercase())) + .collect::>(); + sections.push(single_select_chip_selector( + "sidebar.filter.language", + &languages, + filter.language_filter.as_deref(), + Message::SetPostLanguageFilter, + locale, + )); + sections.push(Space::with_height(4.0).into()); + } + // Calendar archive if !filter.calendar_years.is_empty() { sections.push(calendar_widget( @@ -431,6 +504,33 @@ fn post_filter_panel( sections.push(Space::with_height(4.0).into()); } + sections.push( + iced::widget::Column::with_children(vec![ + text(t(locale, "sidebar.filter.dateRange")) + .size(10) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.50, 0.50, 0.55)) + .into(), + row![ + text_input(&t(locale, "sidebar.filter.from"), &filter.from_date) + .on_input(Message::SetPostFromDate) + .size(10) + .padding([3, 5]) + .style(search_input_style), + text_input(&t(locale, "sidebar.filter.to"), &filter.to_date) + .on_input(Message::SetPostToDate) + .size(10) + .padding([3, 5]) + .style(search_input_style), + ] + .spacing(4) + .into(), + ]) + .spacing(2) + .into(), + ); + sections.push(Space::with_height(4.0).into()); + // Clear all filters button if filter.has_active_filters() { sections.push( @@ -520,6 +620,30 @@ pub fn view( .shaping(Shaping::Advanced) .color(Color::from_rgb(0.85, 0.85, 0.90)); + let create_action = match sidebar_view { + SidebarView::Posts => Some(Message::CreatePost), + SidebarView::Pages => Some(Message::CreatePage), + SidebarView::Media => Some(Message::CreateMedia), + SidebarView::Scripts => Some(Message::CreateScript), + SidebarView::Templates => Some(Message::CreateTemplate), + _ => None, + }; + + let header_row: Element<'static, Message> = if let Some(action) = create_action { + row![ + header, + Space::with_width(Length::Fill), + button(text(t(locale, "common.add")).size(11).shaping(Shaping::Advanced)) + .on_press(action) + .padding([3, 8]) + .style(filter_button_style), + ] + .align_y(iced::Alignment::Center) + .into() + } else { + header.into() + }; + let body: Element<'static, Message> = match sidebar_view { SidebarView::Posts | SidebarView::Pages => { let is_pages = sidebar_view == SidebarView::Pages; @@ -1020,7 +1144,7 @@ pub fn view( }; let content = column![ - header, + header_row, Space::with_height(8.0), body, ] diff --git a/locales/ui/de.json b/locales/ui/de.json index 9d500ff..32c9642 100644 --- a/locales/ui/de.json +++ b/locales/ui/de.json @@ -196,6 +196,11 @@ "sidebar.filter.search": "Suchen...", "sidebar.filter.toggle": "Filter", "sidebar.filter.clearAll": "Alle Filter zurücksetzen", + "sidebar.filter.status": "Status", + "sidebar.filter.language": "Sprache", + "sidebar.filter.dateRange": "Zeitraum", + "sidebar.filter.from": "Von (JJJJ-MM-TT)", + "sidebar.filter.to": "Bis (JJJJ-MM-TT)", "editor.insertLink": "Link einfügen", "editor.insertMedia": "Medium einfügen", "editor.gallery": "Galerie", diff --git a/locales/ui/en.json b/locales/ui/en.json index e051b34..07d96c7 100644 --- a/locales/ui/en.json +++ b/locales/ui/en.json @@ -196,6 +196,11 @@ "sidebar.filter.search": "Search...", "sidebar.filter.toggle": "Filters", "sidebar.filter.clearAll": "Clear All Filters", + "sidebar.filter.status": "Status", + "sidebar.filter.language": "Language", + "sidebar.filter.dateRange": "Date Range", + "sidebar.filter.from": "From (YYYY-MM-DD)", + "sidebar.filter.to": "To (YYYY-MM-DD)", "sidebar.filter.tags": "Tags", "sidebar.filter.categories": "Categories", "sidebar.filter.calendar": "Archive", diff --git a/locales/ui/es.json b/locales/ui/es.json index bf6d315..1eee586 100644 --- a/locales/ui/es.json +++ b/locales/ui/es.json @@ -196,6 +196,11 @@ "sidebar.filter.search": "Buscar...", "sidebar.filter.toggle": "Filtros", "sidebar.filter.clearAll": "Borrar todos los filtros", + "sidebar.filter.status": "Estado", + "sidebar.filter.language": "Idioma", + "sidebar.filter.dateRange": "Rango de fechas", + "sidebar.filter.from": "Desde (AAAA-MM-DD)", + "sidebar.filter.to": "Hasta (AAAA-MM-DD)", "editor.insertLink": "Insertar enlace", "editor.insertMedia": "Insertar medio", "editor.gallery": "Galería", diff --git a/locales/ui/fr.json b/locales/ui/fr.json index 7fd1d9c..1ad3457 100644 --- a/locales/ui/fr.json +++ b/locales/ui/fr.json @@ -196,6 +196,11 @@ "sidebar.filter.search": "Rechercher...", "sidebar.filter.toggle": "Filtres", "sidebar.filter.clearAll": "Effacer tous les filtres", + "sidebar.filter.status": "Statut", + "sidebar.filter.language": "Langue", + "sidebar.filter.dateRange": "Plage de dates", + "sidebar.filter.from": "Depuis (AAAA-MM-JJ)", + "sidebar.filter.to": "Jusqu'au (AAAA-MM-JJ)", "editor.insertLink": "Insérer un lien", "editor.insertMedia": "Insérer un média", "editor.gallery": "Galerie", diff --git a/locales/ui/it.json b/locales/ui/it.json index 8f49d60..2d36b03 100644 --- a/locales/ui/it.json +++ b/locales/ui/it.json @@ -196,6 +196,11 @@ "sidebar.filter.search": "Cerca...", "sidebar.filter.toggle": "Filtri", "sidebar.filter.clearAll": "Cancella tutti i filtri", + "sidebar.filter.status": "Stato", + "sidebar.filter.language": "Lingua", + "sidebar.filter.dateRange": "Intervallo date", + "sidebar.filter.from": "Da (AAAA-MM-GG)", + "sidebar.filter.to": "A (AAAA-MM-GG)", "editor.insertLink": "Inserisci link", "editor.insertMedia": "Inserisci media", "editor.gallery": "Galleria",