use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use chrono::Datelike; use iced::{Element, Subscription, Task, window}; use rusqlite::Error as SqlError; use serde_json::json; use uuid::Uuid; use bds_core::db::Database; use bds_core::engine::task::{TaskId, TaskManager, TaskStatus}; use bds_core::engine; use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind}; use bds_core::i18n::{detect_os_locale, UiLocale}; use bds_core::model::{Media, Post, PostStatus, PostTranslation, Project, PublishingPreferences, Script, SshMode, Template}; use crate::components::webview::{self, WebViewConfig, WebViewController}; 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}, site_validation::SiteValidationState, template_editor::{TemplateEditorState, TemplateEditorMsg}, script_editor::{ScriptEditorState, ScriptEditorMsg}, tags_view::{self, TagsMsg, TagsSection, TagsViewState}, settings_view::{default_category_rows, AiModelOption, 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), MainWindowLoaded(Option), EmbeddedPreviewReady(Result<(), 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), ToggleAiSuggestionField(usize, bool), ApplyAiSuggestions(modal::AiEntityTarget, Vec), // Blog actions (dispatched to engine) RebuildDatabase, ReindexText, RegenerateCalendar, ValidateTranslations, ValidateMedia, GenerateSite, RunMetadataDiff, RunSiteValidation, ApplySiteValidation, EngineTaskDone { task_id: TaskId, label: String, result: Result }, SiteValidationLoaded(Result), SiteValidationApplied(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), if state.published_at.is_some() { None } else { 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)) } } fn persist_post_editor_preview_state_impl( db: &Database, state: &PostEditorState, ) -> Result { if state.active_language != state.canonical_language { let post = bds_core::db::queries::post::get_post_by_id(db.conn(), &state.post_id) .map_err(|e| e.to_string())?; if post.do_not_translate { return Err("cannot create translation for a do-not-translate post".to_string()); } let now = bds_core::util::now_unix_ms(); match bds_core::db::queries::post_translation::get_post_translation_by_post_and_language( db.conn(), &state.post_id, &state.active_language, ) { Ok(mut translation) => { translation.title = state.title.clone(); translation.excerpt = if state.excerpt.is_empty() { None } else { Some(state.excerpt.clone()) }; translation.content = Some(state.content.clone()); translation.updated_at = now; bds_core::db::queries::post_translation::update_post_translation( db.conn(), &translation, ) .map_err(|e| e.to_string())?; Ok(PersistedPostState::Translation(translation)) } Err(SqlError::QueryReturnedNoRows) => { let translation = PostTranslation { id: Uuid::new_v4().to_string(), project_id: post.project_id, translation_for: state.post_id.clone(), language: state.active_language.clone(), title: state.title.clone(), excerpt: if state.excerpt.is_empty() { None } else { Some(state.excerpt.clone()) }, content: Some(state.content.clone()), status: PostStatus::Draft, file_path: String::new(), checksum: None, created_at: now, updated_at: now, published_at: None, }; bds_core::db::queries::post_translation::insert_post_translation( db.conn(), &translation, ) .map_err(|e| e.to_string())?; Ok(PersistedPostState::Translation(translation)) } Err(error) => Err(error.to_string()), } } else { let mut post = bds_core::db::queries::post::get_post_by_id(db.conn(), &state.post_id) .map_err(|e| e.to_string())?; if post.published_at.is_none() && state.slug != post.slug { match bds_core::db::queries::post::get_post_by_project_and_slug( db.conn(), &post.project_id, &state.slug, ) { Ok(existing) if existing.id != post.id => { return Err(format!( "slug '{}' already exists in this project", state.slug )); } Ok(_) | Err(SqlError::QueryReturnedNoRows) => {} Err(error) => return Err(error.to_string()), } } post.title = state.title.clone(); if post.published_at.is_none() { post.slug = state.slug.clone(); } post.excerpt = if state.excerpt.is_empty() { None } else { Some(state.excerpt.clone()) }; post.content = Some(state.content.clone()); post.tags = state.tags.clone(); post.categories = state.categories.clone(); post.author = if state.author.is_empty() { None } else { Some(state.author.clone()) }; post.language = if state.language.is_empty() { None } else { Some(state.language.clone()) }; post.template_slug = if state.template_slug.is_empty() { None } else { Some(state.template_slug.clone()) }; post.do_not_translate = state.do_not_translate; if matches!(post.status, PostStatus::Published | PostStatus::Archived) { post.status = PostStatus::Draft; } post.updated_at = bds_core::util::now_unix_ms(); bds_core::db::queries::post::update_post(db.conn(), &post).map_err(|e| e.to_string())?; Ok(PersistedPostState::Canonical(post)) } } enum PersistedMediaState { Canonical { media: Media, tags: Vec }, Translation, } struct PreviewSession { project_id: String, handle: engine::preview::PreviewServerHandle, } struct EmbeddedPreviewState { controller: WebViewController, current_url: Option, } 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 load_generation_post_body(data_dir: &Path, post: &Post) -> Result { if let Some(content) = &post.content { return Ok(content.clone()); } let raw = std::fs::read_to_string(data_dir.join(&post.file_path)).map_err(|e| e.to_string())?; let (_frontmatter, body) = bds_core::util::frontmatter::read_post_file(&raw)?; Ok(body) } fn default_post_editor_mode(settings_state: Option<&SettingsViewState>) -> &str { settings_state .map(|state| state.default_mode.as_str()) .unwrap_or("markdown") } fn referenced_media_ids(content: &str) -> Vec { let mut ids = Vec::new(); let mut rest = content; while let Some(start) = rest.find("bds-media://") { let suffix = &rest[start + "bds-media://".len()..]; let end = suffix .find(|ch: char| ch == ')' || ch == ']' || ch.is_whitespace()) .unwrap_or(suffix.len()); let media_id = suffix[..end].trim(); if !media_id.is_empty() && !ids.iter().any(|existing| existing == media_id) { ids.push(media_id.to_string()); } rest = &suffix[end..]; } ids } fn draft_preview_url(post_id: &str, language: &str) -> String { format!( "http://{}:{}/__draft/{}?language={}", engine::preview::PREVIEW_HOST, engine::preview::PREVIEW_PORT, post_id, language ) } 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_preview_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::{ai, media, post, script, template}; use bds_core::model::{Project, ScriptKind, TemplateKind}; use std::io::{Read, Write}; use std::net::TcpListener; use std::thread; 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 spawn_models_server() -> String { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); thread::spawn(move || { if let Some(stream) = listener.incoming().next() { let mut stream = stream.unwrap(); let mut buffer = [0_u8; 8192]; let size = stream.read(&mut buffer).unwrap(); let request = String::from_utf8_lossy(&buffer[..size]).to_string(); assert!(request.starts_with("GET /v1/models HTTP/1.1")); let body = r#"{"data":[{"id":"llama3.2","name":"Llama 3.2"}]}"#; let response = format!( "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", body.len(), body, ); stream.write_all(response.as_bytes()).unwrap(); } }); format!("http://{}", addr) } 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, "markdown", &["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 persist_post_editor_state_allows_published_posts_without_slug_conflict() { let (db, project, tmp) = setup(); let created = post::create_post( db.conn(), tmp.path(), &project.id, "Published", Some("Body"), Vec::new(), vec!["article".to_string()], None, Some("en"), None, ) .unwrap(); let published = post::publish_post(db.conn(), tmp.path(), &created.id).unwrap(); let mut editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &published.id).unwrap(); let raw = std::fs::read_to_string(tmp.path().join(&editor_post.file_path)).unwrap(); let (_frontmatter, body) = bds_core::util::frontmatter::read_post_file(&raw).unwrap(); editor_post.content = Some(body); let editor = PostEditorState::from_post( &editor_post, "preview", &["en".to_string()], &[], Vec::new(), Vec::new(), Vec::new(), ); let result = persist_post_editor_state_impl(&db, tmp.path(), &editor).unwrap(); match result { PersistedPostState::Canonical(post) => { assert_eq!(post.slug, created.slug); assert_eq!(post.title, created.title); } PersistedPostState::Translation(_) => panic!("expected canonical post save"), } } #[test] fn preview_persist_bypasses_fts_for_published_canonical_post() { let (db, project, tmp) = setup(); let created = post::create_post( db.conn(), tmp.path(), &project.id, "Published", Some("Body"), Vec::new(), vec!["article".to_string()], None, Some("en"), None, ) .unwrap(); let published = post::publish_post(db.conn(), tmp.path(), &created.id).unwrap(); db.conn().execute("DROP TABLE posts_fts", []).unwrap(); let mut editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &published.id).unwrap(); let raw = std::fs::read_to_string(tmp.path().join(&editor_post.file_path)).unwrap(); let (_frontmatter, body) = bds_core::util::frontmatter::read_post_file(&raw).unwrap(); editor_post.content = Some(body); let mut editor = PostEditorState::from_post( &editor_post, "preview", &["en".to_string()], &[], Vec::new(), Vec::new(), Vec::new(), ); editor.title = "Preview Draft".to_string(); editor.slug = "changed-slug".to_string(); editor.content = "Preview-only body".to_string(); let result = persist_post_editor_preview_state_impl(&db, &editor).unwrap(); match result { PersistedPostState::Canonical(post) => { assert_eq!(post.slug, created.slug); assert_eq!(post.title, "Preview Draft"); assert_eq!(post.content.as_deref(), Some("Preview-only body")); assert_eq!(post.status, PostStatus::Draft); } PersistedPostState::Translation(_) => panic!("expected canonical post save"), } } #[test] fn preview_persist_bypasses_fts_for_translation_updates() { let (db, project, tmp) = setup(); let created = post::create_post( db.conn(), tmp.path(), &project.id, "Canonical", Some("Body"), Vec::new(), vec!["article".to_string()], None, Some("en"), None, ) .unwrap(); post::upsert_translation( db.conn(), tmp.path(), &created.id, "de", "Alt", Some("Auszug"), Some("Inhalt"), ) .unwrap(); db.conn().execute("DROP TABLE posts_fts", []).unwrap(); let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap(); let translations = bds_core::db::queries::post_translation::list_post_translations_by_post( db.conn(), &created.id, ) .unwrap(); let mut editor = PostEditorState::from_post( &editor_post, "preview", &["en".to_string(), "de".to_string()], &translations, Vec::new(), Vec::new(), Vec::new(), ); editor.switch_language("de"); editor.title = "Vorschau".to_string(); editor.excerpt = "Kurz".to_string(); editor.content = "Nur Vorschau".to_string(); let result = persist_post_editor_preview_state_impl(&db, &editor).unwrap(); match result { PersistedPostState::Translation(translation) => { assert_eq!(translation.language, "de"); assert_eq!(translation.title, "Vorschau"); assert_eq!(translation.content.as_deref(), Some("Nur Vorschau")); } PersistedPostState::Canonical(_) => panic!("expected translation save"), } } #[test] fn save_ai_settings_allows_airplane_only_configuration() { let (db, _project, _tmp) = setup(); let mut state = SettingsViewState::default(); state.airplane_endpoint_url = spawn_models_server(); state.airplane_endpoint_model = "llama3.2".to_string(); state.system_prompt = iced::widget::text_editor::Content::with_text("Use JSON only."); BdsApp::save_ai_settings_state(&db, &mut state).unwrap(); let settings = ai::load_ai_settings(db.conn(), false).unwrap(); assert!(settings.online_endpoint.url.is_empty()); assert!(settings.online_endpoint.model.is_empty()); assert_eq!(settings.airplane_endpoint.url, state.airplane_endpoint_url); assert_eq!(settings.airplane_endpoint.model, "llama3.2"); assert_eq!(settings.system_prompt.trim_end(), "Use JSON only."); } #[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 load_post_media_items_includes_media_referenced_only_in_markdown() { 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 post = post::create_post( db.conn(), tmp.path(), &project.id, "Referenced", Some(&format!("![](bds-media://{})", imported.id)), Vec::new(), vec!["article".to_string()], None, Some("en"), None, ) .unwrap(); let app = make_app(db, project, &tmp); let linked_media = app.load_post_media_items(&post.id, post.content.as_deref()); assert_eq!(linked_media.len(), 1); assert_eq!(linked_media[0].media_id, imported.id); assert!(linked_media[0].is_image); } #[test] fn draft_preview_url_points_at_local_preview_server() { let url = super::draft_preview_url("post-42", "de"); assert_eq!( url, format!( "http://{}:{}/__draft/post-42?language=de", bds_core::engine::preview::PREVIEW_HOST, bds_core::engine::preview::PREVIEW_PORT, ) ); } #[test] fn active_post_uses_embedded_preview_only_for_preview_mode_posts() { let (db, project, tmp) = setup(); let created = post::create_post( db.conn(), tmp.path(), &project.id, "Previewed", Some("Body"), Vec::new(), vec!["article".to_string()], 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, "markdown", &["en".to_string()], &[], Vec::new(), Vec::new(), Vec::new(), ); let mut app = make_app(db, project, &tmp); app.tabs.push(crate::state::tabs::Tab { id: created.id.clone(), tab_type: crate::state::tabs::TabType::Post, title: created.title.clone(), is_transient: false, is_dirty: false, }); app.active_tab = Some(created.id.clone()); app.post_editors.insert(created.id.clone(), editor.clone()); assert!(!app.active_post_uses_embedded_preview()); editor.set_editor_mode("preview"); app.post_editors.insert(created.id.clone(), editor); assert!(app.active_post_uses_embedded_preview()); } #[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, "markdown", &["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, "markdown", &["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