use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use chrono::Datelike; use iced::{Element, Subscription, Task}; use bds_core::db::Database; use bds_core::engine::task::{TaskId, TaskManager, TaskStatus}; use bds_core::engine; use bds_core::i18n::{detect_os_locale, UiLocale}; use bds_core::model::{Media, Post, PostStatus, Project, PublishingPreferences, Script, SshMode, Template}; use crate::i18n::{t, tw}; use crate::platform::menu::{self, MenuAction, MenuRegistry}; use crate::state::navigation::{ handle_activity_click, OutputEntry, PanelTab, SidebarView, TaskSnapshot, }; use crate::state::sidebar_filter::{ CalendarMonth, CalendarYear, MediaFilter, PostFilter, }; use crate::state::tabs::{self, Tab, TabType}; use crate::state::toast::{Toast, ToastLevel}; use crate::views::{ modal, workspace, post_editor::{LinkedMediaItem, PostEditorMsg, PostEditorState, ResolvedPostLink}, media_editor::{LinkedPostItem, MediaEditorState, MediaEditorMsg}, template_editor::{TemplateEditorState, TemplateEditorMsg}, script_editor::{ScriptEditorState, ScriptEditorMsg}, tags_view::{self, TagsMsg, TagsSection, TagsViewState}, settings_view::{default_category_rows, SettingsCategoryRow, SettingsViewState, SettingsMsg}, dashboard::{DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag, DashboardTimelineMonth}, }; // ─────────────────────────────────────────────────────────── // Message // ─────────────────────────────────────────────────────────── #[derive(Debug, Clone)] pub enum Message { // Menu MenuEvent(muda::MenuId), // Navigation SetActiveView(SidebarView), ToggleSidebar, TogglePanel, OpenSettingsSection(crate::views::settings_view::SettingsSection), // Sidebar resize SidebarResizeStart, SidebarResizeMove(f32), SidebarResizeEnd, // Tabs OpenTab(Tab), CloseTab(String), SelectTab(String), PinTab(String), ClearTabs, // Project ProjectsLoaded(Vec), SwitchProject(String), ProjectSwitched(Result), RequestCreateProject, CreateProject { name: String, data_path: Option }, ProjectCreated(Result), DeleteProject(String), ProjectDeleted(Result), // Dialogs FolderPicked(Option), MediaFilesPicked(Option>), // Tasks TaskTick, // macOS lifecycle FileOpenRequested(PathBuf), UrlOpenRequested(String), // Panel SetPanelTab(PanelTab), // Settings SetOfflineMode(bool), SetUiLocale(UiLocale), ToggleLocaleDropdown, ToggleProjectDropdown, // Toast ShowToast(ToastLevel, String), DismissToast(u64), ExpireToasts, // Sidebar filters PostSearchChanged(String), TogglePostFilterPanel, SetPostStatusFilter(Option), SetPostLanguageFilter(Option), SetPostCalendarYear(Option), SetPostCalendarMonth(Option), SetPostFromDate(String), SetPostToDate(String), TogglePostTagFilter(String), TogglePostCategoryFilter(String), ClearPostFilters, MediaSearchChanged(String), ToggleMediaFilterPanel, SetMediaCalendarYear(Option), SetMediaCalendarMonth(Option), ToggleMediaTagFilter(String), ClearMediaFilters, // Modal ShowModal(modal::ModalState), DismissModal, ConfirmModal(modal::ConfirmAction), // Blog actions (dispatched to engine) RebuildDatabase, ReindexText, RegenerateCalendar, ValidateTranslations, ValidateMedia, GenerateSite, RunMetadataDiff, EngineTaskDone { task_id: TaskId, label: String, result: Result }, // Editor views PostEditor(PostEditorMsg), MediaEditor(MediaEditorMsg), TemplateEditor(TemplateEditorMsg), ScriptEditor(ScriptEditorMsg), Tags(TagsMsg), Settings(SettingsMsg), // Editor data loading PostLoaded(Result), MediaLoaded(Result), TemplateLoaded(Result), ScriptLoaded(Result), // Async sidebar data SidebarPostsLoaded(Vec), SidebarMediaLoaded { items: Vec, thumbs: HashMap>, }, SidebarPostsAppended(Vec), SidebarMediaAppended { items: Vec, thumbs: HashMap>, }, LoadMorePosts, LoadMoreMedia, CreatePost, CreatePage, CreateMedia, CreateScript, CreateTemplate, Noop, InitMenuBar, } enum PersistedPostState { Canonical(Post), Translation(bds_core::model::PostTranslation), } const POST_AUTO_SAVE_DELAY_MS: i64 = 3_000; fn persist_post_editor_state_impl( db: &Database, data_dir: &Path, state: &PostEditorState, ) -> Result { if state.active_language != state.canonical_language { let translation = engine::post::upsert_translation( db.conn(), data_dir, &state.post_id, &state.active_language, &state.title, if state.excerpt.is_empty() { None } else { Some(state.excerpt.as_str()) }, Some(&state.content), ) .map_err(|e| e.to_string())?; Ok(PersistedPostState::Translation(translation)) } else { let post = engine::post::update_post( db.conn(), data_dir, &state.post_id, Some(&state.title), Some(&state.slug), Some(if state.excerpt.is_empty() { None } else { Some(state.excerpt.as_str()) }), Some(&state.content), Some(state.tags.clone()), Some(state.categories.clone()), Some(if state.author.is_empty() { None } else { Some(state.author.as_str()) }), Some(if state.language.is_empty() { None } else { Some(state.language.as_str()) }), Some(if state.template_slug.is_empty() { None } else { Some(state.template_slug.as_str()) }), Some(state.do_not_translate), ) .map_err(|e| e.to_string())?; Ok(PersistedPostState::Canonical(post)) } } enum PersistedMediaState { Canonical { media: Media, tags: Vec }, Translation, } fn persist_media_editor_state_impl( db: &Database, data_dir: &Path, state: &MediaEditorState, ) -> Result { if state.active_language != state.canonical_language { engine::media::upsert_media_translation( db.conn(), data_dir, &state.media_id, &state.active_language, if state.title.is_empty() { None } else { Some(state.title.as_str()) }, if state.alt.is_empty() { None } else { Some(state.alt.as_str()) }, if state.caption.is_empty() { None } else { Some(state.caption.as_str()) }, ) .map_err(|e| e.to_string())?; Ok(PersistedMediaState::Translation) } else { let tags = state .tags_input .split(',') .map(str::trim) .filter(|tag| !tag.is_empty()) .map(|tag| tag.to_string()) .collect::>(); let media = engine::media::update_media( db.conn(), data_dir, &state.media_id, Some(if state.title.is_empty() { None } else { Some(state.title.as_str()) }), Some(if state.alt.is_empty() { None } else { Some(state.alt.as_str()) }), Some(if state.caption.is_empty() { None } else { Some(state.caption.as_str()) }), Some(if state.author.is_empty() { None } else { Some(state.author.as_str()) }), Some(if state.language.is_empty() { None } else { Some(state.language.as_str()) }), Some(tags.clone()), ) .map_err(|e| e.to_string())?; Ok(PersistedMediaState::Canonical { media, tags }) } } fn save_template_editor_state_impl( db: &Database, project_id: &str, state: &TemplateEditorState, ) -> Result { engine::template::validate_template(&state.content)?; engine::template::update_template( db.conn(), &state.template_id, project_id, Some(&state.title), Some(&state.slug), Some(state.kind.clone()), Some(state.enabled), Some(&state.content), ) .map_err(|e| e.to_string()) } fn save_script_editor_state_impl( db: &Database, project_id: &str, state: &ScriptEditorState, ) -> Result { engine::script::validate_script_syntax(&state.content)?; engine::script::update_script( db.conn(), &state.script_id, project_id, Some(&state.title), Some(&state.slug), Some(state.kind.clone()), Some(&state.entrypoint), Some(state.enabled), Some(&state.content), ) .map_err(|e| e.to_string()) } fn save_editor_settings_state_impl( db: &Database, state: &SettingsViewState, ) -> Result<(), String> { let now = bds_core::util::now_unix_ms(); [ bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.default_mode", &state.default_mode, now), bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.diff_view_style", &state.diff_view_style, now), bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.wrap_long_lines", if state.wrap_long_lines { "true" } else { "false" }, now), bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.hide_unchanged_regions", if state.hide_unchanged_regions { "true" } else { "false" }, now), ] .into_iter() .collect::, _>>() .map(|_| ()) .map_err(|e| e.to_string()) } fn month_abbreviation(month: u32) -> String { match month { 1 => "Jan", 2 => "Feb", 3 => "Mar", 4 => "Apr", 5 => "May", 6 => "Jun", 7 => "Jul", 8 => "Aug", 9 => "Sep", 10 => "Oct", 11 => "Nov", 12 => "Dec", _ => "?", } .to_string() } fn format_timestamp(timestamp_ms: i64) -> String { let secs = timestamp_ms / 1000; let (year, month, day) = bds_core::util::timestamp::year_month_day_from_unix_ms(timestamp_ms); let hour = ((secs % 86_400) / 3_600) as u32; let minute = ((secs % 3_600) / 60) as u32; format!("{year}-{month:02}-{day:02} {hour:02}:{minute:02}") } fn format_bytes(size: i64) -> String { let size = size.max(0) as f64; if size < 1024.0 { return format!("{} B", size as i64); } if size < 1024.0 * 1024.0 { return format!("{:.1} KB", size / 1024.0); } if size < 1024.0 * 1024.0 * 1024.0 { return format!("{:.1} MB", size / (1024.0 * 1024.0)); } format!("{:.1} GB", size / (1024.0 * 1024.0 * 1024.0)) } #[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; use crate::views::settings_view::SettingsViewState; use crate::views::template_editor::TemplateEditorState; use chrono::{Datelike, TimeZone}; use bds_core::db::fts::ensure_fts_tables; use bds_core::db::queries::project::insert_project; use bds_core::db::Database; use bds_core::engine::{media, post, script, template}; use bds_core::model::{Project, ScriptKind, TemplateKind}; use tempfile::TempDir; fn make_project() -> Project { Project { id: "p1".to_string(), name: "Test Project".to_string(), slug: "test-project".to_string(), description: Some("desc".to_string()), data_path: None, is_active: true, created_at: 1000, updated_at: 1000, } } fn tiny_png_bytes() -> &'static [u8] { &[ 0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, b'I', b'H', b'D', b'R', 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, b'I', b'D', b'A', b'T', 0x78, 0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0xF0, 0x1F, 0x00, 0x05, 0x00, 0x01, 0xFF, 0x89, 0x99, 0x3D, 0x1D, 0x00, 0x00, 0x00, 0x00, b'I', b'E', b'N', b'D', 0xAE, 0x42, 0x60, 0x82, ] } fn setup() -> (Database, Project, TempDir) { let mut db = Database::open_in_memory().unwrap(); db.migrate().unwrap(); ensure_fts_tables(db.conn()).unwrap(); let project = make_project(); insert_project(db.conn(), &project).unwrap(); let tempdir = TempDir::new().unwrap(); std::fs::create_dir_all(tempdir.path().join("meta")).unwrap(); std::fs::write( tempdir.path().join("meta/project.json"), r#"{"name":"Test Project","maxPostsPerPage":50,"blogLanguages":["en"],"semanticSimilarityEnabled":false}"#, ) .unwrap(); std::fs::write(tempdir.path().join("meta/publishing.json"), "{}\n").unwrap(); std::fs::write( tempdir.path().join("meta/categories.json"), r#"["article","aside","page","picture"]"#, ) .unwrap(); std::fs::write(tempdir.path().join("meta/category-meta.json"), "{}\n").unwrap(); (db, project, tempdir) } fn make_app(db: Database, project: Project, tmp: &TempDir) -> BdsApp { BdsApp::new_for_tests(db, project, tmp.path().to_path_buf()) } #[test] fn post_editor_save_flow_persists_changes() { let (db, project, tmp) = setup(); let created = post::create_post( db.conn(), tmp.path(), &project.id, "Original", Some("Body"), vec!["rust".to_string()], vec!["article".to_string()], Some("Alice"), Some("en"), None, ).unwrap(); let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap(); let mut editor = PostEditorState::from_post(&editor_post, &["en".to_string(), "de".to_string()], &[], Vec::new(), Vec::new(), Vec::new()); editor.title = "Updated Post".to_string(); editor.content = "Updated body".to_string(); editor.tags = vec!["rust".to_string(), "lua".to_string()]; let result = persist_post_editor_state_impl(&db, tmp.path(), &editor).unwrap(); match result { PersistedPostState::Canonical(post) => assert_eq!(post.title, "Updated Post"), PersistedPostState::Translation(_) => panic!("expected canonical post save"), } let saved = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap(); assert_eq!(saved.title, "Updated Post"); assert_eq!(saved.content.as_deref(), Some("Updated body")); assert_eq!(saved.tags, vec!["rust".to_string(), "lua".to_string()]); } #[test] fn media_editor_save_flow_persists_changes() { let (db, project, tmp) = setup(); let source = tmp.path().join("tiny.png"); std::fs::write(&source, tiny_png_bytes()).unwrap(); let imported = media::import_media( db.conn(), tmp.path(), &project.id, &source, "tiny.png", Some("Tiny"), Some("Alt"), None, None, Some("en"), vec!["photo".to_string()], ).unwrap(); let media_record = bds_core::db::queries::media::get_media_by_id(db.conn(), &imported.id).unwrap(); let mut editor = MediaEditorState::from_media(&media_record, &["en".to_string()], &[], Vec::new()); editor.title = "Tiny Updated".to_string(); editor.tags_input = "photo, lua".to_string(); let result = persist_media_editor_state_impl(&db, tmp.path(), &editor).unwrap(); match result { PersistedMediaState::Canonical { media, tags } => { assert_eq!(media.title.as_deref(), Some("Tiny Updated")); assert_eq!(tags, vec!["photo".to_string(), "lua".to_string()]); } PersistedMediaState::Translation => panic!("expected canonical media save"), } let saved = bds_core::db::queries::media::get_media_by_id(db.conn(), &imported.id).unwrap(); assert_eq!(saved.title.as_deref(), Some("Tiny Updated")); assert_eq!(saved.tags, vec!["photo".to_string(), "lua".to_string()]); assert!(tmp.path().join(saved.sidecar_path).exists()); } #[test] fn template_editor_save_flow_persists_changes() { let (db, project, _tmp) = setup(); let created = template::create_template( db.conn(), &project.id, "Post Template", TemplateKind::Post, "
{{ title }}
", ).unwrap(); let template_record = bds_core::db::queries::template::get_template_by_id(db.conn(), &created.id).unwrap(); let mut editor = TemplateEditorState::from_template(&template_record); editor.title = "Updated Template".to_string(); editor.content = "
{{ title }}
".to_string(); let saved_template = save_template_editor_state_impl(&db, &project.id, &editor).unwrap(); assert_eq!(saved_template.title, "Updated Template"); let saved = bds_core::db::queries::template::get_template_by_id(db.conn(), &created.id).unwrap(); assert_eq!(saved.title, "Updated Template"); assert_eq!(saved.content.as_deref(), Some("
{{ title }}
")); } #[test] fn template_editor_save_rejects_invalid_content() { let (db, project, _tmp) = setup(); let created = template::create_template( db.conn(), &project.id, "Broken Template", TemplateKind::Post, "
{{ title }}
", ).unwrap(); let template_record = bds_core::db::queries::template::get_template_by_id(db.conn(), &created.id).unwrap(); let mut editor = TemplateEditorState::from_template(&template_record); editor.content = "{% if title %}".to_string(); let error = save_template_editor_state_impl(&db, &project.id, &editor).unwrap_err(); assert!(error.contains("endif") || error.contains("unclosed") || error.contains("missing")); let saved = bds_core::db::queries::template::get_template_by_id(db.conn(), &created.id).unwrap(); assert_eq!(saved.content.as_deref(), Some("
{{ title }}
")); } #[test] fn script_editor_save_flow_persists_changes() { let (db, project, _tmp) = setup(); let created = script::create_script( db.conn(), &project.id, "Utility Script", ScriptKind::Utility, "function main()\n return 'ok'\nend", Some("main"), ).unwrap(); let script_record = bds_core::db::queries::script::get_script_by_id(db.conn(), &created.id).unwrap(); let mut editor = ScriptEditorState::from_script(&script_record); editor.title = "Updated Script".to_string(); editor.content = "function main()\n return 'lua'\nend".to_string(); editor.entrypoint = "main".to_string(); let saved_script = save_script_editor_state_impl(&db, &project.id, &editor).unwrap(); assert_eq!(saved_script.title, "Updated Script"); let saved = bds_core::db::queries::script::get_script_by_id(db.conn(), &created.id).unwrap(); assert_eq!(saved.title, "Updated Script"); assert_eq!(saved.content.as_deref(), Some("function main()\n return 'lua'\nend")); } #[test] fn script_editor_save_rejects_invalid_content() { let (db, project, _tmp) = setup(); let created = script::create_script( db.conn(), &project.id, "Broken Script", ScriptKind::Utility, "function main()\n return 'ok'\nend", Some("main"), ).unwrap(); let script_record = bds_core::db::queries::script::get_script_by_id(db.conn(), &created.id).unwrap(); let mut editor = ScriptEditorState::from_script(&script_record); editor.content = "function main()\n return 'oops'".to_string(); let error = save_script_editor_state_impl(&db, &project.id, &editor).unwrap_err(); assert!(error.contains("end") || error.contains("unclosed") || error.contains("missing")); let saved = bds_core::db::queries::script::get_script_by_id(db.conn(), &created.id).unwrap(); assert_eq!(saved.content.as_deref(), Some("function main()\n return 'ok'\nend")); } #[test] fn settings_editor_save_flow_persists_values() { let (db, _project, _tmp) = setup(); let settings = SettingsViewState { default_mode: "markdown".to_string(), diff_view_style: "side-by-side".to_string(), wrap_long_lines: false, hide_unchanged_regions: true, ..SettingsViewState::default() }; save_editor_settings_state_impl(&db, &settings).unwrap(); let wrap = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.wrap_long_lines").unwrap(); let hide = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.hide_unchanged_regions").unwrap(); let diff = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.diff_view_style").unwrap(); assert_eq!(wrap.value, "false"); assert_eq!(hide.value, "true"); assert_eq!(diff.value, "side-by-side"); } #[test] fn task_tick_autosaves_dirty_post_editor() { let (db, project, tmp) = setup(); let created = post::create_post( db.conn(), tmp.path(), &project.id, "Original", Some("Body"), vec![], vec![], None, Some("en"), None, ).unwrap(); let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap(); let mut editor = PostEditorState::from_post(&editor_post, &["en".to_string()], &[], Vec::new(), Vec::new(), Vec::new()); editor.title = "Autosaved".to_string(); editor.mark_dirty(); editor.last_edit_at_ms = bds_core::util::now_unix_ms() - POST_AUTO_SAVE_DELAY_MS - 100; let mut app = make_app(db, project, &tmp); app.post_editors.insert(created.id.clone(), editor); app.tabs.push(crate::state::tabs::Tab { id: created.id.clone(), tab_type: crate::state::tabs::TabType::Post, title: "Original".to_string(), is_transient: false, is_dirty: true, }); app.active_tab = Some(created.id.clone()); let _ = app.update(Message::TaskTick); let saved = bds_core::db::queries::post::get_post_by_id(app.db.as_ref().unwrap().conn(), &created.id).unwrap(); assert_eq!(saved.title, "Autosaved"); assert!(!app.post_editors.get(&created.id).unwrap().is_dirty); } #[test] fn switching_tabs_flushes_active_post_editor() { let (db, project, tmp) = setup(); let created = post::create_post( db.conn(), tmp.path(), &project.id, "Original", Some("Body"), vec![], vec![], None, Some("en"), None, ).unwrap(); let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap(); let mut editor = PostEditorState::from_post(&editor_post, &["en".to_string()], &[], Vec::new(), Vec::new(), Vec::new()); editor.title = "Switched".to_string(); editor.mark_dirty(); let mut app = make_app(db, project, &tmp); app.post_editors.insert(created.id.clone(), editor); app.tabs.push(crate::state::tabs::Tab { id: created.id.clone(), tab_type: crate::state::tabs::TabType::Post, title: "Original".to_string(), is_transient: false, is_dirty: true, }); app.tabs.push(crate::state::tabs::Tab { id: "settings".to_string(), tab_type: crate::state::tabs::TabType::Settings, title: "Settings".to_string(), is_transient: false, is_dirty: false, }); app.active_tab = Some(created.id.clone()); let _ = app.update(Message::SelectTab("settings".to_string())); let saved = bds_core::db::queries::post::get_post_by_id(app.db.as_ref().unwrap().conn(), &created.id).unwrap(); assert_eq!(saved.title, "Switched"); assert_eq!(app.active_tab.as_deref(), Some("settings")); } #[test] fn media_editor_link_and_unlink_post_refreshes_relationships() { let (db, project, tmp) = setup(); let created_post = post::create_post( db.conn(), tmp.path(), &project.id, "Linked Post", Some("Body"), vec![], vec![], None, Some("en"), None, ).unwrap(); let source = tmp.path().join("tiny.png"); std::fs::write(&source, tiny_png_bytes()).unwrap(); let imported = media::import_media( db.conn(), tmp.path(), &project.id, &source, "tiny.png", Some("Tiny"), Some("Alt"), None, None, Some("en"), vec![], ).unwrap(); let media_record = bds_core::db::queries::media::get_media_by_id(db.conn(), &imported.id).unwrap(); let mut app = make_app(db, project, &tmp); app.media_editors.insert( imported.id.clone(), MediaEditorState::from_media(&media_record, &["en".to_string()], &[], Vec::new()), ); app.tabs.push(crate::state::tabs::Tab { id: imported.id.clone(), tab_type: crate::state::tabs::TabType::Media, title: "Tiny".to_string(), is_transient: false, is_dirty: false, }); app.active_tab = Some(imported.id.clone()); let _ = app.update(Message::MediaEditor(MediaEditorMsg::LinkPost(created_post.id.clone()))); let linked = bds_core::engine::post_media::list_posts_for_media(app.db.as_ref().unwrap().conn(), &imported.id).unwrap(); assert_eq!(linked.len(), 1); assert_eq!(linked[0].id, created_post.id); assert_eq!(app.media_editors.get(&imported.id).unwrap().linked_posts.len(), 1); let _ = app.update(Message::MediaEditor(MediaEditorMsg::UnlinkPost(created_post.id.clone()))); let linked = bds_core::engine::post_media::list_posts_for_media(app.db.as_ref().unwrap().conn(), &imported.id).unwrap(); assert!(linked.is_empty()); assert!(app.media_editors.get(&imported.id).unwrap().linked_posts.is_empty()); } #[test] fn refresh_counts_populates_dashboard_state() { let (db, project, tmp) = setup(); let first = post::create_post( db.conn(), tmp.path(), &project.id, "First", Some("Body"), vec!["rust".to_string()], vec!["article".to_string()], None, Some("en"), None, ) .unwrap(); let second = post::create_post( db.conn(), tmp.path(), &project.id, "Second", Some("Body"), vec!["lua".to_string()], vec!["aside".to_string()], None, Some("en"), None, ) .unwrap(); let mut second_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &second.id).unwrap(); second_post.status = PostStatus::Published; bds_core::db::queries::post::update_post(db.conn(), &second_post).unwrap(); bds_core::engine::tag::discover_tags(db.conn(), tmp.path(), &project.id).unwrap(); let source = tmp.path().join("tiny.png"); std::fs::write(&source, tiny_png_bytes()).unwrap(); media::import_media( db.conn(), tmp.path(), &project.id, &source, "tiny.png", Some("Tiny"), Some("Alt"), None, None, Some("en"), vec!["cover".to_string()], ) .unwrap(); let mut app = make_app(db, project, &tmp); let _ = app.refresh_counts(); let dash = app.dashboard_state.expect("dashboard state should be set"); let now = chrono::Utc::now(); assert_eq!(dash.stats.total_posts, 2); assert_eq!(dash.stats.published_count, 1); assert_eq!(dash.stats.media_count, 1); assert_eq!(dash.stats.tag_count, 2); assert_eq!(dash.timeline.len(), 12); assert_eq!(dash.timeline.last().map(|month| month.year), Some(now.year())); assert_eq!(dash.timeline.last().map(|month| month.label.clone()), Some(month_abbreviation(now.month()))); assert_eq!(dash.timeline.iter().map(|month| month.count).sum::(), 2); assert_eq!(dash.recent_posts.len(), 2); assert!(!dash.category_cloud.is_empty()); assert_eq!(dash.recent_posts[0].title, "Second"); assert_eq!(first.title, "First"); } #[test] fn dashboard_timeline_uses_created_month_not_updated_month() { let (db, project, tmp) = setup(); let created = post::create_post( db.conn(), tmp.path(), &project.id, "March Post", Some("Body"), vec![], vec!["article".to_string()], None, Some("en"), None, ) .unwrap(); let march_created_at = chrono::Utc .with_ymd_and_hms(2026, 3, 15, 12, 0, 0) .single() .unwrap() .timestamp_millis(); let april_updated_at = chrono::Utc .with_ymd_and_hms(2026, 4, 2, 12, 0, 0) .single() .unwrap() .timestamp_millis(); let mut saved = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap(); saved.created_at = march_created_at; saved.updated_at = april_updated_at; bds_core::db::queries::post::update_post(db.conn(), &saved).unwrap(); let app = make_app(db, project, &tmp); let dash = app.hydrate_dashboard_state(); let march = dash .timeline .iter() .find(|month| month.year == 2026 && month.label == "Mar") .expect("march bucket should exist"); let april = dash .timeline .iter() .find(|month| month.year == 2026 && month.label == "Apr") .expect("april bucket should exist"); assert_eq!(march.count, 1); assert_eq!(april.count, 0); } #[test] fn save_project_persists_languages_and_blogmark_category() { let (db, project, tmp) = setup(); let mut app = make_app(db, project, &tmp); let mut state = app.hydrate_settings_state(); state.project_name = "Localized Project".to_string(); state.main_language = "de".to_string(); state.blog_languages = vec!["de".to_string(), "en".to_string()]; state.blogmark_category = "article".to_string(); state.semantic_similarity_enabled = true; app.settings_state = Some(state); let _ = app.handle_settings_msg(SettingsMsg::SaveProject); let meta = bds_core::engine::meta::read_project_json(tmp.path()).unwrap(); assert_eq!(meta.name, "Localized Project"); assert_eq!(meta.main_language.as_deref(), Some("de")); assert_eq!(meta.blog_languages, vec!["de".to_string(), "en".to_string()]); assert_eq!(meta.blogmark_category.as_deref(), Some("article")); assert!(meta.semantic_similarity_enabled); } #[test] fn add_category_and_reset_defaults_updates_metadata_files() { let (db, project, tmp) = setup(); let mut app = make_app(db, project, &tmp); app.settings_state = Some(app.hydrate_settings_state()); let _ = app.handle_settings_msg(SettingsMsg::AddCategoryNameChanged("news".to_string())); let _ = app.handle_settings_msg(SettingsMsg::AddCategory); let categories = bds_core::engine::meta::read_categories_json(tmp.path()).unwrap(); assert!(categories.iter().any(|category| category == "news")); let _ = app.handle_settings_msg(SettingsMsg::ResetCategoriesToDefaults); let categories = bds_core::engine::meta::read_categories_json(tmp.path()).unwrap(); assert_eq!(categories, vec![ "article".to_string(), "aside".to_string(), "page".to_string(), "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()); } } // ─────────────────────────────────────────────────────────── // App State // ─────────────────────────────────────────────────────────── pub struct BdsApp { // Database db: Option, db_path: PathBuf, // Project active_project: Option, projects: Vec, data_dir: Option, // Counts post_count: usize, media_count: usize, // Sidebar data sidebar_posts: Vec, sidebar_media: Vec, sidebar_scripts: Vec