use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use bds_core::db::DbQueryError as SqlError; use iced::{Element, Subscription, Task, window}; use serde_json::json; use uuid::Uuid; use bds_core::db::Database; use bds_core::engine; use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind}; use bds_core::engine::task::{TaskId, TaskManager, TaskStatus}; use bds_core::i18n::{UiLocale, detect_os_locale, normalize_language}; use bds_core::model::{ ChatConversation, DomainEntity, DomainEvent, ImportDefinition, ImportReport, Media, NotificationAction, Post, PostStatus, PostTranslation, Project, PublishingPreferences, Script, SshMode, Template, }; use crate::components::native_edit; use crate::components::webview::{self, WebViewConfig, WebViewController}; use crate::i18n::{t, tw}; use crate::platform::menu::{self, MenuAction, MenuRegistry}; use crate::state::navigation::{ OutputEntry, PanelTab, SidebarView, TaskSnapshot, handle_activity_click, }; 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::{ chat_view::{ChatEditorState, ChatModelChoice}, dashboard::{ DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag, DashboardTimelineMonth, }, documentation::{DocumentLoad, DocumentationKind, DocumentationState, current_signature}, duplicates::DuplicatesState, git::{GitDiffLoad, GitDiffState, GitNetworkCompletion, GitSnapshot, GitUiState}, import_editor::{ ImportAnalysisEvent, ImportEditorMsg, ImportEditorState, ImportExecutionEvent, }, media_editor::{LinkedPostItem, MediaEditorMsg, MediaEditorState}, menu_editor::{MenuEditorMsg, MenuEditorState, MenuEditorStatus}, metadata_diff::MetadataDiffState, modal, post_editor::{LinkedMediaItem, PostEditorMsg, PostEditorState, ResolvedPostLink}, script_editor::{ScriptEditorMsg, ScriptEditorState}, settings_view::{ AiModeViewState, AiModelOption, SettingsCategoryRow, SettingsMsg, SettingsViewState, default_category_rows, }, site_validation::SiteValidationState, style_view::{StyleMsg, StyleViewState}, tags_view::{self, TagsMsg, TagsSection, TagsViewState}, template_editor::{TemplateEditorMsg, TemplateEditorState}, workspace, }; mod editor_handlers; mod engine_handlers; mod git_handlers; mod preview_handlers; mod search; mod tasks; // ─────────────────────────────────────────────────────────── // Message // ─────────────────────────────────────────────────────────── #[derive(Debug, Clone)] pub enum OneShotAiAction { PostAnalysis, PostTaxonomy, PostLanguage, PostTranslation { target_language: String }, MediaAnalysis, MediaLanguage, MediaTranslation { target_language: String }, } #[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), // Project ProjectsLoaded(Vec), SwitchProject(String), RequestCreateProject, RequestOpenProject, CreateProject { name: String, data_path: Option, }, DeleteProject(String), // Dialogs FolderPicked(Option), ProjectFolderPicked(Option), MediaFilesPicked(Option>), GalleryImagesPicked { post_id: String, result: Result>, String>, }, GalleryImportFinished { post_id: String, report: engine::gallery_import::GalleryImportReport, }, FileDropped(PathBuf), ImageDropImported { task_id: TaskId, post_id: String, project_id: String, data_dir: PathBuf, source_language: String, offline_mode: bool, path: PathBuf, result: Result, }, ImageDropEnriched { task_id: TaskId, post_id: String, path: PathBuf, result: Result, }, MediaImportFinished { imported: Vec, errors: Vec, }, MediaReplacementPicked { media_id: String, path: Option, }, MediaReplacementFinished { media_id: String, result: Result, String>, }, ImportUploadsPicked { definition_id: String, path: Option, }, ImportWxrPicked { definition_id: String, path: Option, }, ImportAnalysisEvent { definition_id: String, event: ImportAnalysisEvent, }, ImportExecutionEvent { definition_id: String, event: ImportExecutionEvent, }, ImportAutoMapFinished { definition_id: String, result: Result<(ImportDefinition, ImportReport, usize), String>, }, // Tasks TaskTick, DomainEventsTick, CancelTask(TaskId), ToggleTaskGroup(String), // macOS lifecycle FileOpenRequested(PathBuf), UrlOpenRequested(String), BlogmarkImported { task_id: TaskId, result: Result, }, MainWindowLoaded(Option), EmbeddedPreviewReady(Result<(), String>), EmbeddedStylePreviewReady(Result<(), String>), // Panel SetPanelTab(PanelTab), // Settings SetOfflineMode(bool), SetUiLocale(UiLocale), ToggleLocaleDropdown, ToggleProjectDropdown, // Toast 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), RemoteTargetChanged(String), RemoteConnectRequested, RemoteConnected(Result<(Arc, Vec), String>), RemoteProjectSelected(String), RemoteOpenProjectRequested, RemoteProjectOpened(Result), RemoteDisconnectRequested, FindQueryChanged(String), ReplaceQueryChanged(String), FindNext, ReplaceCurrent, ReplaceAll, ToggleAiSuggestionField(usize, bool), ApplyAiSuggestions(modal::AiEntityTarget, Vec), OneShotAiFinished { entity_id: String, action: OneShotAiAction, result: Result, }, // Blog actions (dispatched to engine) RebuildDatabase, ReindexText, RegenerateCalendar, ValidateTranslations, TranslationValidationLoaded( Result, ), ValidateMedia, GenerateSite, RunMetadataDiff, MetadataDiffLoaded(Result), RepairMetadataDiffItem { index: usize, direction: engine::metadata_diff::RepairDirection, }, MetadataDiffItemRepaired(Result<(), String>), RunSiteValidation, ApplySiteValidation, EngineTaskDone { task_id: TaskId, operation: &'static str, label: String, result: Result, }, SiteGenerationSectionDone { group_id: String, task_id: TaskId, result: Result, }, SiteGenerationIndexDone { group_id: String, task_id: TaskId, result: Result, }, SiteValidationLoaded(Result), DuplicatesRefresh, DuplicatesLoaded(Result), DuplicatesToggle(String, String), DuplicatesCheckAll, DuplicatesUncheckAll, DuplicatesDismiss(String, String), DuplicatesDismissSelected, DuplicatesDismissed(Result<(), String>), DuplicatesShowMore, DuplicatesOpenPost(String), DocumentationRefresh(DocumentationKind), DocumentationLoaded(DocumentationKind, u64, DocumentLoad), DocumentationLinkClicked(DocumentationKind, String), EmbeddingReindex, EmbeddingBackfill, LoadSemanticTagSuggestions(String), SemanticTagSuggestionsLoaded { post_id: String, result: Result, String>, }, // Git GitRefresh, GitLoaded { repository_dir: PathBuf, result: Result, }, GitRemoteInputChanged(String), GitCommitMessageChanged(String), GitInitialize, GitSetRemote, GitCommit, GitFetch, GitPull, GitPush, GitPruneLfs, GitLocalFinished { repository_dir: PathBuf, operation: engine::git::GitOperation, result: Result, }, GitNetworkFinished { repository_dir: PathBuf, task_id: TaskId, operation: engine::git::GitOperation, result: Result, }, OpenGitFileDiff(String), OpenGitCommitDiff { hash: String, subject: String, }, SelectGitCommitFile { hash: String, change: engine::git::ChangedFile, }, GitDiffLoaded { repository_dir: PathBuf, tab_id: String, result: Result, }, GitFileHistoryLoaded { repository_dir: PathBuf, path: String, result: Result, String>, }, // Editor views PostEditor(PostEditorMsg), MediaEditor(MediaEditorMsg), TemplateEditor(TemplateEditorMsg), ScriptEditor(ScriptEditorMsg), Tags(TagsMsg), Settings(SettingsMsg), Style(StyleMsg), ImportEditor(ImportEditorMsg), MenuEditor(MenuEditorMsg), // Async sidebar data SidebarPostsLoaded(Vec), SidebarMediaLoaded { items: Vec, thumbs: HashMap>, }, SidebarPostsAppended(Vec), SidebarMediaAppended { items: Vec, thumbs: HashMap>, }, SidebarScrolled(f32), CreatePost, CreatePage, CreateMedia, CreateScript, CreateTemplate, CreateImport, // Conversational AI ChatCreate, ChatRenameInputChanged(String), ChatRename, ChatDelete(String), ChatModelChanged(String), ChatInputAction(iced::widget::text_editor::Action), ChatSend, ChatCancel, ChatLinkClicked(String), ChatSurfaceFieldChanged { surface_id: String, field: String, value: serde_json::Value, }, ChatSurfaceTextareaAction { surface_id: String, field: String, action: iced::widget::text_editor::Action, }, ChatSurfaceTabSelected { surface_id: String, index: usize, }, ChatSurfaceDismissed(String), ChatSurfaceAction { surface_id: String, action: String, payload: serde_json::Value, }, ChatFinished { conversation_id: String, result: Result, }, Noop, InitMenuBar, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SiteGenerationKind { Full, Validation, } #[derive(Debug, Clone)] struct SiteGenerationWorkflow { kind: SiteGenerationKind, db_path: PathBuf, project_id: String, data_dir: PathBuf, group_name: String, render_task_ids: Vec, index_task_id: Option, report: engine::generation::GenerationReport, } #[derive(Debug, Clone)] struct ImageDropRequest { post_id: String, project_id: String, data_dir: PathBuf, source_language: String, offline_mode: bool, path: PathBuf, } enum PersistedPostState { Canonical(Box), Translation(Box), } 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(Box::new(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(Box::new(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(Box::new(translation))) } Err(SqlError::NotFound) => { 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(Box::new(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::NotFound) => {} 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 post.status == PostStatus::Published { 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(Box::new(post))) } } enum PersistedMediaState { Canonical { media: Box, tags: Vec, }, Translation, } struct PreviewSession { project_id: String, handle: engine::preview::PreviewServerHandle, } struct EmbeddedPreviewState { controller: WebViewController, current_url: Option, creation_pending: bool, } fn should_start_embedded_preview_creation(active: bool, pending: bool) -> bool { !active && !pending } 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: Box::new(media), tags, }) } } fn default_post_editor_mode(settings_state: Option<&SettingsViewState>) -> &str { settings_state .map(|state| state.default_mode.as_str()) .unwrap_or("markdown") } fn replace_current_in_buffer( buffer: &mut bds_editor::EditorBuffer, query: &str, replacement: &str, ) -> bool { if query.is_empty() { return false; } if buffer.selected_text() != query && !buffer.find_next(query) { return false; } buffer.insert(replacement); let _ = buffer.find_next(query); true } 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 preview_base_url() -> String { format!( "http://{}:{}", engine::preview::PREVIEW_HOST, engine::preview::PREVIEW_PORT, ) } fn post_preview_url(post: &Post, language: &str, main_language: &str) -> String { let path = bds_core::render::build_canonical_post_path(post, language, main_language); format!( "{}{path}?draft=true&post_id={}", preview_base_url(), post.id ) } fn save_template_editor_state_impl( db: &Database, data_dir: &Path, project_id: &str, state: &TemplateEditorState, ) -> Result { engine::template::validate_template(&state.content)?; engine::template::update_template( db.conn(), data_dir, &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, data_dir: &Path, project_id: &str, state: &ScriptEditorState, ) -> Result { engine::script::validate_script_syntax(&state.content)?; engine::script::update_script( db.conn(), data_dir, &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> { [ engine::settings::set(db.conn(), "editor.default_mode", &state.default_mode), engine::settings::set(db.conn(), "editor.diff_view_style", &state.diff_view_style), engine::settings::set( db.conn(), "editor.wrap_long_lines", if state.wrap_long_lines { "true" } else { "false" }, ), engine::settings::set( db.conn(), "editor.hide_unchanged_regions", if state.hide_unchanged_regions { "true" } else { "false" }, ), ] .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)) } // ─────────────────────────────────────────────────────────── // 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