12964 lines
497 KiB
Rust
12964 lines
497 KiB
Rust
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<Project>),
|
|
SwitchProject(String),
|
|
RequestCreateProject,
|
|
RequestOpenProject,
|
|
CreateProject {
|
|
name: String,
|
|
data_path: Option<PathBuf>,
|
|
},
|
|
DeleteProject(String),
|
|
|
|
// Dialogs
|
|
FolderPicked(Option<PathBuf>),
|
|
ProjectFolderPicked(Option<PathBuf>),
|
|
MediaFilesPicked(Option<Vec<PathBuf>>),
|
|
GalleryImagesPicked {
|
|
post_id: String,
|
|
result: Result<Option<Vec<PathBuf>>, 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<Media, String>,
|
|
},
|
|
ImageDropEnriched {
|
|
task_id: TaskId,
|
|
post_id: String,
|
|
path: PathBuf,
|
|
result: Result<String, String>,
|
|
},
|
|
MediaImportFinished {
|
|
imported: Vec<Media>,
|
|
errors: Vec<String>,
|
|
},
|
|
MediaReplacementPicked {
|
|
media_id: String,
|
|
path: Option<PathBuf>,
|
|
},
|
|
MediaReplacementFinished {
|
|
media_id: String,
|
|
result: Result<Option<Media>, String>,
|
|
},
|
|
ImportUploadsPicked {
|
|
definition_id: String,
|
|
path: Option<PathBuf>,
|
|
},
|
|
ImportWxrPicked {
|
|
definition_id: String,
|
|
path: Option<PathBuf>,
|
|
},
|
|
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<engine::blogmark::BlogmarkImportResult, String>,
|
|
},
|
|
MainWindowLoaded(Option<window::Id>),
|
|
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<String>),
|
|
SetPostLanguageFilter(Option<String>),
|
|
SetPostCalendarYear(Option<i32>),
|
|
SetPostCalendarMonth(Option<u32>),
|
|
SetPostFromDate(String),
|
|
SetPostToDate(String),
|
|
TogglePostTagFilter(String),
|
|
TogglePostCategoryFilter(String),
|
|
ClearPostFilters,
|
|
MediaSearchChanged(String),
|
|
ToggleMediaFilterPanel,
|
|
SetMediaCalendarYear(Option<i32>),
|
|
SetMediaCalendarMonth(Option<u32>),
|
|
ToggleMediaTagFilter(String),
|
|
ClearMediaFilters,
|
|
|
|
// Modal
|
|
ShowModal(modal::ModalState),
|
|
DismissModal,
|
|
ConfirmModal(modal::ConfirmAction),
|
|
RemoteTargetChanged(String),
|
|
RemoteConnectRequested,
|
|
RemoteConnected(Result<(Arc<bds_server::DesktopClient>, Vec<Project>), String>),
|
|
RemoteProjectSelected(String),
|
|
RemoteOpenProjectRequested,
|
|
RemoteProjectOpened(Result<Project, String>),
|
|
RemoteDisconnectRequested,
|
|
FindQueryChanged(String),
|
|
ReplaceQueryChanged(String),
|
|
FindNext,
|
|
ReplaceCurrent,
|
|
ReplaceAll,
|
|
ToggleAiSuggestionField(usize, bool),
|
|
ApplyAiSuggestions(modal::AiEntityTarget, Vec<modal::AiSuggestionField>),
|
|
|
|
OneShotAiFinished {
|
|
entity_id: String,
|
|
action: OneShotAiAction,
|
|
result: Result<ai::OneShotResponse, String>,
|
|
},
|
|
|
|
// Blog actions (dispatched to engine)
|
|
RebuildDatabase,
|
|
ReindexText,
|
|
RegenerateCalendar,
|
|
ValidateTranslations,
|
|
TranslationValidationLoaded(
|
|
Result<engine::validate_translations::TranslationValidationReport, String>,
|
|
),
|
|
ValidateMedia,
|
|
GenerateSite,
|
|
RunMetadataDiff,
|
|
MetadataDiffLoaded(Result<engine::metadata_diff::DiffReport, String>),
|
|
RepairMetadataDiffItem {
|
|
index: usize,
|
|
direction: engine::metadata_diff::RepairDirection,
|
|
},
|
|
MetadataDiffItemRepaired(Result<(), String>),
|
|
RunSiteValidation,
|
|
ApplySiteValidation,
|
|
EngineTaskDone {
|
|
task_id: TaskId,
|
|
operation: &'static str,
|
|
label: String,
|
|
result: Result<String, String>,
|
|
},
|
|
SiteGenerationSectionDone {
|
|
group_id: String,
|
|
task_id: TaskId,
|
|
result: Result<engine::generation::GenerationReport, String>,
|
|
},
|
|
SiteGenerationIndexDone {
|
|
group_id: String,
|
|
task_id: TaskId,
|
|
result: Result<engine::generation::GenerationReport, String>,
|
|
},
|
|
SiteValidationLoaded(Result<engine::validate_site::SiteValidationReport, String>),
|
|
DuplicatesRefresh,
|
|
DuplicatesLoaded(Result<engine::embedding::DuplicateSearchResult, String>),
|
|
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<Vec<String>, String>,
|
|
},
|
|
|
|
// Git
|
|
GitRefresh,
|
|
GitLoaded {
|
|
repository_dir: PathBuf,
|
|
result: Result<GitSnapshot, String>,
|
|
},
|
|
GitRemoteInputChanged(String),
|
|
GitCommitMessageChanged(String),
|
|
GitInitialize,
|
|
GitSetRemote,
|
|
GitCommit,
|
|
GitFetch,
|
|
GitPull,
|
|
GitPush,
|
|
GitPruneLfs,
|
|
GitLocalFinished {
|
|
repository_dir: PathBuf,
|
|
operation: engine::git::GitOperation,
|
|
result: Result<GitSnapshot, String>,
|
|
},
|
|
GitNetworkFinished {
|
|
repository_dir: PathBuf,
|
|
task_id: TaskId,
|
|
operation: engine::git::GitOperation,
|
|
result: Result<GitNetworkCompletion, String>,
|
|
},
|
|
OpenGitFileDiff(String),
|
|
OpenGitCommitDiff {
|
|
hash: String,
|
|
subject: String,
|
|
},
|
|
SelectGitCommitFile {
|
|
hash: String,
|
|
change: engine::git::ChangedFile,
|
|
},
|
|
GitDiffLoaded {
|
|
repository_dir: PathBuf,
|
|
tab_id: String,
|
|
result: Result<GitDiffLoad, String>,
|
|
},
|
|
GitFileHistoryLoaded {
|
|
repository_dir: PathBuf,
|
|
path: String,
|
|
result: Result<Vec<engine::git::GitCommit>, 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<Post>),
|
|
SidebarMediaLoaded {
|
|
items: Vec<Media>,
|
|
thumbs: HashMap<String, Option<std::path::PathBuf>>,
|
|
},
|
|
SidebarPostsAppended(Vec<Post>),
|
|
SidebarMediaAppended {
|
|
items: Vec<Media>,
|
|
thumbs: HashMap<String, Option<std::path::PathBuf>>,
|
|
},
|
|
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<engine::chat::ChatTurnResult, String>,
|
|
},
|
|
|
|
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<TaskId>,
|
|
index_task_id: Option<TaskId>,
|
|
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<Post>),
|
|
Translation(Box<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<PersistedPostState, String> {
|
|
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<PersistedPostState, String> {
|
|
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<Media>,
|
|
tags: Vec<String>,
|
|
},
|
|
Translation,
|
|
}
|
|
|
|
struct PreviewSession {
|
|
project_id: String,
|
|
handle: engine::preview::PreviewServerHandle,
|
|
}
|
|
|
|
struct EmbeddedPreviewState {
|
|
controller: WebViewController,
|
|
current_url: Option<String>,
|
|
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<PersistedMediaState, String> {
|
|
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::<Vec<_>>();
|
|
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<String> {
|
|
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,
|
|
project_id: &str,
|
|
state: &TemplateEditorState,
|
|
) -> Result<Template, String> {
|
|
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<Script, String> {
|
|
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> {
|
|
[
|
|
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::<Result<Vec<_>, _>>()
|
|
.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<Database>,
|
|
db_path: PathBuf,
|
|
|
|
// Project
|
|
active_project: Option<Project>,
|
|
projects: Vec<Project>,
|
|
data_dir: Option<PathBuf>,
|
|
|
|
// Counts
|
|
post_count: usize,
|
|
media_count: usize,
|
|
|
|
// Sidebar data
|
|
sidebar_posts: Vec<Post>,
|
|
sidebar_media: Vec<Media>,
|
|
sidebar_scripts: Vec<Script>,
|
|
sidebar_templates: Vec<Template>,
|
|
sidebar_imports: Vec<ImportDefinition>,
|
|
chat_conversations: Vec<ChatConversation>,
|
|
sidebar_media_thumbs: HashMap<String, Option<std::path::PathBuf>>,
|
|
sidebar_posts_has_more: bool,
|
|
sidebar_media_has_more: bool,
|
|
sidebar_posts_loading_more: bool,
|
|
sidebar_media_loading_more: bool,
|
|
|
|
// Sidebar filters (per sidebar_views.allium PostsView / MediaView)
|
|
post_filter: PostFilter,
|
|
page_filter: PostFilter,
|
|
media_filter: MediaFilter,
|
|
|
|
// Navigation
|
|
sidebar_view: SidebarView,
|
|
sidebar_visible: bool,
|
|
sidebar_width: f32,
|
|
sidebar_dragging: bool,
|
|
|
|
// Tabs
|
|
tabs: Vec<Tab>,
|
|
active_tab: Option<String>,
|
|
|
|
// Panel
|
|
panel_visible: bool,
|
|
panel_tab: PanelTab,
|
|
|
|
// Tasks
|
|
task_manager: Arc<TaskManager>,
|
|
domain_events: engine::domain_events::EventSubscription,
|
|
chat_events: std::sync::mpsc::Receiver<engine::chat::ChatEvent>,
|
|
script_menu_actions: Arc<Mutex<Vec<MenuAction>>>,
|
|
task_snapshots: Vec<TaskSnapshot>,
|
|
collapsed_task_groups: HashSet<String>,
|
|
output_entries: Vec<OutputEntry>,
|
|
search_index_rebuild_required: bool,
|
|
search_index_rebuild_running: bool,
|
|
search_index_rebuild_task_id: Option<TaskId>,
|
|
site_generation_workflows: HashMap<String, SiteGenerationWorkflow>,
|
|
pending_image_drops: VecDeque<ImageDropRequest>,
|
|
image_drop_import_running: bool,
|
|
|
|
// Platform
|
|
_menu_bar: Option<muda::Menu>,
|
|
menu_registry: MenuRegistry,
|
|
native_edit_commands: native_edit::EditCommandQueue,
|
|
#[cfg(target_os = "macos")]
|
|
_lifecycle_handler: Option<objc2::rc::Retained<crate::platform::macos::BdsAppleEventHandler>>,
|
|
#[cfg(target_os = "macos")]
|
|
lifecycle_receiver: Option<std::sync::mpsc::Receiver<crate::platform::macos::LifecycleEvent>>,
|
|
|
|
// i18n
|
|
ui_locale: UiLocale,
|
|
/// Content/render language — the blog's main_language from project.json.
|
|
/// Separate from ui_locale per i18n.allium TwoLocaleAxes.
|
|
content_language: String,
|
|
/// All blog languages from project.json (for translation flags).
|
|
blog_languages: Vec<String>,
|
|
|
|
// Flags
|
|
offline_mode: bool,
|
|
locale_dropdown_open: bool,
|
|
project_dropdown_open: bool,
|
|
theme_badge: String,
|
|
|
|
// Toasts
|
|
toasts: Vec<Toast>,
|
|
|
|
// Modal
|
|
active_modal: Option<modal::ModalState>,
|
|
remote_client: Option<Arc<bds_server::DesktopClient>>,
|
|
remote_projects: Vec<Project>,
|
|
remote_project: Option<Project>,
|
|
remote_display_name: Option<String>,
|
|
remote_previous_locale: Option<UiLocale>,
|
|
|
|
// Local preview
|
|
preview_session: Option<PreviewSession>,
|
|
mcp_server: Option<engine::mcp::McpHttpServer>,
|
|
embedded_preview: Option<EmbeddedPreviewState>,
|
|
embedded_style_preview: Option<EmbeddedPreviewState>,
|
|
main_window_id: Option<window::Id>,
|
|
|
|
// Editor states (keyed by entity id)
|
|
post_editors: HashMap<String, PostEditorState>,
|
|
media_editors: HashMap<String, MediaEditorState>,
|
|
template_editors: HashMap<String, TemplateEditorState>,
|
|
script_editors: HashMap<String, ScriptEditorState>,
|
|
import_editors: HashMap<String, ImportEditorState>,
|
|
chat_editors: HashMap<String, ChatEditorState>,
|
|
tags_view_state: Option<TagsViewState>,
|
|
settings_state: Option<SettingsViewState>,
|
|
style_view_state: Option<StyleViewState>,
|
|
dashboard_state: Option<DashboardState>,
|
|
site_validation_state: SiteValidationState,
|
|
duplicates_state: DuplicatesState,
|
|
guide_documentation: DocumentationState,
|
|
api_documentation: DocumentationState,
|
|
cli_documentation: DocumentationState,
|
|
mcp_documentation: DocumentationState,
|
|
metadata_diff_state: MetadataDiffState,
|
|
menu_editor_state: MenuEditorState,
|
|
translation_validation_state: crate::views::translation_validation::TranslationValidationState,
|
|
git_state: GitUiState,
|
|
git_diffs: HashMap<String, GitDiffState>,
|
|
git_file_history: Vec<engine::git::GitCommit>,
|
|
git_file_history_target: Option<String>,
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────
|
|
// App Implementation
|
|
// ───────────────────────────────────────────────────────────
|
|
|
|
impl BdsApp {
|
|
pub fn new() -> (Self, Task<Message>) {
|
|
let os_locale = detect_os_locale();
|
|
|
|
// Open or create the database
|
|
let db_path = bds_core::util::application_database_path();
|
|
|
|
// Ensure parent directory exists
|
|
if let Some(parent) = db_path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
|
|
let db = Database::open(&db_path).ok();
|
|
if let Some(ref db) = db {
|
|
let _ = db.migrate();
|
|
}
|
|
let locale = db
|
|
.as_ref()
|
|
.and_then(|db| engine::settings::ui_language(db.conn()).ok().flatten())
|
|
.map(|language| normalize_language(&language))
|
|
.unwrap_or(os_locale);
|
|
let offline_mode = db
|
|
.as_ref()
|
|
.is_none_or(|db| engine::settings::airplane_mode(db.conn()).unwrap_or(true));
|
|
let (menu_bar, registry) = menu::build_menu_bar(locale);
|
|
let search_index_rebuild_required = db
|
|
.as_ref()
|
|
.is_some_and(|db| engine::search::prepare_search_index(db.conn()).unwrap_or(true));
|
|
let mcp_enabled = db.as_ref().is_some_and(|db| {
|
|
engine::settings::get(db.conn(), "mcp.http.enabled")
|
|
.ok()
|
|
.flatten()
|
|
.is_some_and(|value| value == "true")
|
|
});
|
|
let mcp_server = mcp_enabled
|
|
.then(|| {
|
|
engine::mcp::McpHttpServer::start(db_path.clone(), engine::mcp::DEFAULT_HTTP_PORT)
|
|
})
|
|
.and_then(Result::ok);
|
|
|
|
// Load projects
|
|
let projects = db
|
|
.as_ref()
|
|
.and_then(|d| engine::project::list_projects(d.conn()).ok())
|
|
.unwrap_or_default();
|
|
|
|
let active_project = db
|
|
.as_ref()
|
|
.and_then(|d| engine::project::get_active_project(d.conn()).ok())
|
|
.flatten();
|
|
|
|
let data_dir = active_project
|
|
.as_ref()
|
|
.and_then(|p| p.data_path.as_ref())
|
|
.map(PathBuf::from);
|
|
|
|
// If no projects exist, ensure the default project per spec
|
|
let init_task = if projects.is_empty() {
|
|
if let Some(ref db) = db {
|
|
let default_data = bds_core::util::default_project_data_dir();
|
|
match engine::project::ensure_default_project(db.conn(), Some(&default_data)) {
|
|
Ok(project) => Task::done(Message::ProjectsLoaded(vec![project])),
|
|
Err(_) => Task::none(),
|
|
}
|
|
} else {
|
|
Task::none()
|
|
}
|
|
} else {
|
|
Task::done(Message::ProjectsLoaded(projects.clone()))
|
|
};
|
|
|
|
// Chain menu initialization after project loading
|
|
// (must happen after the event loop has started for macOS)
|
|
let init_task = Task::batch([
|
|
init_task,
|
|
Task::done(Message::InitMenuBar),
|
|
window::get_oldest().map(Message::MainWindowLoaded),
|
|
]);
|
|
registry.set_enabled(MenuAction::Save, false);
|
|
registry.set_enabled(MenuAction::PublishSelected, false);
|
|
registry.set_enabled(MenuAction::PreviewPost, false);
|
|
registry.set_enabled(MenuAction::Find, false);
|
|
registry.set_enabled(MenuAction::Replace, false);
|
|
registry.set_enabled(MenuAction::OpenInBrowser, false);
|
|
registry.set_enabled(MenuAction::DisconnectServer, false);
|
|
let chat_conversations = db
|
|
.as_ref()
|
|
.and_then(|db| engine::chat::list_conversations(db.conn()).ok())
|
|
.unwrap_or_default();
|
|
#[cfg(target_os = "macos")]
|
|
let (lifecycle_handler, lifecycle_receiver) =
|
|
crate::platform::macos::install_lifecycle_handler()
|
|
.map(|(handler, receiver)| (Some(handler), Some(receiver)))
|
|
.unwrap_or((None, None));
|
|
|
|
(
|
|
Self {
|
|
db,
|
|
db_path,
|
|
active_project: active_project.clone(),
|
|
projects,
|
|
data_dir,
|
|
post_count: 0,
|
|
media_count: 0,
|
|
sidebar_posts: Vec::new(),
|
|
sidebar_media: Vec::new(),
|
|
sidebar_scripts: Vec::new(),
|
|
sidebar_templates: Vec::new(),
|
|
sidebar_imports: Vec::new(),
|
|
chat_conversations,
|
|
sidebar_media_thumbs: HashMap::new(),
|
|
sidebar_posts_has_more: false,
|
|
sidebar_media_has_more: false,
|
|
sidebar_posts_loading_more: false,
|
|
sidebar_media_loading_more: false,
|
|
post_filter: PostFilter::default(),
|
|
page_filter: PostFilter::default(),
|
|
media_filter: MediaFilter::default(),
|
|
sidebar_view: SidebarView::Posts,
|
|
sidebar_visible: true,
|
|
sidebar_width: 280.0,
|
|
sidebar_dragging: false,
|
|
tabs: Vec::new(),
|
|
active_tab: None,
|
|
panel_visible: false,
|
|
panel_tab: PanelTab::Tasks,
|
|
task_manager: Arc::new(TaskManager::default()),
|
|
domain_events: engine::domain_events::subscribe(),
|
|
chat_events: engine::chat::subscribe_events(),
|
|
script_menu_actions: Arc::new(Mutex::new(Vec::new())),
|
|
task_snapshots: Vec::new(),
|
|
collapsed_task_groups: HashSet::new(),
|
|
output_entries: Vec::new(),
|
|
search_index_rebuild_required,
|
|
search_index_rebuild_running: false,
|
|
search_index_rebuild_task_id: None,
|
|
site_generation_workflows: HashMap::new(),
|
|
pending_image_drops: VecDeque::new(),
|
|
image_drop_import_running: false,
|
|
_menu_bar: Some(menu_bar),
|
|
menu_registry: registry,
|
|
native_edit_commands: native_edit::command_queue(),
|
|
#[cfg(target_os = "macos")]
|
|
_lifecycle_handler: lifecycle_handler,
|
|
#[cfg(target_os = "macos")]
|
|
lifecycle_receiver,
|
|
ui_locale: locale,
|
|
content_language: "en".to_string(),
|
|
blog_languages: Vec::new(),
|
|
offline_mode,
|
|
locale_dropdown_open: false,
|
|
project_dropdown_open: false,
|
|
theme_badge: String::from("pico"),
|
|
toasts: Vec::new(),
|
|
active_modal: search_index_rebuild_required
|
|
.then_some(modal::ModalState::SearchIndexRepair),
|
|
remote_client: None,
|
|
remote_projects: Vec::new(),
|
|
remote_project: None,
|
|
remote_display_name: None,
|
|
remote_previous_locale: None,
|
|
preview_session: None,
|
|
mcp_server,
|
|
embedded_preview: None,
|
|
embedded_style_preview: None,
|
|
main_window_id: None,
|
|
post_editors: HashMap::new(),
|
|
media_editors: HashMap::new(),
|
|
template_editors: HashMap::new(),
|
|
script_editors: HashMap::new(),
|
|
import_editors: HashMap::new(),
|
|
chat_editors: HashMap::new(),
|
|
tags_view_state: None,
|
|
settings_state: None,
|
|
style_view_state: None,
|
|
dashboard_state: None,
|
|
site_validation_state: SiteValidationState::default(),
|
|
duplicates_state: DuplicatesState::default(),
|
|
guide_documentation: DocumentationState::new(DocumentationKind::Guide),
|
|
api_documentation: DocumentationState::new(DocumentationKind::Api),
|
|
cli_documentation: DocumentationState::new(DocumentationKind::Cli),
|
|
mcp_documentation: DocumentationState::new(DocumentationKind::Mcp),
|
|
metadata_diff_state: MetadataDiffState::default(),
|
|
menu_editor_state: MenuEditorState::default(),
|
|
translation_validation_state: Default::default(),
|
|
git_state: GitUiState::default(),
|
|
git_diffs: HashMap::new(),
|
|
git_file_history: Vec::new(),
|
|
git_file_history_target: None,
|
|
},
|
|
init_task,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn new_for_tests(db: Database, project: Project, data_dir: PathBuf) -> Self {
|
|
let chat_conversations = engine::chat::list_conversations(db.conn()).unwrap_or_default();
|
|
let offline_mode = engine::settings::airplane_mode(db.conn()).unwrap_or(true);
|
|
Self {
|
|
db: Some(db),
|
|
db_path: data_dir.join("bds.db"),
|
|
active_project: Some(project.clone()),
|
|
projects: vec![project],
|
|
data_dir: Some(data_dir),
|
|
post_count: 0,
|
|
media_count: 0,
|
|
sidebar_posts: Vec::new(),
|
|
sidebar_media: Vec::new(),
|
|
sidebar_scripts: Vec::new(),
|
|
sidebar_templates: Vec::new(),
|
|
sidebar_imports: Vec::new(),
|
|
chat_conversations,
|
|
sidebar_media_thumbs: HashMap::new(),
|
|
sidebar_posts_has_more: false,
|
|
sidebar_media_has_more: false,
|
|
sidebar_posts_loading_more: false,
|
|
sidebar_media_loading_more: false,
|
|
post_filter: PostFilter::default(),
|
|
page_filter: PostFilter::default(),
|
|
media_filter: MediaFilter::default(),
|
|
sidebar_view: SidebarView::Posts,
|
|
sidebar_visible: true,
|
|
sidebar_width: 280.0,
|
|
sidebar_dragging: false,
|
|
tabs: Vec::new(),
|
|
active_tab: None,
|
|
panel_visible: false,
|
|
panel_tab: PanelTab::Tasks,
|
|
task_manager: Arc::new(TaskManager::default()),
|
|
domain_events: engine::domain_events::subscribe(),
|
|
chat_events: engine::chat::subscribe_events(),
|
|
script_menu_actions: Arc::new(Mutex::new(Vec::new())),
|
|
task_snapshots: Vec::new(),
|
|
collapsed_task_groups: HashSet::new(),
|
|
output_entries: Vec::new(),
|
|
search_index_rebuild_required: false,
|
|
search_index_rebuild_running: false,
|
|
search_index_rebuild_task_id: None,
|
|
site_generation_workflows: HashMap::new(),
|
|
pending_image_drops: VecDeque::new(),
|
|
image_drop_import_running: false,
|
|
_menu_bar: None,
|
|
menu_registry: MenuRegistry::empty(),
|
|
native_edit_commands: native_edit::command_queue(),
|
|
#[cfg(target_os = "macos")]
|
|
_lifecycle_handler: None,
|
|
#[cfg(target_os = "macos")]
|
|
lifecycle_receiver: None,
|
|
ui_locale: UiLocale::En,
|
|
content_language: "en".to_string(),
|
|
blog_languages: Vec::new(),
|
|
offline_mode,
|
|
locale_dropdown_open: false,
|
|
project_dropdown_open: false,
|
|
theme_badge: String::from("pico"),
|
|
toasts: Vec::new(),
|
|
active_modal: None,
|
|
remote_client: None,
|
|
remote_projects: Vec::new(),
|
|
remote_project: None,
|
|
remote_display_name: None,
|
|
remote_previous_locale: None,
|
|
preview_session: None,
|
|
mcp_server: None,
|
|
embedded_preview: None,
|
|
embedded_style_preview: None,
|
|
main_window_id: None,
|
|
post_editors: HashMap::new(),
|
|
media_editors: HashMap::new(),
|
|
template_editors: HashMap::new(),
|
|
script_editors: HashMap::new(),
|
|
import_editors: HashMap::new(),
|
|
chat_editors: HashMap::new(),
|
|
tags_view_state: None,
|
|
settings_state: None,
|
|
style_view_state: None,
|
|
dashboard_state: None,
|
|
site_validation_state: SiteValidationState::default(),
|
|
duplicates_state: DuplicatesState::default(),
|
|
guide_documentation: DocumentationState::new(DocumentationKind::Guide),
|
|
api_documentation: DocumentationState::new(DocumentationKind::Api),
|
|
cli_documentation: DocumentationState::new(DocumentationKind::Cli),
|
|
mcp_documentation: DocumentationState::new(DocumentationKind::Mcp),
|
|
metadata_diff_state: MetadataDiffState::default(),
|
|
menu_editor_state: MenuEditorState::default(),
|
|
translation_validation_state: Default::default(),
|
|
git_state: GitUiState::default(),
|
|
git_diffs: HashMap::new(),
|
|
git_file_history: Vec::new(),
|
|
git_file_history_target: None,
|
|
}
|
|
}
|
|
|
|
pub fn update(&mut self, message: Message) -> Task<Message> {
|
|
match message {
|
|
// ── Menu event dispatch ──
|
|
Message::MenuEvent(id) => {
|
|
if let Some(action) = self.menu_registry.lookup(&id) {
|
|
return self.dispatch_menu_action(action);
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
// ── Navigation ──
|
|
Message::SetActiveView(view) => {
|
|
let (new_view, new_visible) =
|
|
handle_activity_click(self.sidebar_view, self.sidebar_visible, view);
|
|
let old_view = self.sidebar_view;
|
|
self.sidebar_view = new_view;
|
|
self.sidebar_visible = new_visible;
|
|
// When switching to/from Posts/Pages, re-query with correct filter
|
|
let needs_post_refresh = old_view != new_view
|
|
&& matches!(new_view, SidebarView::Posts | SidebarView::Pages);
|
|
if needs_post_refresh {
|
|
self.refresh_sidebar_posts()
|
|
} else if new_view == SidebarView::Git && new_visible {
|
|
self.refresh_git()
|
|
} else if new_view == SidebarView::Chat && new_visible {
|
|
self.refresh_chat_conversations();
|
|
Task::none()
|
|
} else {
|
|
Task::none()
|
|
}
|
|
}
|
|
Message::ToggleSidebar => {
|
|
self.sidebar_visible = !self.sidebar_visible;
|
|
Task::none()
|
|
}
|
|
Message::TogglePanel => {
|
|
self.panel_visible = !self.panel_visible;
|
|
Task::none()
|
|
}
|
|
Message::CreatePost => self.create_sidebar_post(false),
|
|
Message::CreatePage => self.create_sidebar_post(true),
|
|
Message::CreateMedia => crate::platform::dialog::pick_media_files(
|
|
t(self.ui_locale, "dialog.importMedia"),
|
|
t(self.ui_locale, "dialog.imageFilter"),
|
|
),
|
|
Message::CreateScript => self.create_sidebar_script(),
|
|
Message::CreateTemplate => self.create_sidebar_template(),
|
|
Message::CreateImport => self.create_sidebar_import(),
|
|
Message::ChatCreate => self.create_chat_conversation(),
|
|
Message::ChatRenameInputChanged(value) => {
|
|
if let Some(state) = self.active_chat_state_mut() {
|
|
state.rename_input = value;
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ChatRename => {
|
|
let Some(id) = self.active_chat_id().map(str::to_string) else {
|
|
return Task::none();
|
|
};
|
|
let Some(title) = self
|
|
.chat_editors
|
|
.get(&id)
|
|
.map(|state| state.rename_input.clone())
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let result = self
|
|
.db
|
|
.as_ref()
|
|
.ok_or_else(|| "database unavailable".to_string())
|
|
.and_then(|db| {
|
|
engine::chat::rename_conversation(db.conn(), &id, &title)
|
|
.map_err(|error| error.to_string())
|
|
});
|
|
match result {
|
|
Ok(conversation) => {
|
|
if let Some(state) = self.chat_editors.get_mut(&id) {
|
|
state.conversation = conversation.clone();
|
|
state.rename_input = conversation.title.clone();
|
|
}
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
|
tab.title = conversation.title.clone();
|
|
}
|
|
self.refresh_chat_conversations();
|
|
}
|
|
Err(error) => self.notify(ToastLevel::Error, &error),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ChatDelete(id) => {
|
|
let result = self
|
|
.db
|
|
.as_ref()
|
|
.ok_or_else(|| "database unavailable".to_string())
|
|
.and_then(|db| {
|
|
engine::chat::delete_conversation(db.conn(), &id)
|
|
.map_err(|error| error.to_string())
|
|
});
|
|
match result {
|
|
Ok(()) => {
|
|
self.chat_editors.remove(&id);
|
|
if let Some(next) = tabs::close_tab(&mut self.tabs, &id) {
|
|
self.active_tab = self.tabs.get(next).map(|tab| tab.id.clone());
|
|
} else {
|
|
self.active_tab = None;
|
|
}
|
|
self.refresh_chat_conversations();
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "chat.deleted"));
|
|
}
|
|
Err(error) => self.notify(ToastLevel::Error, &error),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ChatModelChanged(model) => {
|
|
let Some(id) = self.active_chat_id().map(str::to_string) else {
|
|
return Task::none();
|
|
};
|
|
let result = self
|
|
.db
|
|
.as_ref()
|
|
.ok_or_else(|| "database unavailable".to_string())
|
|
.and_then(|db| {
|
|
engine::chat::set_conversation_model(db.conn(), &id, &model)
|
|
.map_err(|error| error.to_string())
|
|
});
|
|
match result {
|
|
Ok(()) => {
|
|
if let Some(state) = self.chat_editors.get_mut(&id) {
|
|
state.conversation.model = Some(model);
|
|
}
|
|
self.refresh_chat_conversations();
|
|
}
|
|
Err(error) => self.notify(ToastLevel::Error, &error),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ChatInputAction(action) => {
|
|
if let Some(state) = self.active_chat_state_mut() {
|
|
state.input.perform(action);
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ChatSend => self.send_active_chat_message(),
|
|
Message::ChatCancel => {
|
|
if let Some(id) = self.active_chat_id() {
|
|
engine::chat::cancel_chat(id);
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ChatLinkClicked(url) => {
|
|
if url.starts_with("https://") || url.starts_with("http://") {
|
|
if let Err(error) = open::that_detached(&url) {
|
|
self.notify(ToastLevel::Error, &error.to_string());
|
|
}
|
|
} else {
|
|
self.notify(ToastLevel::Error, &t(self.ui_locale, "chat.link.refused"));
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ChatSurfaceFieldChanged {
|
|
surface_id,
|
|
field,
|
|
value,
|
|
} => {
|
|
if let Some(state) = self.active_chat_state_mut() {
|
|
state
|
|
.surface_state
|
|
.surface_data
|
|
.entry(surface_id)
|
|
.or_default()
|
|
.insert(field, value);
|
|
state.surface_state_dirty_since = Some(std::time::Instant::now());
|
|
state.rebuild_surfaces();
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ChatSurfaceTextareaAction {
|
|
surface_id,
|
|
field,
|
|
action,
|
|
} => {
|
|
if let Some(state) = self.active_chat_state_mut()
|
|
&& let Some(content) = state
|
|
.surface_textareas
|
|
.get_mut(&crate::views::chat_view::textarea_key(&surface_id, &field))
|
|
{
|
|
content.perform(action);
|
|
let value = content.text();
|
|
state
|
|
.surface_state
|
|
.surface_data
|
|
.entry(surface_id)
|
|
.or_default()
|
|
.insert(field, value.into());
|
|
state.surface_state_dirty_since = Some(std::time::Instant::now());
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ChatSurfaceTabSelected { surface_id, index } => {
|
|
let Some(conversation_id) = self.active_chat_id().map(str::to_string) else {
|
|
return Task::none();
|
|
};
|
|
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
|
|
state.surface_state.surface_tabs.insert(surface_id, index);
|
|
state.rebuild_surfaces();
|
|
}
|
|
self.persist_chat_surface_state(&conversation_id);
|
|
Task::none()
|
|
}
|
|
Message::ChatSurfaceDismissed(surface_id) => {
|
|
let Some(conversation_id) = self.active_chat_id().map(str::to_string) else {
|
|
return Task::none();
|
|
};
|
|
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
|
|
state.surface_state.dismissed_surfaces.insert(surface_id);
|
|
state.rebuild_surfaces();
|
|
}
|
|
self.persist_chat_surface_state(&conversation_id);
|
|
Task::none()
|
|
}
|
|
Message::ChatSurfaceAction {
|
|
surface_id,
|
|
action,
|
|
payload,
|
|
} => {
|
|
let result = self
|
|
.active_chat_id()
|
|
.and_then(|id| self.chat_editors.get(id))
|
|
.map(|state| {
|
|
let payload = engine::chat_surfaces::merge_form_data(
|
|
payload,
|
|
&surface_id,
|
|
&state.surface_state,
|
|
);
|
|
engine::chat_surfaces::resolve_surface_action(&action, &payload)
|
|
});
|
|
match result {
|
|
Some(Ok(navigation)) => self.dispatch_chat_navigation(
|
|
&navigation.destination,
|
|
navigation.entity_id.as_deref(),
|
|
),
|
|
Some(Err(reason)) => {
|
|
let message = tw(
|
|
self.ui_locale,
|
|
"chat.surface.actionRefused",
|
|
&[("reason", &reason)],
|
|
);
|
|
if let Some(state) = self.active_chat_state_mut() {
|
|
state.error = Some(message.clone());
|
|
}
|
|
self.notify(ToastLevel::Error, &message);
|
|
}
|
|
None => {}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ChatFinished {
|
|
conversation_id,
|
|
result,
|
|
} => {
|
|
let locale = self.ui_locale;
|
|
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
|
|
state.streaming = false;
|
|
state.clear_streaming();
|
|
state.active_tool = None;
|
|
match result {
|
|
Ok(_) => state.error = None,
|
|
Err(error) => state.error = Some(localize_chat_error(locale, &error)),
|
|
}
|
|
if let Some(db) = &self.db {
|
|
state.set_messages(
|
|
engine::chat::list_messages(db.conn(), &conversation_id)
|
|
.unwrap_or_default(),
|
|
);
|
|
if let Ok(conversation) =
|
|
engine::chat::get_conversation(db.conn(), &conversation_id)
|
|
{
|
|
state.conversation = conversation;
|
|
}
|
|
}
|
|
}
|
|
self.refresh_chat_conversations();
|
|
self.refresh_counts()
|
|
}
|
|
Message::OpenSettingsSection(section) => {
|
|
self.sidebar_view = SidebarView::Settings;
|
|
self.sidebar_visible = true;
|
|
self.open_singleton_tab(TabType::Settings, "common.settings");
|
|
if self.settings_state.is_none() {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
}
|
|
if let Some(state) = self.settings_state.as_mut() {
|
|
state.focus_section(section);
|
|
}
|
|
self.sync_menu_state();
|
|
Task::none()
|
|
}
|
|
Message::SidebarResizeStart => {
|
|
self.sidebar_dragging = true;
|
|
Task::none()
|
|
}
|
|
Message::SidebarResizeMove(x) => {
|
|
if self.sidebar_dragging {
|
|
// x is global cursor position; subtract activity bar width (~48px)
|
|
let effective = x - 48.0;
|
|
self.sidebar_width = effective.clamp(200.0, 500.0);
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::SidebarResizeEnd => {
|
|
self.sidebar_dragging = false;
|
|
Task::none()
|
|
}
|
|
|
|
// ── Tabs ──
|
|
Message::OpenTab(tab) => self.open_tab(tab),
|
|
Message::CloseTab(id) => {
|
|
if self.active_tab.as_deref() == Some(id.as_str()) {
|
|
self.flush_active_post_editor();
|
|
}
|
|
if let Some(next_idx) = tabs::close_tab(&mut self.tabs, &id) {
|
|
self.active_tab = self.tabs.get(next_idx).map(|t| t.id.clone());
|
|
} else {
|
|
self.active_tab = None;
|
|
}
|
|
self.git_diffs.remove(&id);
|
|
self.enforce_panel_tab_fallback();
|
|
self.sync_menu_state();
|
|
self.sync_embedded_previews()
|
|
}
|
|
Message::SelectTab(id) => {
|
|
if self.tabs.iter().any(|t| t.id == id) {
|
|
if self.active_tab.as_deref() != Some(id.as_str()) {
|
|
self.flush_active_post_editor();
|
|
}
|
|
self.active_tab = Some(id.clone());
|
|
}
|
|
self.enforce_panel_tab_fallback();
|
|
let semantic_task = self
|
|
.tabs
|
|
.iter()
|
|
.find(|tab| tab.id == id && tab.tab_type == TabType::Post)
|
|
.map_or_else(Task::none, |_| {
|
|
Task::done(Message::LoadSemanticTagSuggestions(id))
|
|
});
|
|
Task::batch([self.sync_embedded_previews(), semantic_task])
|
|
}
|
|
// ── Project management ──
|
|
Message::ProjectsLoaded(projects) => {
|
|
self.projects = projects;
|
|
if let Some(ref db) = self.db {
|
|
self.active_project = engine::project::get_active_project(db.conn())
|
|
.ok()
|
|
.flatten();
|
|
self.data_dir = self
|
|
.active_project
|
|
.as_ref()
|
|
.and_then(|p| p.data_path.as_ref())
|
|
.map(PathBuf::from);
|
|
}
|
|
// Per metadata.allium StartupSync: sync metadata from filesystem
|
|
if let Some(data_dir) = self.data_dir.clone() {
|
|
if let Err(e) = engine::meta::startup_sync(&data_dir) {
|
|
let message =
|
|
self.operation_failed_text("common.metadataSync", e.to_string());
|
|
self.add_output(&message);
|
|
}
|
|
// Extract content language from project metadata
|
|
if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
|
|
let main_lang = meta.main_language.unwrap_or_else(|| "en".to_string());
|
|
self.content_language = main_lang.clone();
|
|
self.blog_languages = meta.blog_languages;
|
|
if !self.blog_languages.contains(&main_lang) {
|
|
self.blog_languages.insert(0, main_lang);
|
|
}
|
|
}
|
|
}
|
|
let sidebar_task = self.refresh_counts();
|
|
let documentation_task = self.reload_changed_documentation();
|
|
let menu_editor_task = if self
|
|
.tabs
|
|
.iter()
|
|
.any(|tab| tab.tab_type == TabType::MenuEditor)
|
|
{
|
|
Task::done(Message::MenuEditor(MenuEditorMsg::Reload))
|
|
} else {
|
|
self.menu_editor_state = MenuEditorState::default();
|
|
Task::none()
|
|
};
|
|
self.sync_menu_state();
|
|
Task::batch([
|
|
sidebar_task,
|
|
Task::done(Message::EmbeddingBackfill),
|
|
documentation_task,
|
|
menu_editor_task,
|
|
])
|
|
}
|
|
Message::SwitchProject(project_id) => {
|
|
self.project_dropdown_open = false;
|
|
if let Some(ref db) = self.db {
|
|
if let (Some(outgoing), Some(data_dir)) = (&self.active_project, &self.data_dir)
|
|
{
|
|
let _ =
|
|
engine::embedding::EmbeddingService::production(db.conn(), data_dir)
|
|
.flush_project(&outgoing.id);
|
|
}
|
|
match engine::project::set_active_project(db.conn(), &project_id) {
|
|
Ok(()) => {
|
|
self.reset_git_for_project_change();
|
|
self.active_project =
|
|
self.projects.iter().find(|p| p.id == project_id).cloned();
|
|
self.preview_session = None;
|
|
self.duplicates_state = DuplicatesState::default();
|
|
self.hide_embedded_preview();
|
|
self.hide_embedded_style_preview();
|
|
self.style_view_state = None;
|
|
self.data_dir = self
|
|
.active_project
|
|
.as_ref()
|
|
.and_then(|p| p.data_path.as_ref())
|
|
.map(PathBuf::from);
|
|
// Per metadata.allium StartupSync
|
|
if let Some(data_dir) = self.data_dir.clone() {
|
|
let _ = engine::meta::startup_sync(&data_dir);
|
|
if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
|
|
let main_lang =
|
|
meta.main_language.unwrap_or_else(|| "en".to_string());
|
|
self.content_language = main_lang.clone();
|
|
self.blog_languages = meta.blog_languages;
|
|
if !self.blog_languages.contains(&main_lang) {
|
|
self.blog_languages.insert(0, main_lang);
|
|
}
|
|
}
|
|
}
|
|
if self.tabs.iter().any(|tab| tab.tab_type == TabType::Style) {
|
|
self.style_view_state = self.hydrate_style_state();
|
|
}
|
|
let name = self
|
|
.active_project
|
|
.as_ref()
|
|
.map(|p| p.name.clone())
|
|
.unwrap_or_default();
|
|
self.notify(
|
|
ToastLevel::Success,
|
|
&tw(
|
|
self.ui_locale,
|
|
"projectSelector.toast.switched",
|
|
&[("name", &name)],
|
|
),
|
|
);
|
|
}
|
|
Err(_) => {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "projectSelector.toast.switchFailed"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
self.sync_menu_state();
|
|
Task::batch([
|
|
self.refresh_counts(),
|
|
self.refresh_git_if_visible(),
|
|
Task::done(Message::EmbeddingBackfill),
|
|
self.sync_embedded_previews(),
|
|
])
|
|
}
|
|
Message::RequestCreateProject => {
|
|
crate::platform::dialog::pick_folder(t(self.ui_locale, "dialog.selectFolder"))
|
|
}
|
|
Message::RequestOpenProject => crate::platform::dialog::pick_project_folder(t(
|
|
self.ui_locale,
|
|
"dialog.openProject",
|
|
)),
|
|
Message::CreateProject { name, data_path } => {
|
|
if let Some(ref db) = self.db {
|
|
let path_str = data_path.as_ref().map(|p| p.to_string_lossy().to_string());
|
|
match engine::project::create_project(db.conn(), &name, path_str.as_deref()) {
|
|
Ok(project) => {
|
|
let _ = engine::project::set_active_project(db.conn(), &project.id);
|
|
self.projects =
|
|
engine::project::list_projects(db.conn()).unwrap_or_default();
|
|
self.reset_git_for_project_change();
|
|
self.active_project = Some(project.clone());
|
|
self.preview_session = None;
|
|
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
|
|
let msg = tw(
|
|
self.ui_locale,
|
|
"projectSelector.toast.created",
|
|
&[("name", &project.name)],
|
|
);
|
|
self.notify(ToastLevel::Success, &msg);
|
|
}
|
|
Err(_) => {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "projectSelector.toast.createFailed"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
self.refresh_git_if_visible()
|
|
}
|
|
Message::DeleteProject(project_id) => {
|
|
if let Some(ref db) = self.db {
|
|
let data_path = self
|
|
.projects
|
|
.iter()
|
|
.find(|p| p.id == project_id)
|
|
.and_then(|p| p.data_path.as_ref())
|
|
.map(PathBuf::from);
|
|
match engine::project::delete_project(
|
|
db.conn(),
|
|
&project_id,
|
|
data_path.as_deref(),
|
|
) {
|
|
Ok(()) => {
|
|
self.projects.retain(|p| p.id != project_id);
|
|
}
|
|
Err(_) => {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "projectSelector.toast.deleteFailed"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
// ── Dialogs ──
|
|
Message::FolderPicked(path) => {
|
|
if let Some(path) = path {
|
|
let name = path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "New Project".to_string());
|
|
return Task::done(Message::CreateProject {
|
|
name,
|
|
data_path: Some(path),
|
|
});
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ProjectFolderPicked(path) => {
|
|
if let (Some(path), Some(db)) = (path, self.db.as_ref()) {
|
|
match engine::project::open_project(db.conn(), &path) {
|
|
Ok(project) => {
|
|
let _ = engine::project::set_active_project(db.conn(), &project.id);
|
|
self.projects =
|
|
engine::project::list_projects(db.conn()).unwrap_or_default();
|
|
self.reset_git_for_project_change();
|
|
self.active_project = Some(project.clone());
|
|
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
|
|
self.preview_session = None;
|
|
self.hide_embedded_preview();
|
|
self.hide_embedded_style_preview();
|
|
self.style_view_state = None;
|
|
self.notify(
|
|
ToastLevel::Success,
|
|
&tw(
|
|
self.ui_locale,
|
|
"projectSelector.toast.opened",
|
|
&[("name", &project.name)],
|
|
),
|
|
);
|
|
self.sync_menu_state();
|
|
return Task::batch([
|
|
self.refresh_counts(),
|
|
self.refresh_git_if_visible(),
|
|
]);
|
|
}
|
|
Err(error) => self.notify(
|
|
ToastLevel::Error,
|
|
&tw(
|
|
self.ui_locale,
|
|
"projectSelector.toast.openFailed",
|
|
&[("error", &error.to_string())],
|
|
),
|
|
),
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::FileDropped(path) => self.enqueue_image_drop(path),
|
|
Message::ImageDropImported {
|
|
task_id,
|
|
post_id,
|
|
project_id,
|
|
data_dir,
|
|
source_language,
|
|
offline_mode,
|
|
path,
|
|
result,
|
|
} => self.finish_image_drop_import(
|
|
task_id,
|
|
ImageDropRequest {
|
|
post_id,
|
|
project_id,
|
|
data_dir,
|
|
source_language,
|
|
offline_mode,
|
|
path,
|
|
},
|
|
result,
|
|
),
|
|
Message::ImageDropEnriched {
|
|
task_id,
|
|
post_id,
|
|
path,
|
|
result,
|
|
} => self.finish_image_drop_enrichment(task_id, &post_id, &path, result),
|
|
Message::MediaFilesPicked(paths) => {
|
|
let (Some(paths), Some(project), Some(data_dir)) =
|
|
(paths, self.active_project.as_ref(), self.data_dir.as_ref())
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let db_path = self.db_path.clone();
|
|
let project_id = project.id.clone();
|
|
let data_dir = data_dir.clone();
|
|
let language = self.content_language.clone();
|
|
let concurrency = engine::meta::read_project_json(&data_dir)
|
|
.map(|metadata| metadata.image_import_concurrency.clamp(1, 8) as usize)
|
|
.unwrap_or(4);
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let results = match engine::gallery_import::process_paths_concurrently(
|
|
paths,
|
|
concurrency,
|
|
|_index, path| {
|
|
let original_name = path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "image".to_string());
|
|
let db = Database::open(&db_path)
|
|
.map_err(|error| format!("{}: {error}", path.display()))?;
|
|
engine::media::import_media(
|
|
db.conn(),
|
|
&data_dir,
|
|
&project_id,
|
|
&path,
|
|
&original_name,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(&language),
|
|
Vec::new(),
|
|
)
|
|
.map_err(|error| format!("{}: {error}", path.display()))
|
|
},
|
|
) {
|
|
Ok(results) => results,
|
|
Err(error) => return (Vec::new(), vec![error]),
|
|
};
|
|
let mut imported = Vec::new();
|
|
let mut errors = Vec::new();
|
|
for result in results {
|
|
match result {
|
|
Ok(media) => imported.push(media),
|
|
Err(error) => errors.push(error),
|
|
}
|
|
}
|
|
(imported, errors)
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| (Vec::new(), vec![error.to_string()]))
|
|
},
|
|
|(imported, errors)| Message::MediaImportFinished { imported, errors },
|
|
)
|
|
}
|
|
Message::GalleryImagesPicked { post_id, result } => match result {
|
|
Ok(Some(paths)) if !paths.is_empty() => {
|
|
let (Some(project), Some(data_dir)) =
|
|
(self.active_project.as_ref(), self.data_dir.as_ref())
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let db_path = self.db_path.clone();
|
|
let data_dir = data_dir.clone();
|
|
let project_id = project.id.clone();
|
|
let source_language = self.content_language.clone();
|
|
let offline_mode = self.offline_mode;
|
|
let result_post_id = post_id.clone();
|
|
let failed_paths = paths.clone();
|
|
let selected_count = paths.len();
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
engine::gallery_import::import_gallery_images(
|
|
&db_path,
|
|
&data_dir,
|
|
&project_id,
|
|
&post_id,
|
|
paths,
|
|
&source_language,
|
|
offline_mode,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| {
|
|
engine::gallery_import::GalleryImportReport {
|
|
selected_count,
|
|
outcomes: failed_paths
|
|
.into_iter()
|
|
.map(|path| engine::gallery_import::GalleryImportOutcome {
|
|
path,
|
|
result: Err(error.to_string()),
|
|
})
|
|
.collect(),
|
|
}
|
|
})
|
|
},
|
|
move |report| Message::GalleryImportFinished {
|
|
post_id: result_post_id.clone(),
|
|
report,
|
|
},
|
|
)
|
|
}
|
|
Ok(_) => Task::none(),
|
|
Err(error) => {
|
|
self.add_output(&tw(
|
|
self.ui_locale,
|
|
"editor.galleryPickerFailed",
|
|
&[("error", &error)],
|
|
));
|
|
Task::none()
|
|
}
|
|
},
|
|
Message::GalleryImportFinished { post_id, report } => {
|
|
for outcome in report.outcomes {
|
|
match outcome.result {
|
|
Ok(image) => self.add_output(&tw(
|
|
self.ui_locale,
|
|
"editor.galleryImageAdded",
|
|
&[("title", &image.title)],
|
|
)),
|
|
Err(error) => {
|
|
let path = outcome
|
|
.path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| outcome.path.display().to_string());
|
|
self.add_output(&tw(
|
|
self.ui_locale,
|
|
"editor.galleryImageFailed",
|
|
&[("path", &path), ("error", &error)],
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(state) = self.post_editors.get_mut(&post_id) {
|
|
state.insert_markdown_at_cursor("\n[[gallery]]\n");
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post_id) {
|
|
tab.is_dirty = true;
|
|
}
|
|
if let Err(error) = self.persist_post_editor_state(&post_id) {
|
|
self.notify_operation_failed("common.save", error);
|
|
}
|
|
self.refresh_post_relationships(&post_id);
|
|
}
|
|
self.add_output(&tw(
|
|
self.ui_locale,
|
|
"editor.galleryImportComplete",
|
|
&[("count", &report.selected_count.to_string())],
|
|
));
|
|
self.refresh_sidebar_media()
|
|
}
|
|
Message::MediaImportFinished { imported, errors } => {
|
|
if !imported.is_empty() {
|
|
self.sidebar_view = SidebarView::Media;
|
|
self.notify(
|
|
ToastLevel::Success,
|
|
&tw(
|
|
self.ui_locale,
|
|
"media.toast.imported",
|
|
&[("count", &imported.len().to_string())],
|
|
),
|
|
);
|
|
}
|
|
if !errors.is_empty() {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&tw(
|
|
self.ui_locale,
|
|
"media.toast.importFailed",
|
|
&[("error", &errors.join("; "))],
|
|
),
|
|
);
|
|
}
|
|
self.refresh_sidebar_media()
|
|
}
|
|
Message::MediaReplacementPicked { media_id, path } => {
|
|
let (Some(path), Some(data_dir)) = (path, self.data_dir.as_ref()) else {
|
|
return Task::none();
|
|
};
|
|
let db_path = self.db_path.clone();
|
|
let data_dir = data_dir.clone();
|
|
let result_media_id = media_id.clone();
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
engine::media::replace_media_file(
|
|
db.conn(),
|
|
&data_dir,
|
|
&media_id,
|
|
&path,
|
|
)
|
|
.map_err(|error| error.to_string())
|
|
})
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
},
|
|
move |result| Message::MediaReplacementFinished {
|
|
media_id: result_media_id.clone(),
|
|
result,
|
|
},
|
|
)
|
|
}
|
|
Message::MediaReplacementFinished { media_id, result } => {
|
|
match result {
|
|
Ok(Some(media)) => {
|
|
if let Some(state) = self.media_editors.get_mut(&media_id) {
|
|
state.size = media.size;
|
|
state.width = media.width;
|
|
state.height = media.height;
|
|
state.updated_at = media.updated_at;
|
|
}
|
|
self.notify(
|
|
ToastLevel::Success,
|
|
&t(self.ui_locale, "media.toast.replaced"),
|
|
);
|
|
}
|
|
Ok(None) => self.notify(
|
|
ToastLevel::Info,
|
|
&t(self.ui_locale, "media.toast.unchanged"),
|
|
),
|
|
Err(error) => self.notify(
|
|
ToastLevel::Error,
|
|
&tw(
|
|
self.ui_locale,
|
|
"media.toast.replaceFailed",
|
|
&[("error", &error)],
|
|
),
|
|
),
|
|
}
|
|
self.refresh_sidebar_media()
|
|
}
|
|
Message::ImportUploadsPicked {
|
|
definition_id,
|
|
path,
|
|
} => {
|
|
if let Some(path) = path {
|
|
self.update_import_paths(&definition_id, None, Some(path.as_path()));
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ImportWxrPicked {
|
|
definition_id,
|
|
path,
|
|
} => {
|
|
if let Some(path) = path {
|
|
self.update_import_paths(&definition_id, Some(path.as_path()), None);
|
|
return self.start_import_analysis(&definition_id);
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ImportAnalysisEvent {
|
|
definition_id,
|
|
event,
|
|
} => {
|
|
if let Some(state) = self.import_editors.get_mut(&definition_id) {
|
|
match event {
|
|
ImportAnalysisEvent::Progress(progress) => state.progress = Some(progress),
|
|
ImportAnalysisEvent::Finished(result) => {
|
|
state.is_analyzing = false;
|
|
match *result {
|
|
Ok((definition, report)) => {
|
|
state.definition = definition.clone();
|
|
state.report = Some(report);
|
|
state.error = None;
|
|
if let Some(sidebar) = self
|
|
.sidebar_imports
|
|
.iter_mut()
|
|
.find(|item| item.id == definition_id)
|
|
{
|
|
*sidebar = definition;
|
|
}
|
|
}
|
|
Err(error) => state.error = Some(error),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ImportExecutionEvent {
|
|
definition_id,
|
|
event,
|
|
} => {
|
|
if let Some(state) = self.import_editors.get_mut(&definition_id) {
|
|
match event {
|
|
ImportExecutionEvent::Progress(progress) => state.progress = Some(progress),
|
|
ImportExecutionEvent::Finished(result) => {
|
|
state.is_executing = false;
|
|
match result {
|
|
Ok(result) => {
|
|
state.result = Some(result);
|
|
state.error = None;
|
|
self.notify(
|
|
ToastLevel::Success,
|
|
&t(self.ui_locale, "import.toast.complete"),
|
|
);
|
|
return self.refresh_counts();
|
|
}
|
|
Err(error) => state.error = Some(error),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ImportAutoMapFinished {
|
|
definition_id,
|
|
result,
|
|
} => {
|
|
if let Some(state) = self.import_editors.get_mut(&definition_id) {
|
|
state.is_analyzing = false;
|
|
match result {
|
|
Ok((definition, report, count)) => {
|
|
state.definition = definition;
|
|
state.report = Some(report);
|
|
state.error = None;
|
|
self.notify(
|
|
ToastLevel::Success,
|
|
&tw(
|
|
self.ui_locale,
|
|
"import.toast.mapped",
|
|
&[("count", &count.to_string())],
|
|
),
|
|
);
|
|
}
|
|
Err(error) => {
|
|
state.error = Some(error.clone());
|
|
self.notify(ToastLevel::Error, &error);
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::DuplicatesRefresh => self.start_duplicate_search(0),
|
|
Message::DuplicatesShowMore => {
|
|
let next = self.duplicates_state.page.saturating_add(1);
|
|
self.start_duplicate_search(next)
|
|
}
|
|
Message::DuplicatesLoaded(result) => {
|
|
self.duplicates_state.is_loading = false;
|
|
self.duplicates_state.has_run = true;
|
|
match result {
|
|
Ok(result) => {
|
|
self.duplicates_state.result = result;
|
|
self.duplicates_state.error = None;
|
|
self.duplicates_state.selected.retain(|pair| {
|
|
self.duplicates_state.result.pairs.iter().any(|candidate| {
|
|
candidate.post_id_a == pair.0 && candidate.post_id_b == pair.1
|
|
})
|
|
});
|
|
}
|
|
Err(error) => self.duplicates_state.error = Some(error),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::DuplicatesToggle(a, b) => {
|
|
if !self
|
|
.duplicates_state
|
|
.selected
|
|
.remove(&(a.clone(), b.clone()))
|
|
{
|
|
self.duplicates_state.selected.insert((a, b));
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::DuplicatesCheckAll => {
|
|
self.duplicates_state.selected = self
|
|
.duplicates_state
|
|
.result
|
|
.pairs
|
|
.iter()
|
|
.map(|pair| (pair.post_id_a.clone(), pair.post_id_b.clone()))
|
|
.collect();
|
|
Task::none()
|
|
}
|
|
Message::DuplicatesUncheckAll => {
|
|
self.duplicates_state.selected.clear();
|
|
Task::none()
|
|
}
|
|
Message::DuplicatesDismiss(a, b) => self.dismiss_duplicate_pairs(vec![(a, b)]),
|
|
Message::DuplicatesDismissSelected => self
|
|
.dismiss_duplicate_pairs(self.duplicates_state.selected.iter().cloned().collect()),
|
|
Message::DuplicatesDismissed(result) => {
|
|
match result {
|
|
Ok(()) => {
|
|
self.duplicates_state.selected.clear();
|
|
self.notify(
|
|
ToastLevel::Success,
|
|
&t(self.ui_locale, "duplicates.dismissed"),
|
|
);
|
|
return self.start_duplicate_search(self.duplicates_state.page);
|
|
}
|
|
Err(error) => self.notify(ToastLevel::Error, &error),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::DuplicatesOpenPost(post_id) => {
|
|
let Some(db) = &self.db else {
|
|
return Task::none();
|
|
};
|
|
let Ok(post) = bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id)
|
|
else {
|
|
return Task::none();
|
|
};
|
|
Task::done(Message::OpenTab(Tab {
|
|
id: post.id,
|
|
tab_type: TabType::Post,
|
|
title: post.title,
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
}))
|
|
}
|
|
Message::DocumentationRefresh(kind) => self.start_documentation_load(kind),
|
|
Message::DocumentationLoaded(kind, generation, load) => {
|
|
let state = self.documentation_state_mut(kind);
|
|
if state.load_generation == generation {
|
|
state.apply(load);
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::DocumentationLinkClicked(kind, url) => {
|
|
if url == crate::views::documentation::API_DOCUMENTATION_URL {
|
|
self.open_singleton_tab(TabType::ApiDocumentation, "tabBar.apiDocumentation");
|
|
return self.start_documentation_load(DocumentationKind::Api);
|
|
} else if let Some(anchor) = url.strip_prefix("https://ruds.invalid/document#") {
|
|
if let Some(offset) = self.documentation_state(kind).parsed.anchors.get(anchor)
|
|
{
|
|
return iced::widget::scrollable::snap_to(
|
|
crate::views::documentation::scroll_id(kind),
|
|
iced::widget::scrollable::RelativeOffset { x: 0.0, y: *offset },
|
|
);
|
|
}
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "documentation.anchorMissing"),
|
|
);
|
|
} else if !self.confirm_external_link(url) {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "documentation.linkRefused"),
|
|
);
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::EmbeddingReindex => self.start_embedding_reindex(),
|
|
Message::EmbeddingBackfill => self.start_embedding_backfill(),
|
|
Message::LoadSemanticTagSuggestions(post_id) => {
|
|
let Some(data_dir) = self.data_dir.clone() else {
|
|
return Task::none();
|
|
};
|
|
let db_path = self.db_path.clone();
|
|
let returned_post_id = post_id.clone();
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
engine::embedding::EmbeddingService::production(db.conn(), &data_dir)
|
|
.suggest_tags(&post_id)
|
|
.map_err(|error| error.to_string())
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
|
},
|
|
move |result| Message::SemanticTagSuggestionsLoaded {
|
|
post_id: returned_post_id.clone(),
|
|
result,
|
|
},
|
|
)
|
|
}
|
|
Message::SemanticTagSuggestionsLoaded { post_id, result } => {
|
|
if let (Some(state), Ok(suggestions)) =
|
|
(self.post_editors.get_mut(&post_id), result)
|
|
{
|
|
state.semantic_tag_suggestions = suggestions;
|
|
}
|
|
Task::none()
|
|
}
|
|
message @ (Message::MainWindowLoaded(_)
|
|
| Message::EmbeddedPreviewReady(_)
|
|
| Message::EmbeddedStylePreviewReady(_)) => self.handle_preview_message(message),
|
|
|
|
// ── Tasks ──
|
|
Message::TaskTick => {
|
|
self.task_manager.evict_expired();
|
|
self.refresh_task_snapshots();
|
|
self.process_chat_events();
|
|
self.persist_due_chat_surface_state();
|
|
let _ = engine::embedding::EmbeddingService::flush_due();
|
|
if !self.search_index_rebuild_running {
|
|
self.auto_save_due_post_editors();
|
|
}
|
|
let actions = std::mem::take(&mut *self.script_menu_actions.lock().unwrap());
|
|
let mut tasks = actions
|
|
.into_iter()
|
|
.map(|action| self.dispatch_menu_action(action))
|
|
.collect::<Vec<_>>();
|
|
#[cfg(target_os = "macos")]
|
|
if let Some(receiver) = &self.lifecycle_receiver {
|
|
tasks.push(
|
|
crate::platform::macos::drain_lifecycle(receiver)
|
|
.into_iter()
|
|
.map(Task::done)
|
|
.fold(Task::none(), Task::chain),
|
|
);
|
|
}
|
|
tasks.push(self.reload_changed_documentation());
|
|
Task::batch(tasks)
|
|
}
|
|
Message::DomainEventsTick => self.process_domain_events(),
|
|
Message::CancelTask(task_id) => {
|
|
if self.cancel_site_generation_task(task_id) {
|
|
return Task::none();
|
|
}
|
|
self.task_manager.cancel(task_id);
|
|
self.refresh_task_snapshots();
|
|
Task::none()
|
|
}
|
|
Message::ToggleTaskGroup(group_id) => {
|
|
if !self.collapsed_task_groups.remove(&group_id) {
|
|
self.collapsed_task_groups.insert(group_id);
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
// ── macOS lifecycle ──
|
|
Message::FileOpenRequested(path) => {
|
|
let folder = if path.is_dir() {
|
|
Some(path)
|
|
} else if path.file_name().is_some_and(|name| name == "project.json")
|
|
&& path.parent().is_some_and(|parent| parent.ends_with("meta"))
|
|
{
|
|
path.parent().and_then(Path::parent).map(PathBuf::from)
|
|
} else {
|
|
None
|
|
};
|
|
folder.map_or_else(Task::none, |path| {
|
|
Task::done(Message::ProjectFolderPicked(Some(path)))
|
|
})
|
|
}
|
|
Message::UrlOpenRequested(url) => {
|
|
let candidate = match engine::blogmark::parse_deep_link(&url) {
|
|
Ok(candidate) => candidate,
|
|
Err(error) => {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&tw(
|
|
self.ui_locale,
|
|
"blogmark.invalid",
|
|
&[("error", &error.to_string())],
|
|
),
|
|
);
|
|
return Task::none();
|
|
}
|
|
};
|
|
let target = if let Some(project_id) = candidate.project_id.as_deref() {
|
|
let Some(project) = self
|
|
.projects
|
|
.iter()
|
|
.find(|project| project.id == project_id)
|
|
.cloned()
|
|
else {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "blogmark.unknownProject"),
|
|
);
|
|
return Task::none();
|
|
};
|
|
project
|
|
} else if let Some(project) = self.active_project.clone() {
|
|
project
|
|
} else {
|
|
self.notify(
|
|
ToastLevel::Warning,
|
|
&t(self.ui_locale, "blogmark.noProject"),
|
|
);
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = target.data_path.as_ref().map(PathBuf::from) else {
|
|
self.notify(ToastLevel::Error, &t(self.ui_locale, "blogmark.noProject"));
|
|
return Task::none();
|
|
};
|
|
if self.active_project.as_ref().map(|project| &project.id) != Some(&target.id) {
|
|
if let Some(db) = self.db.as_ref()
|
|
&& let Err(error) =
|
|
engine::project::set_active_project(db.conn(), &target.id)
|
|
{
|
|
self.notify(ToastLevel::Error, &error.to_string());
|
|
return Task::none();
|
|
}
|
|
self.active_project = Some(target.clone());
|
|
self.data_dir = Some(data_dir.clone());
|
|
self.preview_session = None;
|
|
self.hide_embedded_preview();
|
|
self.hide_embedded_style_preview();
|
|
self.style_view_state = None;
|
|
if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
|
|
let main_language = meta.main_language.unwrap_or_else(|| "en".into());
|
|
self.content_language = main_language.clone();
|
|
self.blog_languages = meta.blog_languages;
|
|
if !self.blog_languages.contains(&main_language) {
|
|
self.blog_languages.insert(0, main_language);
|
|
}
|
|
}
|
|
self.sync_menu_state();
|
|
}
|
|
let db_path = self.db_path.clone();
|
|
let project_id = target.id;
|
|
let task_manager = Arc::clone(&self.task_manager);
|
|
let label = t(self.ui_locale, "blogmark.importing");
|
|
let task_id = task_manager.submit(&label);
|
|
let offline_mode = self.offline_mode;
|
|
let app_handler = crate::platform::script_host::handler(
|
|
Arc::clone(&self.script_menu_actions),
|
|
t(self.ui_locale, "dialog.selectFolder"),
|
|
);
|
|
self.refresh_task_snapshots();
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
if !task_manager.wait_until_runnable(task_id) {
|
|
return Err("cancelled".to_string());
|
|
}
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
let host =
|
|
bds_core::scripting::CoreHost::new(db_path, &project_id, &data_dir)
|
|
.with_task(Arc::clone(&task_manager), task_id)
|
|
.with_offline_mode(offline_mode)
|
|
.with_app_handler(app_handler);
|
|
let control = task_manager
|
|
.cancellation_flag(task_id)
|
|
.map(bds_core::scripting::ExecutionControl::from_cancelled)
|
|
.unwrap_or_default();
|
|
engine::blogmark::receive_deep_link_with_host(
|
|
db.conn(),
|
|
&data_dir,
|
|
&project_id,
|
|
&url,
|
|
&control,
|
|
Arc::new(host),
|
|
)
|
|
.map_err(|error| error.to_string())
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
|
},
|
|
move |result| Message::BlogmarkImported { task_id, result },
|
|
)
|
|
}
|
|
Message::BlogmarkImported { task_id, result } => match result {
|
|
Ok(result) => {
|
|
self.task_manager.complete(task_id);
|
|
self.refresh_task_snapshots();
|
|
for message in result.toasts {
|
|
self.notify(ToastLevel::Info, &message);
|
|
}
|
|
for error in result.transform_errors {
|
|
self.add_output(&error);
|
|
}
|
|
self.sidebar_view = SidebarView::Posts;
|
|
self.sidebar_visible = true;
|
|
let tab = Tab {
|
|
id: result.post.id.clone(),
|
|
tab_type: TabType::Post,
|
|
title: result.post.title.clone(),
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
};
|
|
let open_editor = self.open_tab(tab);
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "blogmark.imported"));
|
|
Task::batch([open_editor, self.refresh_counts()])
|
|
}
|
|
Err(error) => {
|
|
if self.task_manager.status(task_id) != Some(TaskStatus::Cancelled) {
|
|
self.task_manager.fail(task_id, error.clone());
|
|
}
|
|
self.refresh_task_snapshots();
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&tw(self.ui_locale, "blogmark.failed", &[("error", &error)]),
|
|
);
|
|
Task::none()
|
|
}
|
|
},
|
|
|
|
// ── Panel ──
|
|
Message::SetPanelTab(tab) => {
|
|
self.panel_tab = tab;
|
|
if tab == PanelTab::GitLog {
|
|
self.refresh_git_file_history()
|
|
} else {
|
|
Task::none()
|
|
}
|
|
}
|
|
|
|
// ── Settings ──
|
|
Message::SetOfflineMode(mode) => {
|
|
if let Some(db) = &self.db
|
|
&& let Err(error) = engine::settings::set_airplane_mode(db.conn(), mode)
|
|
{
|
|
let operation = t(self.ui_locale, "settings.offlineMode");
|
|
let error = error.to_string();
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&tw(
|
|
self.ui_locale,
|
|
"common.operationFailed",
|
|
&[("operation", &operation), ("error", &error)],
|
|
),
|
|
);
|
|
return Task::none();
|
|
}
|
|
self.offline_mode = mode;
|
|
let models = self.chat_model_options();
|
|
let selected = self.db.as_ref().and_then(|db| {
|
|
ai::load_ai_settings(db.conn(), mode)
|
|
.ok()
|
|
.map(|settings| settings.active().endpoint.model.clone())
|
|
.filter(|model| !model.trim().is_empty())
|
|
});
|
|
for (id, state) in &mut self.chat_editors {
|
|
state.model_options = models.clone();
|
|
if let Some(model) = selected.as_ref() {
|
|
state.conversation.model = Some(model.clone());
|
|
if let Some(db) = &self.db {
|
|
let _ = engine::chat::set_conversation_model(db.conn(), id, model);
|
|
}
|
|
}
|
|
}
|
|
self.sync_menu_state();
|
|
Task::none()
|
|
}
|
|
Message::SetUiLocale(locale) => {
|
|
if let Some(db) = &self.db {
|
|
match engine::settings::set(
|
|
db.conn(),
|
|
engine::settings::UI_LANGUAGE_KEY,
|
|
locale.code(),
|
|
) {
|
|
Ok(()) => self.apply_ui_locale(locale),
|
|
Err(error) => {
|
|
self.add_output(&error.to_string());
|
|
}
|
|
}
|
|
} else {
|
|
self.apply_ui_locale(locale);
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ToggleLocaleDropdown => {
|
|
self.locale_dropdown_open = !self.locale_dropdown_open;
|
|
self.project_dropdown_open = false;
|
|
Task::none()
|
|
}
|
|
Message::ToggleProjectDropdown => {
|
|
self.project_dropdown_open = !self.project_dropdown_open;
|
|
self.locale_dropdown_open = false;
|
|
Task::none()
|
|
}
|
|
|
|
// ── Blog engine actions (async via TaskManager) ──
|
|
message @ (Message::RebuildDatabase
|
|
| Message::ReindexText
|
|
| Message::RegenerateCalendar
|
|
| Message::ValidateTranslations
|
|
| Message::TranslationValidationLoaded(_)
|
|
| Message::ValidateMedia
|
|
| Message::GenerateSite
|
|
| Message::RunMetadataDiff
|
|
| Message::MetadataDiffLoaded(_)
|
|
| Message::RepairMetadataDiffItem { .. }
|
|
| Message::MetadataDiffItemRepaired(_)
|
|
| Message::RunSiteValidation
|
|
| Message::ApplySiteValidation
|
|
| Message::EngineTaskDone { .. }
|
|
| Message::SiteGenerationSectionDone { .. }
|
|
| Message::SiteGenerationIndexDone { .. }
|
|
| Message::SiteValidationLoaded(_)) => self.handle_engine_message(message),
|
|
|
|
// ── Git ──
|
|
message @ (Message::GitRefresh
|
|
| Message::GitLoaded { .. }
|
|
| Message::GitRemoteInputChanged(_)
|
|
| Message::GitCommitMessageChanged(_)
|
|
| Message::GitInitialize
|
|
| Message::GitSetRemote
|
|
| Message::GitCommit
|
|
| Message::GitFetch
|
|
| Message::GitPull
|
|
| Message::GitPush
|
|
| Message::GitPruneLfs
|
|
| Message::GitLocalFinished { .. }
|
|
| Message::GitNetworkFinished { .. }
|
|
| Message::OpenGitFileDiff(_)
|
|
| Message::OpenGitCommitDiff { .. }
|
|
| Message::SelectGitCommitFile { .. }
|
|
| Message::GitDiffLoaded { .. }
|
|
| Message::GitFileHistoryLoaded { .. }) => self.handle_git_message(message),
|
|
|
|
// ── Toasts ──
|
|
Message::DismissToast(id) => {
|
|
self.toasts.retain(|toast| toast.id != id);
|
|
Task::none()
|
|
}
|
|
Message::ExpireToasts => {
|
|
self.toasts.retain(|t| !t.is_expired());
|
|
Task::none()
|
|
}
|
|
|
|
// ── Sidebar filters ──
|
|
message @ (Message::PostSearchChanged(_)
|
|
| Message::TogglePostFilterPanel
|
|
| Message::SetPostStatusFilter(_)
|
|
| Message::SetPostLanguageFilter(_)
|
|
| Message::SetPostCalendarYear(_)
|
|
| Message::SetPostCalendarMonth(_)
|
|
| Message::SetPostFromDate(_)
|
|
| Message::SetPostToDate(_)
|
|
| Message::TogglePostTagFilter(_)
|
|
| Message::TogglePostCategoryFilter(_)
|
|
| Message::ClearPostFilters
|
|
| Message::MediaSearchChanged(_)
|
|
| Message::ToggleMediaFilterPanel
|
|
| Message::SetMediaCalendarYear(_)
|
|
| Message::SetMediaCalendarMonth(_)
|
|
| Message::ToggleMediaTagFilter(_)
|
|
| Message::ClearMediaFilters
|
|
| Message::SidebarPostsLoaded(_)
|
|
| Message::SidebarMediaLoaded { .. }
|
|
| Message::SidebarPostsAppended(_)
|
|
| Message::SidebarMediaAppended { .. }
|
|
| Message::SidebarScrolled(_)) => self.handle_sidebar_message(message),
|
|
|
|
// ── Modal ──
|
|
Message::ShowModal(state) => {
|
|
self.active_modal = Some(state);
|
|
Task::none()
|
|
}
|
|
Message::DismissModal => {
|
|
self.active_modal = None;
|
|
Task::none()
|
|
}
|
|
Message::ConfirmModal(action) => {
|
|
self.active_modal = None;
|
|
match action {
|
|
modal::ConfirmAction::RebuildSearchIndex => self.start_search_index_rebuild(),
|
|
modal::ConfirmAction::OpenExternalUrl(url) => {
|
|
if (url.starts_with("https://") || url.starts_with("http://"))
|
|
&& let Err(error) = open::that_detached(&url)
|
|
{
|
|
self.notify(ToastLevel::Error, &error.to_string());
|
|
}
|
|
Task::none()
|
|
}
|
|
modal::ConfirmAction::DeleteProject(id) => {
|
|
Task::done(Message::DeleteProject(id))
|
|
}
|
|
modal::ConfirmAction::DeletePost(id) => self.delete_post_editor(&id),
|
|
modal::ConfirmAction::DeleteMedia(id) => self.delete_media_editor(&id),
|
|
modal::ConfirmAction::DeleteScript(id) => self.delete_script_editor(&id),
|
|
modal::ConfirmAction::DeleteTemplate(id) => {
|
|
self.delete_template_editor(&id, false)
|
|
}
|
|
modal::ConfirmAction::ForceDeleteTemplate(id) => {
|
|
self.delete_template_editor(&id, true)
|
|
}
|
|
modal::ConfirmAction::DeleteTag(id) => self.delete_tag(&id),
|
|
modal::ConfirmAction::DeleteImport(id) => self.delete_import_definition(&id),
|
|
modal::ConfirmAction::MergeTags { sources, target } => {
|
|
self.merge_tags(&sources, &target)
|
|
}
|
|
}
|
|
}
|
|
Message::RemoteTargetChanged(value) => {
|
|
if let Some(modal::ModalState::RemoteConnection { target, error, .. }) =
|
|
self.active_modal.as_mut()
|
|
{
|
|
*target = value;
|
|
*error = None;
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::RemoteConnectRequested => {
|
|
let target = match self.active_modal.as_mut() {
|
|
Some(modal::ModalState::RemoteConnection {
|
|
target,
|
|
connecting,
|
|
error,
|
|
..
|
|
}) => {
|
|
*connecting = true;
|
|
*error = None;
|
|
target.clone()
|
|
}
|
|
_ => return Task::none(),
|
|
};
|
|
let parsed = match bds_server::RemoteTarget::parse(&target) {
|
|
Ok(target) => target,
|
|
Err(error) => {
|
|
if let Some(modal::ModalState::RemoteConnection {
|
|
connecting,
|
|
error: shown,
|
|
..
|
|
}) = self.active_modal.as_mut()
|
|
{
|
|
*connecting = false;
|
|
let _ = error;
|
|
*shown = Some(t(self.ui_locale, "remoteConnection.invalidTarget"));
|
|
}
|
|
return Task::none();
|
|
}
|
|
};
|
|
let data_root = bds_core::util::application_data_dir();
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let client = Arc::new(
|
|
bds_server::DesktopClient::connect(parsed, &data_root)
|
|
.map_err(|error| error.to_string())?,
|
|
);
|
|
let projects =
|
|
client.list_projects().map_err(|error| error.to_string())?;
|
|
Ok((client, projects))
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| {
|
|
Err(format!("remote connection task failed: {error}"))
|
|
})
|
|
},
|
|
Message::RemoteConnected,
|
|
)
|
|
}
|
|
Message::RemoteConnected(result) => {
|
|
match result {
|
|
Ok((client, projects)) => {
|
|
let server_locale =
|
|
bds_core::i18n::normalize_language(client.server_locale());
|
|
self.remote_projects.clone_from(&projects);
|
|
self.remote_client = Some(client);
|
|
if let Some(modal::ModalState::RemoteConnection {
|
|
connecting,
|
|
connected,
|
|
error,
|
|
projects: shown_projects,
|
|
selected_project_id,
|
|
..
|
|
}) = self.active_modal.as_mut()
|
|
{
|
|
*connecting = false;
|
|
*connected = true;
|
|
*error = None;
|
|
shown_projects.clone_from(&projects);
|
|
*selected_project_id =
|
|
projects.first().map(|project| project.id.clone());
|
|
}
|
|
self.menu_registry
|
|
.set_enabled(MenuAction::DisconnectServer, true);
|
|
if self.remote_previous_locale.is_none() {
|
|
self.remote_previous_locale = Some(self.ui_locale);
|
|
}
|
|
self.apply_ui_locale(server_locale);
|
|
}
|
|
Err(connection_error) => {
|
|
let remains_connected = self.remote_client.is_some();
|
|
if let Some(modal::ModalState::RemoteConnection {
|
|
connecting,
|
|
connected,
|
|
error,
|
|
..
|
|
}) = self.active_modal.as_mut()
|
|
{
|
|
*connecting = false;
|
|
*connected = remains_connected;
|
|
*error = Some(tw(
|
|
self.ui_locale,
|
|
"remoteConnection.failed",
|
|
&[("reason", &connection_error)],
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::RemoteProjectSelected(project_id) => {
|
|
if let Some(modal::ModalState::RemoteConnection {
|
|
selected_project_id,
|
|
error,
|
|
..
|
|
}) = self.active_modal.as_mut()
|
|
{
|
|
*selected_project_id = Some(project_id);
|
|
*error = None;
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::RemoteOpenProjectRequested => {
|
|
let project_id = match self.active_modal.as_mut() {
|
|
Some(modal::ModalState::RemoteConnection {
|
|
selected_project_id: Some(project_id),
|
|
connecting,
|
|
error,
|
|
..
|
|
}) => {
|
|
*connecting = true;
|
|
*error = None;
|
|
project_id.clone()
|
|
}
|
|
_ => return Task::none(),
|
|
};
|
|
let Some(client) = self.remote_client.clone() else {
|
|
return Task::done(Message::RemoteProjectOpened(Err(t(
|
|
self.ui_locale,
|
|
"remoteConnection.connectionLost",
|
|
))));
|
|
};
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
client
|
|
.open_project(&project_id)
|
|
.map_err(|error| error.to_string())
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| Err(format!("remote project task failed: {error}")))
|
|
},
|
|
Message::RemoteProjectOpened,
|
|
)
|
|
}
|
|
Message::RemoteProjectOpened(result) => {
|
|
match result {
|
|
Ok(project) => {
|
|
let target = self
|
|
.remote_client
|
|
.as_ref()
|
|
.map(|client| client.target().label())
|
|
.unwrap_or_default();
|
|
self.remote_display_name = Some(format!("{} — {target}", project.name));
|
|
self.remote_project = Some(project);
|
|
self.active_modal = None;
|
|
self.notify(
|
|
ToastLevel::Success,
|
|
&t(self.ui_locale, "remoteConnection.opened"),
|
|
);
|
|
}
|
|
Err(open_error) => {
|
|
if let Some(modal::ModalState::RemoteConnection {
|
|
connecting, error, ..
|
|
}) = self.active_modal.as_mut()
|
|
{
|
|
*connecting = false;
|
|
*error = Some(tw(
|
|
self.ui_locale,
|
|
"remoteConnection.openFailed",
|
|
&[("reason", &open_error)],
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::RemoteDisconnectRequested => {
|
|
if let Some(client) = self.remote_client.take() {
|
|
client.close();
|
|
}
|
|
self.remote_projects.clear();
|
|
self.remote_project = None;
|
|
self.remote_display_name = None;
|
|
self.active_modal = None;
|
|
self.menu_registry
|
|
.set_enabled(MenuAction::DisconnectServer, false);
|
|
if let Some(locale) = self.remote_previous_locale.take() {
|
|
self.apply_ui_locale(locale);
|
|
}
|
|
self.notify(
|
|
ToastLevel::Info,
|
|
&t(self.ui_locale, "remoteConnection.disconnected"),
|
|
);
|
|
Task::none()
|
|
}
|
|
Message::FindQueryChanged(value) => {
|
|
if let Some(modal::ModalState::FindReplace { query, .. }) =
|
|
self.active_modal.as_mut()
|
|
{
|
|
*query = value;
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ReplaceQueryChanged(value) => {
|
|
if let Some(modal::ModalState::FindReplace { replacement, .. }) =
|
|
self.active_modal.as_mut()
|
|
{
|
|
*replacement = value;
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::FindNext => {
|
|
let query = match &self.active_modal {
|
|
Some(modal::ModalState::FindReplace { query, .. }) => query.clone(),
|
|
_ => return Task::none(),
|
|
};
|
|
if !self.find_next_in_active_editor(&query) && !query.is_empty() {
|
|
self.notify(ToastLevel::Info, &t(self.ui_locale, "find.noMatches"));
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ReplaceCurrent => {
|
|
let (query, replacement) = match &self.active_modal {
|
|
Some(modal::ModalState::FindReplace {
|
|
query, replacement, ..
|
|
}) => (query.clone(), replacement.clone()),
|
|
_ => return Task::none(),
|
|
};
|
|
self.replace_current_in_active_editor(&query, &replacement);
|
|
Task::none()
|
|
}
|
|
Message::ReplaceAll => {
|
|
let (query, replacement) = match &self.active_modal {
|
|
Some(modal::ModalState::FindReplace {
|
|
query, replacement, ..
|
|
}) => (query.clone(), replacement.clone()),
|
|
_ => return Task::none(),
|
|
};
|
|
let count = self.replace_all_in_active_editor(&query, &replacement);
|
|
self.notify(
|
|
ToastLevel::Info,
|
|
&tw(
|
|
self.ui_locale,
|
|
"find.replacedCount",
|
|
&[("count", &count.to_string())],
|
|
),
|
|
);
|
|
Task::none()
|
|
}
|
|
Message::ToggleAiSuggestionField(index, accepted) => {
|
|
if let Some(modal::ModalState::AISuggestions { fields, .. }) =
|
|
self.active_modal.as_mut()
|
|
&& let Some(field) = fields.get_mut(index)
|
|
&& !field.locked
|
|
{
|
|
field.accepted = accepted;
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ApplyAiSuggestions(target, fields) => {
|
|
self.active_modal = None;
|
|
self.apply_ai_suggestions(target, &fields)
|
|
}
|
|
Message::OneShotAiFinished {
|
|
entity_id,
|
|
action,
|
|
result,
|
|
} => self.finish_one_shot_ai(&entity_id, action, result),
|
|
|
|
// ── Editor view messages ──
|
|
Message::PostEditor(msg) => self.handle_post_editor_msg(msg),
|
|
Message::MediaEditor(msg) => self.handle_media_editor_msg(msg),
|
|
Message::TemplateEditor(msg) => self.handle_template_editor_msg(msg),
|
|
Message::ScriptEditor(msg) => self.handle_script_editor_msg(msg),
|
|
Message::Tags(msg) => self.handle_tags_msg(msg),
|
|
Message::Settings(msg) => self.handle_settings_msg(msg),
|
|
Message::Style(msg) => self.handle_style_msg(msg),
|
|
Message::ImportEditor(msg) => self.handle_import_editor_msg(msg),
|
|
Message::MenuEditor(msg) => self.handle_menu_editor_msg(msg),
|
|
|
|
Message::Noop => Task::none(),
|
|
Message::InitMenuBar => {
|
|
#[cfg(target_os = "macos")]
|
|
if let Some(menu_bar) = self._menu_bar.as_ref() {
|
|
menu::init_menu_for_nsapp(menu_bar);
|
|
}
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn documentation_state(&self, kind: DocumentationKind) -> &DocumentationState {
|
|
match kind {
|
|
DocumentationKind::Guide => &self.guide_documentation,
|
|
DocumentationKind::Api => &self.api_documentation,
|
|
DocumentationKind::Cli => &self.cli_documentation,
|
|
DocumentationKind::Mcp => &self.mcp_documentation,
|
|
}
|
|
}
|
|
|
|
fn documentation_state_mut(&mut self, kind: DocumentationKind) -> &mut DocumentationState {
|
|
match kind {
|
|
DocumentationKind::Guide => &mut self.guide_documentation,
|
|
DocumentationKind::Api => &mut self.api_documentation,
|
|
DocumentationKind::Cli => &mut self.cli_documentation,
|
|
DocumentationKind::Mcp => &mut self.mcp_documentation,
|
|
}
|
|
}
|
|
|
|
fn start_documentation_load(&mut self, kind: DocumentationKind) -> Task<Message> {
|
|
let generation = self.documentation_state_mut(kind).start_loading();
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || match kind {
|
|
DocumentationKind::Guide => crate::views::documentation::load_user_guide(),
|
|
DocumentationKind::Api => crate::views::documentation::load_api_document(),
|
|
DocumentationKind::Cli => crate::views::documentation::load_cli_guide(),
|
|
DocumentationKind::Mcp => crate::views::documentation::load_mcp_guide(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| DocumentLoad::Malformed {
|
|
signature: 0,
|
|
error: format!("documentation task panicked: {error}"),
|
|
})
|
|
},
|
|
move |load| Message::DocumentationLoaded(kind, generation, load),
|
|
)
|
|
}
|
|
|
|
fn reload_changed_documentation(&mut self) -> Task<Message> {
|
|
let mut changed = Vec::new();
|
|
for (tab_type, kind) in [
|
|
(TabType::Documentation, DocumentationKind::Guide),
|
|
(TabType::ApiDocumentation, DocumentationKind::Api),
|
|
(TabType::CliDocumentation, DocumentationKind::Cli),
|
|
(TabType::McpDocumentation, DocumentationKind::Mcp),
|
|
] {
|
|
if !self.tabs.iter().any(|tab| tab.tab_type == tab_type) {
|
|
continue;
|
|
}
|
|
let should_check = self.documentation_state(kind).should_check();
|
|
if !should_check {
|
|
continue;
|
|
}
|
|
let signature = current_signature(kind);
|
|
let state = self.documentation_state_mut(kind);
|
|
state.mark_checked();
|
|
if state.status == crate::views::documentation::DocumentStatus::NotLoaded
|
|
|| signature != state.signature
|
|
{
|
|
changed.push(kind);
|
|
}
|
|
}
|
|
Task::batch(
|
|
changed
|
|
.into_iter()
|
|
.map(|kind| self.start_documentation_load(kind)),
|
|
)
|
|
}
|
|
|
|
fn confirm_external_link(&mut self, url: String) -> bool {
|
|
if !url.starts_with("https://") && !url.starts_with("http://") {
|
|
return false;
|
|
}
|
|
self.active_modal = Some(modal::ModalState::Confirm {
|
|
title: t(self.ui_locale, "documentation.externalLinkTitle"),
|
|
message: tw(
|
|
self.ui_locale,
|
|
"documentation.externalLinkMessage",
|
|
&[("url", &url)],
|
|
),
|
|
on_confirm: modal::ConfirmAction::OpenExternalUrl(url),
|
|
});
|
|
true
|
|
}
|
|
|
|
fn start_duplicate_search(&mut self, page: usize) -> Task<Message> {
|
|
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
|
|
return Task::none();
|
|
};
|
|
self.duplicates_state.enabled = engine::meta::read_project_json(data_dir)
|
|
.is_ok_and(|metadata| metadata.semantic_similarity_enabled);
|
|
self.duplicates_state.page = page;
|
|
self.duplicates_state.error = None;
|
|
if !self.duplicates_state.enabled {
|
|
self.duplicates_state.is_loading = false;
|
|
self.duplicates_state.has_run = false;
|
|
self.duplicates_state.result = Default::default();
|
|
return Task::none();
|
|
}
|
|
self.duplicates_state.is_loading = true;
|
|
let db_path = self.db_path.clone();
|
|
let data_dir = data_dir.clone();
|
|
let project_id = project.id.clone();
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
engine::embedding::EmbeddingService::production(db.conn(), &data_dir)
|
|
.find_duplicates(&project_id, page)
|
|
.map_err(|error| error.to_string())
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
|
},
|
|
Message::DuplicatesLoaded,
|
|
)
|
|
}
|
|
|
|
fn dismiss_duplicate_pairs(&mut self, pairs: Vec<(String, String)>) -> Task<Message> {
|
|
if pairs.is_empty() {
|
|
return Task::none();
|
|
}
|
|
let (Some(_), Some(data_dir)) = (&self.db, &self.data_dir) else {
|
|
return Task::none();
|
|
};
|
|
let db_path = self.db_path.clone();
|
|
let data_dir = data_dir.clone();
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
engine::embedding::EmbeddingService::production(db.conn(), &data_dir)
|
|
.dismiss_duplicate_pairs(&pairs)
|
|
.map_err(|error| error.to_string())
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
|
},
|
|
Message::DuplicatesDismissed,
|
|
)
|
|
}
|
|
|
|
fn start_embedding_reindex(&mut self) -> Task<Message> {
|
|
let Some(data_dir) = &self.data_dir else {
|
|
return Task::none();
|
|
};
|
|
if !engine::meta::read_project_json(data_dir)
|
|
.is_ok_and(|metadata| metadata.semantic_similarity_enabled)
|
|
{
|
|
self.notify(
|
|
ToastLevel::Warning,
|
|
&t(self.ui_locale, "duplicates.disabled"),
|
|
);
|
|
return Task::none();
|
|
}
|
|
let locale = self.ui_locale;
|
|
self.spawn_engine_task(
|
|
"menu.item.rebuildEmbeddingIndex",
|
|
move |db_path, project_id, data_dir, tm, tid| {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
let service = engine::embedding::EmbeddingService::production(db.conn(), &data_dir);
|
|
let indexed = service
|
|
.reindex_all_with_progress(&project_id, |current, total| {
|
|
tm.report_progress(
|
|
tid,
|
|
Some(current as f32 / total.max(1) as f32),
|
|
Some(tw(
|
|
locale,
|
|
"embeddings.indexingProgress",
|
|
&[
|
|
("current", ¤t.to_string()),
|
|
("total", &total.to_string()),
|
|
],
|
|
)),
|
|
);
|
|
!tm.is_cancelled(tid)
|
|
})
|
|
.map_err(|error| error.to_string())?;
|
|
service
|
|
.flush_project(&project_id)
|
|
.map_err(|error| error.to_string())?;
|
|
Ok(tw(
|
|
locale,
|
|
"embeddings.reindexed",
|
|
&[("count", &indexed.len().to_string())],
|
|
))
|
|
},
|
|
)
|
|
}
|
|
|
|
fn start_embedding_backfill(&mut self) -> Task<Message> {
|
|
let Some(data_dir) = &self.data_dir else {
|
|
return Task::none();
|
|
};
|
|
if !engine::meta::read_project_json(data_dir)
|
|
.is_ok_and(|metadata| metadata.semantic_similarity_enabled)
|
|
{
|
|
return Task::none();
|
|
}
|
|
let locale = self.ui_locale;
|
|
self.spawn_engine_task(
|
|
"embeddings.indexing",
|
|
move |db_path, project_id, data_dir, tm, tid| {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
let indexed = engine::embedding::EmbeddingService::production(db.conn(), &data_dir)
|
|
.index_unindexed_with_progress(&project_id, |current, total| {
|
|
tm.report_progress(
|
|
tid,
|
|
Some(current as f32 / total.max(1) as f32),
|
|
Some(tw(
|
|
locale,
|
|
"embeddings.indexingProgress",
|
|
&[
|
|
("current", ¤t.to_string()),
|
|
("total", &total.to_string()),
|
|
],
|
|
)),
|
|
);
|
|
!tm.is_cancelled(tid)
|
|
})
|
|
.map_err(|error| error.to_string())?;
|
|
Ok(tw(
|
|
locale,
|
|
"embeddings.indexed",
|
|
&[("count", &indexed.len().to_string())],
|
|
))
|
|
},
|
|
)
|
|
}
|
|
|
|
pub fn view(&self) -> Element<'_, Message> {
|
|
let active_name = self.remote_display_name.as_deref().or_else(|| {
|
|
self.active_project
|
|
.as_ref()
|
|
.map(|project| project.name.as_str())
|
|
});
|
|
let active_post_filter = match self.sidebar_view {
|
|
SidebarView::Pages => &self.page_filter,
|
|
_ => &self.post_filter,
|
|
};
|
|
let post_preview_widget = if self.active_post_uses_embedded_preview() {
|
|
self.embedded_preview
|
|
.as_ref()
|
|
.map(|preview| webview::webview(&preview.controller).into())
|
|
} else {
|
|
None
|
|
};
|
|
let style_preview_widget = if self.active_style_uses_embedded_preview() {
|
|
self.embedded_style_preview
|
|
.as_ref()
|
|
.map(|preview| webview::webview(&preview.controller).into())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
native_edit::native_edit(
|
|
workspace::view(
|
|
self.sidebar_view,
|
|
self.sidebar_visible,
|
|
self.sidebar_width,
|
|
&self.tabs,
|
|
self.active_tab.as_deref(),
|
|
self.panel_visible,
|
|
self.panel_tab,
|
|
&self.task_snapshots,
|
|
&self.collapsed_task_groups,
|
|
&self.output_entries,
|
|
&self.sidebar_posts,
|
|
&self.sidebar_media,
|
|
&self.sidebar_scripts,
|
|
&self.sidebar_templates,
|
|
&self.sidebar_imports,
|
|
&self.chat_conversations,
|
|
active_post_filter,
|
|
&self.media_filter,
|
|
&self.sidebar_media_thumbs,
|
|
self.sidebar_posts_has_more,
|
|
self.sidebar_media_has_more,
|
|
active_name,
|
|
&self.projects,
|
|
self.active_project.as_ref().map(|p| p.id.as_str()),
|
|
self.post_count,
|
|
self.media_count,
|
|
self.offline_mode,
|
|
self.locale_dropdown_open,
|
|
self.project_dropdown_open,
|
|
&self.theme_badge,
|
|
self.ui_locale,
|
|
&self.toasts,
|
|
self.active_modal.clone(),
|
|
self.data_dir.as_deref(),
|
|
post_preview_widget,
|
|
style_preview_widget,
|
|
&self.post_editors,
|
|
&self.media_editors,
|
|
&self.template_editors,
|
|
&self.script_editors,
|
|
&self.import_editors,
|
|
&self.chat_editors,
|
|
self.tags_view_state.as_ref(),
|
|
self.settings_state.as_ref(),
|
|
self.style_view_state.as_ref(),
|
|
self.dashboard_state.as_ref(),
|
|
&self.site_validation_state,
|
|
&self.duplicates_state,
|
|
&self.guide_documentation,
|
|
&self.api_documentation,
|
|
&self.cli_documentation,
|
|
&self.mcp_documentation,
|
|
&self.metadata_diff_state,
|
|
&self.menu_editor_state,
|
|
&self.translation_validation_state,
|
|
&self.git_state,
|
|
&self.git_diffs,
|
|
&self.git_file_history,
|
|
),
|
|
Arc::clone(&self.native_edit_commands),
|
|
)
|
|
.into()
|
|
}
|
|
|
|
pub fn subscription(&self) -> Subscription<Message> {
|
|
let menu_sub = menu::menu_subscription();
|
|
|
|
let task_tick =
|
|
iced::time::every(std::time::Duration::from_millis(500)).map(|_| Message::TaskTick);
|
|
let domain_event_tick = iced::time::every(std::time::Duration::from_millis(100))
|
|
.map(|_| Message::DomainEventsTick);
|
|
|
|
let toast_tick = if !self.toasts.is_empty() {
|
|
iced::time::every(std::time::Duration::from_millis(250)).map(|_| Message::ExpireToasts)
|
|
} else {
|
|
Subscription::none()
|
|
};
|
|
|
|
let file_drop_sub = iced::event::listen_with(|event, _status, _id| match event {
|
|
iced::Event::Window(window::Event::FileDropped(path)) => {
|
|
Some(Message::FileDropped(path))
|
|
}
|
|
_ => None,
|
|
});
|
|
|
|
// Global mouse tracking for sidebar resize dragging.
|
|
// The 4px drag handle mouse_area only fires on_press; move/release
|
|
// are captured here so dragging works even when the cursor leaves
|
|
// the narrow handle strip.
|
|
let drag_sub = if self.sidebar_dragging {
|
|
iced::event::listen_with(|event, _status, _id| match event {
|
|
iced::Event::Mouse(iced::mouse::Event::CursorMoved { position }) => {
|
|
Some(Message::SidebarResizeMove(position.x))
|
|
}
|
|
iced::Event::Mouse(iced::mouse::Event::ButtonReleased(
|
|
iced::mouse::Button::Left,
|
|
)) => Some(Message::SidebarResizeEnd),
|
|
_ => None,
|
|
})
|
|
} else {
|
|
Subscription::none()
|
|
};
|
|
|
|
let menu_interaction_sub = if self.menu_editor_state.draft.is_some() {
|
|
iced::event::listen_with(|event, _status, _id| match event {
|
|
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
|
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
|
..
|
|
}) => Some(Message::MenuEditor(MenuEditorMsg::CancelDraft)),
|
|
_ => None,
|
|
})
|
|
} else if self.menu_editor_state.dragging_id.is_some() {
|
|
iced::event::listen_with(|event, _status, _id| match event {
|
|
iced::Event::Mouse(iced::mouse::Event::ButtonReleased(
|
|
iced::mouse::Button::Left,
|
|
)) => Some(Message::MenuEditor(MenuEditorMsg::Drop)),
|
|
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
|
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
|
..
|
|
}) => Some(Message::MenuEditor(MenuEditorMsg::DragCancel)),
|
|
_ => None,
|
|
})
|
|
} else {
|
|
Subscription::none()
|
|
};
|
|
let menu_expand_tick = if self.menu_editor_state.hover_expand.is_some() {
|
|
iced::time::every(std::time::Duration::from_millis(50))
|
|
.map(|_| Message::MenuEditor(MenuEditorMsg::ExpandTick))
|
|
} else {
|
|
Subscription::none()
|
|
};
|
|
|
|
Subscription::batch([
|
|
menu_sub,
|
|
task_tick,
|
|
domain_event_tick,
|
|
toast_tick,
|
|
file_drop_sub,
|
|
drag_sub,
|
|
menu_interaction_sub,
|
|
menu_expand_tick,
|
|
])
|
|
}
|
|
|
|
// ── Private helpers ──
|
|
|
|
fn active_chat_id(&self) -> Option<&str> {
|
|
let id = self.active_tab.as_deref()?;
|
|
self.tabs
|
|
.iter()
|
|
.any(|tab| tab.id == id && tab.tab_type == TabType::Chat)
|
|
.then_some(id)
|
|
}
|
|
|
|
fn active_chat_state_mut(&mut self) -> Option<&mut ChatEditorState> {
|
|
let id = self.active_chat_id()?.to_string();
|
|
self.chat_editors.get_mut(&id)
|
|
}
|
|
|
|
fn refresh_chat_conversations(&mut self) {
|
|
self.chat_conversations = self
|
|
.db
|
|
.as_ref()
|
|
.and_then(|db| engine::chat::list_conversations(db.conn()).ok())
|
|
.unwrap_or_default();
|
|
}
|
|
|
|
fn persist_due_chat_surface_state(&mut self) {
|
|
let due = self
|
|
.chat_editors
|
|
.iter()
|
|
.filter_map(|(id, state)| {
|
|
state.surface_state_dirty_since.and_then(|since| {
|
|
(since.elapsed() >= std::time::Duration::from_millis(500)).then(|| id.clone())
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
for id in due {
|
|
self.persist_chat_surface_state(&id);
|
|
}
|
|
}
|
|
|
|
fn persist_chat_surface_state(&mut self, conversation_id: &str) {
|
|
let Some(surface_state) = self
|
|
.chat_editors
|
|
.get(conversation_id)
|
|
.map(|state| state.surface_state.clone())
|
|
else {
|
|
return;
|
|
};
|
|
let result = self
|
|
.db
|
|
.as_ref()
|
|
.ok_or_else(|| "database unavailable".to_string())
|
|
.and_then(|db| {
|
|
engine::chat::put_surface_state(db.conn(), conversation_id, &surface_state)
|
|
.map_err(|error| error.to_string())
|
|
});
|
|
match result {
|
|
Ok(()) => {
|
|
if let Some(state) = self.chat_editors.get_mut(conversation_id) {
|
|
state.conversation.surface_state = serde_json::to_string(&surface_state).ok();
|
|
state.surface_state_dirty_since = None;
|
|
}
|
|
}
|
|
Err(error) => self.notify(ToastLevel::Error, &error),
|
|
}
|
|
}
|
|
|
|
fn chat_model_options(&self) -> Vec<ChatModelChoice> {
|
|
let Some(db) = &self.db else {
|
|
return Vec::new();
|
|
};
|
|
let Ok(settings) = ai::load_ai_settings(db.conn(), self.offline_mode) else {
|
|
return Vec::new();
|
|
};
|
|
let active = settings.active();
|
|
let mut models = active
|
|
.models
|
|
.iter()
|
|
.map(|model| ChatModelChoice {
|
|
id: model.id.clone(),
|
|
label: model.name.clone(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let model = active.endpoint.model.clone();
|
|
if !model.trim().is_empty() && !models.iter().any(|choice| choice.id == model) {
|
|
models.push(ChatModelChoice {
|
|
id: model.clone(),
|
|
label: model,
|
|
});
|
|
}
|
|
models.sort_by(|left, right| left.label.cmp(&right.label));
|
|
models.dedup_by(|left, right| left.id == right.id);
|
|
models
|
|
}
|
|
|
|
fn create_chat_conversation(&mut self) -> Task<Message> {
|
|
let model = self
|
|
.db
|
|
.as_ref()
|
|
.and_then(|db| ai::load_ai_settings(db.conn(), self.offline_mode).ok())
|
|
.map(|settings| settings.active().endpoint.model.clone())
|
|
.filter(|model| !model.trim().is_empty())
|
|
.or_else(|| {
|
|
self.chat_model_options()
|
|
.into_iter()
|
|
.next()
|
|
.map(|choice| choice.id)
|
|
});
|
|
let title = model.as_deref().map_or_else(
|
|
|| t(self.ui_locale, "chat.new"),
|
|
|model| tw(self.ui_locale, "chat.newWithModel", &[("model", model)]),
|
|
);
|
|
let result = self
|
|
.db
|
|
.as_ref()
|
|
.ok_or_else(|| "database unavailable".to_string())
|
|
.and_then(|db| {
|
|
engine::chat::create_conversation_titled(db.conn(), model.as_deref(), &title)
|
|
.map_err(|error| error.to_string())
|
|
});
|
|
match result {
|
|
Ok(conversation) => {
|
|
self.refresh_chat_conversations();
|
|
Task::done(Message::OpenTab(Tab {
|
|
id: conversation.id,
|
|
tab_type: TabType::Chat,
|
|
title: conversation.title,
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
}))
|
|
}
|
|
Err(error) => {
|
|
self.notify(ToastLevel::Error, &error);
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn send_active_chat_message(&mut self) -> Task<Message> {
|
|
let Some(conversation_id) = self.active_chat_id().map(str::to_string) else {
|
|
return Task::none();
|
|
};
|
|
let Some(project_id) = self
|
|
.active_project
|
|
.as_ref()
|
|
.map(|project| project.id.clone())
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = self.data_dir.clone() else {
|
|
return Task::none();
|
|
};
|
|
let Some(state) = self.chat_editors.get_mut(&conversation_id) else {
|
|
return Task::none();
|
|
};
|
|
if state.streaming {
|
|
return Task::none();
|
|
}
|
|
let content = state.input.text().trim().to_string();
|
|
if content.is_empty() {
|
|
return Task::none();
|
|
}
|
|
state.input = iced::widget::text_editor::Content::new();
|
|
state.streaming = true;
|
|
state.clear_streaming();
|
|
state.active_tool = None;
|
|
state.error = None;
|
|
let db_path = self.db_path.clone();
|
|
let offline_mode = self.offline_mode;
|
|
let result_id = conversation_id.clone();
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
engine::chat::send_chat_message(
|
|
db.conn(),
|
|
&data_dir,
|
|
&project_id,
|
|
offline_mode,
|
|
&conversation_id,
|
|
&content,
|
|
engine::chat::ChatSendOptions::default(),
|
|
)
|
|
.map_err(|error| error.to_string())
|
|
})
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
},
|
|
move |result| Message::ChatFinished {
|
|
conversation_id: result_id.clone(),
|
|
result,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn process_chat_events(&mut self) {
|
|
let events = self.chat_events.try_iter().collect::<Vec<_>>();
|
|
for event in events {
|
|
match event {
|
|
engine::chat::ChatEvent::Content {
|
|
conversation_id,
|
|
content,
|
|
} => {
|
|
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
|
|
state.set_streaming_content(content);
|
|
}
|
|
}
|
|
engine::chat::ChatEvent::ToolStarted {
|
|
conversation_id,
|
|
name,
|
|
surface_id,
|
|
arguments,
|
|
} => {
|
|
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
|
|
state.active_tool = Some(name.clone());
|
|
state.add_streaming_surface(&name, &arguments, surface_id);
|
|
}
|
|
}
|
|
engine::chat::ChatEvent::ToolFinished {
|
|
conversation_id, ..
|
|
} => {
|
|
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
|
|
state.active_tool = None;
|
|
}
|
|
}
|
|
engine::chat::ChatEvent::Failed {
|
|
conversation_id,
|
|
message,
|
|
} => {
|
|
let message = localize_chat_error(self.ui_locale, &message);
|
|
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
|
|
state.error = Some(message);
|
|
}
|
|
}
|
|
engine::chat::ChatEvent::Navigate {
|
|
destination,
|
|
entity_id,
|
|
} => self.dispatch_chat_navigation(&destination, entity_id.as_deref()),
|
|
engine::chat::ChatEvent::Started { .. }
|
|
| engine::chat::ChatEvent::Finished { .. }
|
|
| engine::chat::ChatEvent::Cancelled { .. } => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn dispatch_chat_navigation(&mut self, destination: &str, entity_id: Option<&str>) {
|
|
match destination {
|
|
"toggle_sidebar" => {
|
|
self.sidebar_visible = !self.sidebar_visible;
|
|
return;
|
|
}
|
|
"toggle_panel" => {
|
|
self.panel_visible = !self.panel_visible;
|
|
return;
|
|
}
|
|
"toggle_assistant_sidebar" => {
|
|
if self.sidebar_view == SidebarView::Chat {
|
|
self.sidebar_visible = !self.sidebar_visible;
|
|
} else {
|
|
self.sidebar_view = SidebarView::Chat;
|
|
self.sidebar_visible = true;
|
|
}
|
|
return;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
self.sidebar_view = match destination {
|
|
"posts" => SidebarView::Posts,
|
|
"pages" => SidebarView::Pages,
|
|
"media" => SidebarView::Media,
|
|
"templates" => SidebarView::Templates,
|
|
"scripts" => SidebarView::Scripts,
|
|
"tags" => SidebarView::Tags,
|
|
"chat" => SidebarView::Chat,
|
|
"import" => SidebarView::Import,
|
|
"git" => SidebarView::Git,
|
|
"settings" => SidebarView::Settings,
|
|
_ => return,
|
|
};
|
|
self.sidebar_visible = true;
|
|
if destination == "settings" && entity_id.is_none() {
|
|
self.open_singleton_tab(TabType::Settings, "common.settings");
|
|
if self.settings_state.is_none() {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
}
|
|
return;
|
|
}
|
|
if destination == "tags" && entity_id.is_none() {
|
|
self.open_singleton_tab(TabType::Tags, "tabBar.tags");
|
|
return;
|
|
}
|
|
let Some(id) = entity_id else { return };
|
|
let tab = self.db.as_ref().and_then(|db| match destination {
|
|
"posts" => bds_core::db::queries::post::get_post_by_id(db.conn(), id)
|
|
.ok()
|
|
.map(|item| (TabType::Post, item.title)),
|
|
"media" => bds_core::db::queries::media::get_media_by_id(db.conn(), id)
|
|
.ok()
|
|
.map(|item| {
|
|
(
|
|
TabType::Media,
|
|
item.title.unwrap_or_else(|| item.original_name.clone()),
|
|
)
|
|
}),
|
|
"templates" => bds_core::db::queries::template::get_template_by_id(db.conn(), id)
|
|
.ok()
|
|
.map(|item| (TabType::Templates, item.title)),
|
|
"scripts" => bds_core::db::queries::script::get_script_by_id(db.conn(), id)
|
|
.ok()
|
|
.map(|item| (TabType::Scripts, item.title)),
|
|
"chat" => engine::chat::get_conversation(db.conn(), id)
|
|
.ok()
|
|
.map(|item| (TabType::Chat, item.title)),
|
|
_ => None,
|
|
});
|
|
let Some((tab_type, title)) = tab else {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "chat.navigation.invalid"),
|
|
);
|
|
return;
|
|
};
|
|
let index = tabs::open_tab(
|
|
&mut self.tabs,
|
|
Tab {
|
|
id: id.to_string(),
|
|
tab_type,
|
|
title,
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
},
|
|
);
|
|
if let Some(tab) = self.tabs.get(index).cloned() {
|
|
self.active_tab = Some(tab.id.clone());
|
|
self.load_editor_for_tab(&tab);
|
|
}
|
|
}
|
|
|
|
fn apply_ui_locale(&mut self, locale: UiLocale) {
|
|
self.ui_locale = locale;
|
|
self.locale_dropdown_open = false;
|
|
menu::update_menu_labels(&self.menu_registry, locale);
|
|
for tab in &mut self.tabs {
|
|
if let Some(key) = tab.tab_type.i18n_key() {
|
|
tab.title = t(locale, key);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn process_domain_events(&mut self) -> Task<Message> {
|
|
let remote_messages = self
|
|
.remote_client
|
|
.as_ref()
|
|
.map(|client| client.drain_events())
|
|
.unwrap_or_default();
|
|
for message in remote_messages {
|
|
match message {
|
|
bds_server::protocol::ServerMessage::Event { sequence, event } => {
|
|
self.add_output(&tw(
|
|
self.ui_locale,
|
|
"remoteConnection.eventReceived",
|
|
&[
|
|
("sequence", &sequence.to_string()),
|
|
("event", &format!("{event:?}")),
|
|
],
|
|
));
|
|
}
|
|
bds_server::protocol::ServerMessage::Tasks { tasks, .. } => {
|
|
self.add_output(&tw(
|
|
self.ui_locale,
|
|
"remoteConnection.taskUpdate",
|
|
&[("count", &tasks.len().to_string())],
|
|
));
|
|
}
|
|
bds_server::protocol::ServerMessage::Error { code, message, .. } => {
|
|
self.notify(ToastLevel::Error, &message);
|
|
if remote_error_closes_connection(&code) {
|
|
self.remote_client = None;
|
|
self.remote_project = None;
|
|
self.remote_projects.clear();
|
|
self.remote_display_name = None;
|
|
self.menu_registry
|
|
.set_enabled(MenuAction::DisconnectServer, false);
|
|
if let Some(locale) = self.remote_previous_locale.take() {
|
|
self.apply_ui_locale(locale);
|
|
}
|
|
}
|
|
}
|
|
bds_server::protocol::ServerMessage::Response { .. } => {}
|
|
}
|
|
}
|
|
if let Some(db) = &self.db {
|
|
let _ = engine::cli_sync::poll_notifications(db.conn());
|
|
}
|
|
let events = self.domain_events.drain();
|
|
if events.is_empty() {
|
|
return Task::none();
|
|
}
|
|
|
|
let mut refresh_content = false;
|
|
for event in events {
|
|
refresh_content |= self.handle_domain_event(event);
|
|
}
|
|
if refresh_content {
|
|
self.refresh_counts()
|
|
} else {
|
|
Task::none()
|
|
}
|
|
}
|
|
|
|
fn handle_domain_event(&mut self, event: DomainEvent) -> bool {
|
|
match event {
|
|
DomainEvent::SettingsChanged { project_id, key } => {
|
|
let relevant = project_id.is_none()
|
|
|| project_id.as_deref()
|
|
== self
|
|
.active_project
|
|
.as_ref()
|
|
.map(|project| project.id.as_str());
|
|
if !relevant {
|
|
return false;
|
|
}
|
|
if key == engine::settings::UI_LANGUAGE_KEY
|
|
&& let Some(db) = &self.db
|
|
&& let Ok(Some(language)) = engine::settings::ui_language(db.conn())
|
|
{
|
|
self.apply_ui_locale(normalize_language(&language));
|
|
}
|
|
if self
|
|
.tabs
|
|
.iter()
|
|
.any(|tab| tab.tab_type == TabType::Settings)
|
|
{
|
|
let active_section = self
|
|
.settings_state
|
|
.as_ref()
|
|
.and_then(|state| state.active_section.clone());
|
|
let mut state = self.hydrate_settings_state();
|
|
if let Some(section) = active_section {
|
|
state.focus_section(section);
|
|
}
|
|
self.settings_state = Some(state);
|
|
}
|
|
false
|
|
}
|
|
DomainEvent::EntityChanged {
|
|
project_id,
|
|
entity: DomainEntity::Project,
|
|
..
|
|
} => {
|
|
let previous_active = self
|
|
.active_project
|
|
.as_ref()
|
|
.map(|project| project.id.clone());
|
|
if let Some(db) = &self.db {
|
|
self.projects = engine::project::list_projects(db.conn()).unwrap_or_default();
|
|
self.active_project = engine::project::get_active_project(db.conn())
|
|
.ok()
|
|
.flatten();
|
|
self.data_dir = self
|
|
.active_project
|
|
.as_ref()
|
|
.and_then(|project| project.data_path.as_deref())
|
|
.map(PathBuf::from);
|
|
}
|
|
if let Some(data_dir) = self.data_dir.as_deref()
|
|
&& let Ok(metadata) = engine::meta::read_project_json(data_dir)
|
|
{
|
|
let applied = StyleViewState::new(metadata.pico_theme.as_deref());
|
|
self.theme_badge = applied.applied_theme.clone();
|
|
if let Some(state) = self.style_view_state.as_mut() {
|
|
state.refresh_applied_theme(metadata.pico_theme.as_deref());
|
|
}
|
|
}
|
|
previous_active.as_deref() == Some(project_id.as_str())
|
|
|| self
|
|
.active_project
|
|
.as_ref()
|
|
.map(|project| project.id.as_str())
|
|
== Some(project_id.as_str())
|
|
}
|
|
DomainEvent::EntityChanged {
|
|
project_id,
|
|
entity,
|
|
entity_id,
|
|
action,
|
|
} => {
|
|
if self
|
|
.active_project
|
|
.as_ref()
|
|
.map(|project| project.id.as_str())
|
|
!= Some(project_id.as_str())
|
|
{
|
|
return false;
|
|
}
|
|
self.refresh_entity_editor(entity, &entity_id, action);
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
fn refresh_entity_editor(
|
|
&mut self,
|
|
entity: DomainEntity,
|
|
entity_id: &str,
|
|
action: NotificationAction,
|
|
) {
|
|
if action == NotificationAction::Deleted {
|
|
match entity {
|
|
DomainEntity::Post => {
|
|
self.post_editors.remove(entity_id);
|
|
}
|
|
DomainEntity::Media => {
|
|
self.media_editors.remove(entity_id);
|
|
}
|
|
DomainEntity::Template => {
|
|
self.template_editors.remove(entity_id);
|
|
}
|
|
DomainEntity::Script => {
|
|
self.script_editors.remove(entity_id);
|
|
}
|
|
DomainEntity::Tag => {
|
|
self.tags_view_state = None;
|
|
}
|
|
DomainEntity::Project | DomainEntity::Setting => {}
|
|
}
|
|
if !matches!(entity, DomainEntity::Tag) {
|
|
self.close_entity_tab(entity_id);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if entity == DomainEntity::Tag {
|
|
self.tags_view_state = None;
|
|
self.refresh_post_editor_tag_options();
|
|
if let Some(tab) = self
|
|
.tabs
|
|
.iter()
|
|
.find(|tab| tab.tab_type == TabType::Tags)
|
|
.cloned()
|
|
{
|
|
self.load_editor_for_tab(&tab);
|
|
}
|
|
return;
|
|
}
|
|
|
|
let tab = self.tabs.iter().find(|tab| tab.id == entity_id).cloned();
|
|
let Some(tab) = tab else { return };
|
|
let previous_post_state = (entity == DomainEntity::Post)
|
|
.then(|| self.post_editors.get(entity_id).cloned())
|
|
.flatten();
|
|
let dirty = match entity {
|
|
DomainEntity::Post => self
|
|
.post_editors
|
|
.get(entity_id)
|
|
.is_some_and(|state| state.is_dirty),
|
|
DomainEntity::Media => self
|
|
.media_editors
|
|
.get(entity_id)
|
|
.is_some_and(|state| state.is_dirty),
|
|
DomainEntity::Template => self
|
|
.template_editors
|
|
.get(entity_id)
|
|
.is_some_and(|state| state.is_dirty),
|
|
DomainEntity::Script => self
|
|
.script_editors
|
|
.get(entity_id)
|
|
.is_some_and(|state| state.is_dirty),
|
|
DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => false,
|
|
};
|
|
if dirty {
|
|
return;
|
|
}
|
|
match entity {
|
|
DomainEntity::Post => {
|
|
self.post_editors.remove(entity_id);
|
|
}
|
|
DomainEntity::Media => {
|
|
self.media_editors.remove(entity_id);
|
|
}
|
|
DomainEntity::Template => {
|
|
self.template_editors.remove(entity_id);
|
|
}
|
|
DomainEntity::Script => {
|
|
self.script_editors.remove(entity_id);
|
|
}
|
|
DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => {}
|
|
}
|
|
self.load_editor_for_tab(&tab);
|
|
if let (Some(previous), Some(current)) =
|
|
(previous_post_state, self.post_editors.get_mut(entity_id))
|
|
{
|
|
current.restore_view_state(&previous);
|
|
}
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == entity_id) {
|
|
tab.title = match entity {
|
|
DomainEntity::Post => self
|
|
.post_editors
|
|
.get(entity_id)
|
|
.map(|state| state.title.clone()),
|
|
DomainEntity::Media => self
|
|
.media_editors
|
|
.get(entity_id)
|
|
.map(|state| state.title.clone()),
|
|
DomainEntity::Template => self
|
|
.template_editors
|
|
.get(entity_id)
|
|
.map(|state| state.title.clone()),
|
|
DomainEntity::Script => self
|
|
.script_editors
|
|
.get(entity_id)
|
|
.map(|state| state.title.clone()),
|
|
DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => None,
|
|
}
|
|
.unwrap_or_else(|| tab.title.clone());
|
|
}
|
|
}
|
|
|
|
fn dispatch_menu_action(&mut self, action: MenuAction) -> Task<Message> {
|
|
match action {
|
|
// File
|
|
MenuAction::NewPost => self.create_sidebar_post(false),
|
|
MenuAction::ImportMedia => crate::platform::dialog::pick_media_files(
|
|
t(self.ui_locale, "dialog.importMedia"),
|
|
t(self.ui_locale, "dialog.imageFilter"),
|
|
),
|
|
MenuAction::Save => match self.active_tab_type() {
|
|
Some(TabType::Post) => Task::done(Message::PostEditor(PostEditorMsg::Save)),
|
|
Some(TabType::Media) => Task::done(Message::MediaEditor(MediaEditorMsg::Save)),
|
|
Some(TabType::Templates) => {
|
|
Task::done(Message::TemplateEditor(TemplateEditorMsg::Save))
|
|
}
|
|
Some(TabType::Scripts) => Task::done(Message::ScriptEditor(ScriptEditorMsg::Save)),
|
|
Some(TabType::MenuEditor) => Task::done(Message::MenuEditor(MenuEditorMsg::Save)),
|
|
_ => Task::none(),
|
|
},
|
|
MenuAction::OpenInBrowser => self.open_preview_in_browser(),
|
|
MenuAction::OpenDataFolder => {
|
|
if let Some(ref dir) = self.data_dir {
|
|
let _ = open::that(dir);
|
|
}
|
|
Task::none()
|
|
}
|
|
MenuAction::ConnectServer => {
|
|
let target = self
|
|
.remote_client
|
|
.as_ref()
|
|
.map(|client| client.target().label())
|
|
.unwrap_or_default();
|
|
self.active_modal = Some(modal::ModalState::RemoteConnection {
|
|
target,
|
|
connecting: false,
|
|
connected: self.remote_client.is_some(),
|
|
error: None,
|
|
projects: self.remote_projects.clone(),
|
|
selected_project_id: self
|
|
.remote_project
|
|
.as_ref()
|
|
.map(|project| project.id.clone()),
|
|
});
|
|
Task::none()
|
|
}
|
|
MenuAction::DisconnectServer => Task::done(Message::RemoteDisconnectRequested),
|
|
// Edit
|
|
MenuAction::Undo => {
|
|
native_edit::queue_command(
|
|
&self.native_edit_commands,
|
|
native_edit::EditCommand::Undo,
|
|
);
|
|
Task::none()
|
|
}
|
|
MenuAction::Redo => {
|
|
native_edit::queue_command(
|
|
&self.native_edit_commands,
|
|
native_edit::EditCommand::Redo,
|
|
);
|
|
Task::none()
|
|
}
|
|
MenuAction::Cut => {
|
|
native_edit::queue_command(
|
|
&self.native_edit_commands,
|
|
native_edit::EditCommand::Cut,
|
|
);
|
|
Task::none()
|
|
}
|
|
MenuAction::Copy => {
|
|
native_edit::queue_command(
|
|
&self.native_edit_commands,
|
|
native_edit::EditCommand::Copy,
|
|
);
|
|
Task::none()
|
|
}
|
|
MenuAction::Paste => {
|
|
native_edit::queue_command(
|
|
&self.native_edit_commands,
|
|
native_edit::EditCommand::Paste,
|
|
);
|
|
Task::none()
|
|
}
|
|
MenuAction::SelectAll => {
|
|
native_edit::queue_command(
|
|
&self.native_edit_commands,
|
|
native_edit::EditCommand::SelectAll,
|
|
);
|
|
Task::none()
|
|
}
|
|
MenuAction::Find => {
|
|
self.active_modal = Some(modal::ModalState::FindReplace {
|
|
query: String::new(),
|
|
replacement: String::new(),
|
|
show_replace: false,
|
|
});
|
|
Task::none()
|
|
}
|
|
MenuAction::Replace => {
|
|
self.active_modal = Some(modal::ModalState::FindReplace {
|
|
query: String::new(),
|
|
replacement: String::new(),
|
|
show_replace: true,
|
|
});
|
|
Task::none()
|
|
}
|
|
MenuAction::EditPreferences => {
|
|
self.open_singleton_tab(TabType::Settings, "common.settings");
|
|
Task::none()
|
|
}
|
|
// View
|
|
MenuAction::ViewPosts => Task::done(Message::SetActiveView(SidebarView::Posts)),
|
|
MenuAction::ViewMedia => Task::done(Message::SetActiveView(SidebarView::Media)),
|
|
MenuAction::ToggleSidebar => Task::done(Message::ToggleSidebar),
|
|
MenuAction::TogglePanel => Task::done(Message::TogglePanel),
|
|
// Blog
|
|
MenuAction::PublishSelected => match self.active_tab_type() {
|
|
Some(TabType::Post) => Task::done(Message::PostEditor(PostEditorMsg::Publish)),
|
|
_ => Task::none(),
|
|
},
|
|
MenuAction::PreviewPost => self.preview_active_post(),
|
|
MenuAction::EditMenu => {
|
|
self.open_singleton_tab(TabType::MenuEditor, "tabBar.menuEditor");
|
|
Task::done(Message::MenuEditor(MenuEditorMsg::Reload))
|
|
}
|
|
MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase),
|
|
MenuAction::ReindexText => Task::done(Message::ReindexText),
|
|
MenuAction::RebuildEmbeddingIndex => Task::done(Message::EmbeddingReindex),
|
|
MenuAction::FindDuplicates => {
|
|
self.open_singleton_tab(TabType::FindDuplicates, "tabBar.findDuplicates");
|
|
Task::done(Message::DuplicatesRefresh)
|
|
}
|
|
MenuAction::MetadataDiff => Task::done(Message::RunMetadataDiff),
|
|
MenuAction::RegenerateCalendar => Task::done(Message::RegenerateCalendar),
|
|
MenuAction::ValidateTranslations => Task::done(Message::ValidateTranslations),
|
|
MenuAction::FillMissingTranslations => {
|
|
let offline_mode = self.offline_mode;
|
|
let locale = self.ui_locale;
|
|
self.spawn_grouped_engine_task(
|
|
"engine.fillMissingTranslationsStarted",
|
|
"AI",
|
|
move |db_path, project_id, data_dir, task_manager, task_id| {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
let meta = engine::meta::read_project_json(&data_dir)
|
|
.map_err(|error| error.to_string())?;
|
|
let main_language = meta.main_language.as_deref().unwrap_or("en");
|
|
let task_manager_for_progress = Arc::clone(&task_manager);
|
|
let report = engine::auto_translation::fill_missing_translations(
|
|
db.conn(),
|
|
&data_dir,
|
|
&project_id,
|
|
main_language,
|
|
&meta.blog_languages,
|
|
offline_mode,
|
|
move |progress, _message| {
|
|
task_manager_for_progress.report_progress(
|
|
task_id,
|
|
Some(progress),
|
|
Some(t(locale, "engine.translatingContent")),
|
|
);
|
|
!task_manager_for_progress.is_cancelled(task_id)
|
|
},
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
Ok(tw(
|
|
locale,
|
|
"engine.fillMissingTranslationsComplete",
|
|
&[
|
|
("posts", &report.translated_posts.to_string()),
|
|
("media", &report.translated_media.to_string()),
|
|
("failed", &report.failed_count.to_string()),
|
|
],
|
|
))
|
|
},
|
|
)
|
|
}
|
|
MenuAction::GenerateSitemap => Task::done(Message::GenerateSite),
|
|
MenuAction::ValidateSite => {
|
|
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
|
|
self.start_site_validation()
|
|
}
|
|
MenuAction::UploadSite => {
|
|
if self.offline_mode {
|
|
self.notify(
|
|
ToastLevel::Warning,
|
|
&t(self.ui_locale, "engine.uploadOffline"),
|
|
);
|
|
return Task::none();
|
|
} else if let Some(data_dir) = &self.data_dir {
|
|
let pub_prefs = engine::meta::read_publishing_json(data_dir).ok();
|
|
let has_creds = pub_prefs
|
|
.as_ref()
|
|
.map(|p| {
|
|
p.ssh_host.as_ref().is_some_and(|h| !h.is_empty())
|
|
&& p.ssh_user.as_ref().is_some_and(|u| !u.is_empty())
|
|
})
|
|
.unwrap_or(false);
|
|
if !has_creds {
|
|
self.notify(
|
|
ToastLevel::Warning,
|
|
&t(self.ui_locale, "engine.uploadMissingCredentials"),
|
|
);
|
|
return Task::none();
|
|
}
|
|
}
|
|
let locale = self.ui_locale;
|
|
self.spawn_engine_task(
|
|
"engine.uploadStarted",
|
|
move |db_path, _project_id, data_dir, task_manager, task_id| {
|
|
let preferences = engine::meta::read_publishing_json(&data_dir)
|
|
.map_err(|error| error.to_string())?;
|
|
let cache_dir = db_path.parent().map(PathBuf::from).ok_or_else(|| {
|
|
"private application directory unavailable".to_string()
|
|
})?;
|
|
let job = engine::publishing::upload_site(
|
|
&data_dir,
|
|
&cache_dir,
|
|
&preferences,
|
|
|current, total, kind| {
|
|
let target = match kind {
|
|
engine::publishing::UploadTargetKind::Html => "html",
|
|
engine::publishing::UploadTargetKind::Thumbnails => {
|
|
"thumbnails"
|
|
}
|
|
engine::publishing::UploadTargetKind::Media => "media",
|
|
};
|
|
task_manager.report_progress(
|
|
task_id,
|
|
Some(current as f32 / total.max(1) as f32),
|
|
Some(tw(
|
|
locale,
|
|
"engine.uploadingTarget",
|
|
&[("target", target)],
|
|
)),
|
|
);
|
|
},
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
Ok(format!("{} targets uploaded", job.completed_targets.len()))
|
|
},
|
|
)
|
|
}
|
|
// Help
|
|
MenuAction::About => {
|
|
self.add_output(&t(self.ui_locale, "menu.item.about"));
|
|
Task::none()
|
|
}
|
|
MenuAction::OpenDocumentation => {
|
|
self.open_singleton_tab(TabType::Documentation, "tabBar.documentation");
|
|
self.start_documentation_load(DocumentationKind::Guide)
|
|
}
|
|
MenuAction::OpenApiDocumentation => {
|
|
self.open_singleton_tab(TabType::ApiDocumentation, "tabBar.apiDocumentation");
|
|
self.start_documentation_load(DocumentationKind::Api)
|
|
}
|
|
MenuAction::OpenCliDocumentation => {
|
|
self.open_singleton_tab(TabType::CliDocumentation, "tabBar.cliDocumentation");
|
|
self.start_documentation_load(DocumentationKind::Cli)
|
|
}
|
|
MenuAction::OpenMcpDocumentation => {
|
|
self.open_singleton_tab(TabType::McpDocumentation, "tabBar.mcpDocumentation");
|
|
self.start_documentation_load(DocumentationKind::Mcp)
|
|
}
|
|
MenuAction::ViewOnGitHub => {
|
|
let _ = open::that("https://github.com/nickarumern/bds");
|
|
Task::none()
|
|
}
|
|
MenuAction::ReportIssue => {
|
|
let _ = open::that("https://github.com/nickarumern/bds/issues");
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn open_singleton_tab(&mut self, tab_type: TabType, i18n_key: &str) {
|
|
let title = t(self.ui_locale, i18n_key);
|
|
let tab = Tab {
|
|
id: tab_type.singleton_id().to_string(),
|
|
tab_type,
|
|
title,
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
};
|
|
let idx = tabs::open_tab(&mut self.tabs, tab);
|
|
if let Some(t) = self.tabs.get(idx) {
|
|
self.active_tab = Some(t.id.clone());
|
|
}
|
|
self.enforce_panel_tab_fallback();
|
|
}
|
|
|
|
fn create_sidebar_post(&mut self, is_page: bool) -> Task<Message> {
|
|
let (Some(db), Some(project), Some(data_dir)) =
|
|
(&self.db, &self.active_project, &self.data_dir)
|
|
else {
|
|
return Task::none();
|
|
};
|
|
|
|
let categories = if is_page {
|
|
vec!["page".to_string()]
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
match engine::post::create_post(
|
|
db.conn(),
|
|
data_dir,
|
|
&project.id,
|
|
"",
|
|
Some(""),
|
|
Vec::new(),
|
|
categories,
|
|
None,
|
|
None,
|
|
None,
|
|
) {
|
|
Ok(post) => {
|
|
let tab = Tab {
|
|
id: post.id.clone(),
|
|
tab_type: TabType::Post,
|
|
title: t(self.ui_locale, "post.untitled"),
|
|
is_transient: true,
|
|
is_dirty: false,
|
|
};
|
|
let idx = tabs::open_tab(&mut self.tabs, tab);
|
|
if let Some(tab) = self.tabs.get(idx).cloned() {
|
|
self.active_tab = Some(tab.id.clone());
|
|
self.load_editor_for_tab(&tab);
|
|
}
|
|
self.sync_menu_state();
|
|
self.refresh_counts()
|
|
}
|
|
Err(error) => {
|
|
self.notify_operation_failed("modal.postInsertLink.createPost", error);
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn create_sidebar_script(&mut self) -> Task<Message> {
|
|
let (Some(db), Some(project)) = (&self.db, &self.active_project) else {
|
|
return Task::none();
|
|
};
|
|
|
|
match engine::script::create_script(
|
|
db.conn(),
|
|
&project.id,
|
|
&t(self.ui_locale, "editor.untitled"),
|
|
bds_core::model::ScriptKind::Utility,
|
|
"print(\"new script\")",
|
|
Some("render"),
|
|
) {
|
|
Ok(script) => {
|
|
let tab = Tab {
|
|
id: script.id.clone(),
|
|
tab_type: TabType::Scripts,
|
|
title: if script.title.is_empty() {
|
|
t(self.ui_locale, "editor.untitled")
|
|
} else {
|
|
script.title.clone()
|
|
},
|
|
is_transient: true,
|
|
is_dirty: false,
|
|
};
|
|
let idx = tabs::open_tab(&mut self.tabs, tab);
|
|
if let Some(tab) = self.tabs.get(idx).cloned() {
|
|
self.active_tab = Some(tab.id.clone());
|
|
self.load_editor_for_tab(&tab);
|
|
}
|
|
self.sync_menu_state();
|
|
self.refresh_counts()
|
|
}
|
|
Err(error) => {
|
|
self.notify_operation_failed("common.createScript", error);
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn create_sidebar_template(&mut self) -> Task<Message> {
|
|
let (Some(db), Some(project)) = (&self.db, &self.active_project) else {
|
|
return Task::none();
|
|
};
|
|
|
|
match engine::template::create_template(
|
|
db.conn(),
|
|
&project.id,
|
|
&t(self.ui_locale, "editor.untitled"),
|
|
bds_core::model::TemplateKind::Post,
|
|
"",
|
|
) {
|
|
Ok(template) => {
|
|
let tab = Tab {
|
|
id: template.id.clone(),
|
|
tab_type: TabType::Templates,
|
|
title: if template.title.is_empty() {
|
|
t(self.ui_locale, "editor.untitled")
|
|
} else {
|
|
template.title.clone()
|
|
},
|
|
is_transient: true,
|
|
is_dirty: false,
|
|
};
|
|
let idx = tabs::open_tab(&mut self.tabs, tab);
|
|
if let Some(tab) = self.tabs.get(idx).cloned() {
|
|
self.active_tab = Some(tab.id.clone());
|
|
self.load_editor_for_tab(&tab);
|
|
}
|
|
self.sync_menu_state();
|
|
self.refresh_counts()
|
|
}
|
|
Err(error) => {
|
|
self.notify_operation_failed("common.createTemplate", error);
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn create_sidebar_import(&mut self) -> Task<Message> {
|
|
let (Some(db), Some(project)) = (&self.db, &self.active_project) else {
|
|
return Task::none();
|
|
};
|
|
match engine::wordpress_import::create_definition(
|
|
db.conn(),
|
|
&project.id,
|
|
&t(self.ui_locale, "import.untitled"),
|
|
) {
|
|
Ok(definition) => {
|
|
self.sidebar_imports.push(definition.clone());
|
|
let tab = Tab {
|
|
id: definition.id.clone(),
|
|
tab_type: TabType::Import,
|
|
title: definition.name.clone(),
|
|
is_transient: true,
|
|
is_dirty: false,
|
|
};
|
|
let index = tabs::open_tab(&mut self.tabs, tab);
|
|
if let Some(tab) = self.tabs.get(index).cloned() {
|
|
self.active_tab = Some(tab.id.clone());
|
|
self.load_editor_for_tab(&tab);
|
|
}
|
|
self.sync_menu_state();
|
|
}
|
|
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn handle_import_editor_msg(&mut self, message: ImportEditorMsg) -> Task<Message> {
|
|
let Some(definition_id) = self.active_tab.clone().filter(|id| {
|
|
self.tabs
|
|
.iter()
|
|
.any(|tab| tab.id == *id && tab.tab_type == TabType::Import)
|
|
}) else {
|
|
return Task::none();
|
|
};
|
|
match message {
|
|
ImportEditorMsg::NameChanged(name) => {
|
|
let Some(db) = &self.db else {
|
|
return Task::none();
|
|
};
|
|
match engine::wordpress_import::update_definition(
|
|
db.conn(),
|
|
&definition_id,
|
|
Some(&name),
|
|
None,
|
|
None,
|
|
None,
|
|
) {
|
|
Ok(definition) => {
|
|
if let Some(state) = self.import_editors.get_mut(&definition_id) {
|
|
state.definition = definition.clone();
|
|
}
|
|
if let Some(item) = self
|
|
.sidebar_imports
|
|
.iter_mut()
|
|
.find(|item| item.id == definition_id)
|
|
{
|
|
*item = definition;
|
|
}
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == definition_id)
|
|
{
|
|
tab.title = name;
|
|
}
|
|
}
|
|
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
|
}
|
|
Task::none()
|
|
}
|
|
ImportEditorMsg::PickUploads => crate::platform::dialog::pick_import_uploads_folder(
|
|
definition_id,
|
|
t(self.ui_locale, "import.uploadsFolder"),
|
|
),
|
|
ImportEditorMsg::PickWxr => crate::platform::dialog::pick_import_wxr_file(
|
|
definition_id,
|
|
t(self.ui_locale, "import.wxrFile"),
|
|
t(self.ui_locale, "import.wxrFilter"),
|
|
),
|
|
ImportEditorMsg::Analyze => self.start_import_analysis(&definition_id),
|
|
ImportEditorMsg::Execute => self.start_import_execution(&definition_id),
|
|
ImportEditorMsg::AutoMapTaxonomy => self.start_import_auto_mapping(&definition_id),
|
|
ImportEditorMsg::DeleteRequested => {
|
|
let name = self
|
|
.import_editors
|
|
.get(&definition_id)
|
|
.map(|state| state.definition.name.clone())
|
|
.unwrap_or_default();
|
|
self.active_modal = Some(modal::ModalState::Confirm {
|
|
title: t(self.ui_locale, "import.deleteTitle"),
|
|
message: tw(self.ui_locale, "import.deleteMessage", &[("name", &name)]),
|
|
on_confirm: modal::ConfirmAction::DeleteImport(definition_id),
|
|
});
|
|
Task::none()
|
|
}
|
|
ImportEditorMsg::SetResolution {
|
|
kind,
|
|
identity,
|
|
resolution,
|
|
} => {
|
|
let mut report = self
|
|
.import_editors
|
|
.get(&definition_id)
|
|
.and_then(|state| state.report.clone());
|
|
if let Some(report) = report.as_mut() {
|
|
match engine::wordpress_import::set_conflict_resolution(
|
|
report, kind, &identity, resolution,
|
|
)
|
|
.and_then(|_| self.persist_import_report(&definition_id, report))
|
|
{
|
|
Ok(definition) => {
|
|
if let Some(state) = self.import_editors.get_mut(&definition_id) {
|
|
state.definition = definition;
|
|
state.report = Some(report.clone());
|
|
}
|
|
}
|
|
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
ImportEditorMsg::SetTaxonomyMapping {
|
|
kind,
|
|
source,
|
|
target,
|
|
} => {
|
|
let mut report = self
|
|
.import_editors
|
|
.get(&definition_id)
|
|
.and_then(|state| state.report.clone());
|
|
if let Some(report) = report.as_mut() {
|
|
match engine::wordpress_import::set_taxonomy_mapping(
|
|
report,
|
|
kind,
|
|
&source,
|
|
target.as_deref(),
|
|
)
|
|
.and_then(|_| self.persist_import_report(&definition_id, report))
|
|
{
|
|
Ok(definition) => {
|
|
if let Some(state) = self.import_editors.get_mut(&definition_id) {
|
|
state.definition = definition;
|
|
state.report = Some(report.clone());
|
|
}
|
|
}
|
|
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
ImportEditorMsg::ToggleSection(section) => {
|
|
if let Some(state) = self.import_editors.get_mut(&definition_id)
|
|
&& !state.expanded.remove(§ion)
|
|
{
|
|
state.expanded.insert(section);
|
|
}
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn reload_menu_editor(&mut self) {
|
|
let result = match (&self.db, &self.active_project, &self.data_dir) {
|
|
(Some(db), Some(project), Some(data_dir)) => {
|
|
crate::views::menu_editor::load(db, &project.id, data_dir)
|
|
}
|
|
_ => Err(t(self.ui_locale, "menuEditor.noProject")),
|
|
};
|
|
match result {
|
|
Ok(state) => self.menu_editor_state = state,
|
|
Err(error) => {
|
|
self.menu_editor_state.status = MenuEditorStatus::LoadFailed;
|
|
self.menu_editor_state.error = Some(error.clone());
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&tw(
|
|
self.ui_locale,
|
|
"menuEditor.loadFailedDetail",
|
|
&[("error", &error)],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_menu_editor_msg(&mut self, message: MenuEditorMsg) -> Task<Message> {
|
|
match message {
|
|
MenuEditorMsg::Reload => self.reload_menu_editor(),
|
|
MenuEditorMsg::Select(id) => self.menu_editor_state.selected_id = Some(id),
|
|
MenuEditorMsg::StartDraft(kind) => {
|
|
self.menu_editor_state.start_draft(kind);
|
|
}
|
|
MenuEditorMsg::DraftChanged(value) => self.menu_editor_state.draft_changed(value),
|
|
MenuEditorMsg::ChoosePage(id) => {
|
|
let _ = self.menu_editor_state.choose_page(&id);
|
|
}
|
|
MenuEditorMsg::ChooseCategory(name) => {
|
|
let _ = self.menu_editor_state.choose_category(&name);
|
|
}
|
|
MenuEditorMsg::SubmitDraft => {
|
|
let kind = self
|
|
.menu_editor_state
|
|
.draft
|
|
.as_ref()
|
|
.map(|draft| draft.kind);
|
|
match kind {
|
|
Some(crate::views::menu_editor::DraftKind::Page) => {
|
|
let label = t(self.ui_locale, "menuEditor.newSubmenu");
|
|
let _ = self.menu_editor_state.submit_submenu(&label);
|
|
}
|
|
Some(crate::views::menu_editor::DraftKind::Category) => {
|
|
let previous = self.menu_editor_state.clone();
|
|
if let Ok((name, is_new)) = self.menu_editor_state.submit_category()
|
|
&& is_new
|
|
&& let Some(data_dir) = &self.data_dir
|
|
&& let Err(error) = engine::meta::add_category(data_dir, &name)
|
|
{
|
|
self.menu_editor_state = previous;
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&tw(
|
|
self.ui_locale,
|
|
"menuEditor.categoryCreateFailed",
|
|
&[("error", &error.to_string())],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
MenuEditorMsg::CancelDraft => {
|
|
self.menu_editor_state.cancel_draft();
|
|
}
|
|
MenuEditorMsg::Move(direction) => {
|
|
self.menu_editor_state.move_selected(direction);
|
|
}
|
|
MenuEditorMsg::Indent => {
|
|
self.menu_editor_state.indent_selected();
|
|
}
|
|
MenuEditorMsg::Unindent => {
|
|
self.menu_editor_state.unindent_selected();
|
|
}
|
|
MenuEditorMsg::Delete => {
|
|
self.menu_editor_state.delete_selected();
|
|
}
|
|
MenuEditorMsg::Save => {
|
|
if self.menu_editor_state.draft.is_some() {
|
|
self.notify(
|
|
ToastLevel::Warning,
|
|
&t(self.ui_locale, "menuEditor.finishDraft"),
|
|
);
|
|
return Task::none();
|
|
}
|
|
let Some(data_dir) = self.data_dir.clone() else {
|
|
return Task::none();
|
|
};
|
|
self.menu_editor_state.status = MenuEditorStatus::Saving;
|
|
let result =
|
|
engine::menu::write_menu(&data_dir, &self.menu_editor_state.persisted_items())
|
|
.and_then(|()| engine::menu::read_menu(&data_dir));
|
|
match result {
|
|
Ok(items) => {
|
|
let project_id = self
|
|
.menu_editor_state
|
|
.project_id
|
|
.clone()
|
|
.unwrap_or_default();
|
|
let pages = self.menu_editor_state.pages.clone();
|
|
let categories = self.menu_editor_state.categories.clone();
|
|
self.menu_editor_state =
|
|
MenuEditorState::from_persisted(project_id, items, pages, categories);
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "menuEditor.saved"));
|
|
}
|
|
Err(error) => {
|
|
self.menu_editor_state.status = MenuEditorStatus::Ready;
|
|
self.menu_editor_state.error = Some(error.to_string());
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&tw(
|
|
self.ui_locale,
|
|
"menuEditor.saveFailed",
|
|
&[("error", &error.to_string())],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
MenuEditorMsg::ToggleExpanded(id) => {
|
|
if !self.menu_editor_state.collapsed.insert(id.clone()) {
|
|
self.menu_editor_state.collapsed.remove(&id);
|
|
}
|
|
}
|
|
MenuEditorMsg::DragStart(id) => {
|
|
if id != crate::views::menu_editor::HOME_ID {
|
|
self.menu_editor_state.selected_id = Some(id.clone());
|
|
self.menu_editor_state.dragging_id = Some(id);
|
|
}
|
|
}
|
|
MenuEditorMsg::DragOver(id, position) => {
|
|
self.menu_editor_state
|
|
.drag_over(id, position, std::time::Instant::now());
|
|
}
|
|
MenuEditorMsg::DragLeave(id) => {
|
|
if self
|
|
.menu_editor_state
|
|
.drop_target
|
|
.as_ref()
|
|
.is_some_and(|(target, _)| target == &id)
|
|
{
|
|
self.menu_editor_state.drop_target = None;
|
|
self.menu_editor_state.hover_expand = None;
|
|
}
|
|
}
|
|
MenuEditorMsg::Drop => {
|
|
if let (Some(dragged), Some((target, position))) = (
|
|
self.menu_editor_state.dragging_id.clone(),
|
|
self.menu_editor_state.drop_target.clone(),
|
|
) {
|
|
self.menu_editor_state
|
|
.drop_item(&dragged, &target, position);
|
|
}
|
|
self.menu_editor_state.dragging_id = None;
|
|
self.menu_editor_state.drop_target = None;
|
|
self.menu_editor_state.hover_expand = None;
|
|
}
|
|
MenuEditorMsg::DragCancel => {
|
|
self.menu_editor_state.dragging_id = None;
|
|
self.menu_editor_state.drop_target = None;
|
|
self.menu_editor_state.hover_expand = None;
|
|
}
|
|
MenuEditorMsg::ExpandTick => {
|
|
self.menu_editor_state
|
|
.expand_hovered(std::time::Instant::now());
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn update_import_paths(
|
|
&mut self,
|
|
definition_id: &str,
|
|
wxr_path: Option<&Path>,
|
|
uploads_path: Option<&Path>,
|
|
) {
|
|
let Some(db) = &self.db else { return };
|
|
match engine::wordpress_import::update_definition(
|
|
db.conn(),
|
|
definition_id,
|
|
None,
|
|
wxr_path.map(Some),
|
|
uploads_path.map(Some),
|
|
wxr_path.map(|_| None),
|
|
) {
|
|
Ok(definition) => {
|
|
if let Some(state) = self.import_editors.get_mut(definition_id) {
|
|
state.definition = definition.clone();
|
|
if wxr_path.is_some() {
|
|
state.report = None;
|
|
state.result = None;
|
|
}
|
|
}
|
|
if let Some(item) = self
|
|
.sidebar_imports
|
|
.iter_mut()
|
|
.find(|item| item.id == definition_id)
|
|
{
|
|
*item = definition;
|
|
}
|
|
}
|
|
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
|
}
|
|
}
|
|
|
|
fn delete_import_definition(&mut self, definition_id: &str) -> Task<Message> {
|
|
self.active_modal = None;
|
|
let Some(db) = &self.db else {
|
|
return Task::none();
|
|
};
|
|
match engine::wordpress_import::delete_definition(db.conn(), definition_id) {
|
|
Ok(()) => {
|
|
self.import_editors.remove(definition_id);
|
|
self.sidebar_imports
|
|
.retain(|definition| definition.id != definition_id);
|
|
self.close_entity_tab(definition_id);
|
|
self.notify(
|
|
ToastLevel::Success,
|
|
&t(self.ui_locale, "import.toast.deleted"),
|
|
);
|
|
}
|
|
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn persist_import_report(
|
|
&self,
|
|
definition_id: &str,
|
|
report: &ImportReport,
|
|
) -> engine::EngineResult<ImportDefinition> {
|
|
let db = self
|
|
.db
|
|
.as_ref()
|
|
.ok_or_else(|| engine::EngineError::Validation("database unavailable".to_string()))?;
|
|
engine::wordpress_import::update_definition(
|
|
db.conn(),
|
|
definition_id,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(Some(report)),
|
|
)
|
|
}
|
|
|
|
fn start_import_analysis(&mut self, definition_id: &str) -> Task<Message> {
|
|
let Some(definition) = self
|
|
.import_editors
|
|
.get(definition_id)
|
|
.map(|state| state.definition.clone())
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let Some(wxr_path) = definition.wxr_file_path.clone() else {
|
|
if let Some(state) = self.import_editors.get_mut(definition_id) {
|
|
state.error = Some(t(self.ui_locale, "import.error.wxrRequired"));
|
|
}
|
|
return Task::none();
|
|
};
|
|
let Some(project) = self
|
|
.projects
|
|
.iter()
|
|
.find(|project| project.id == definition.project_id)
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = project.data_path.as_deref().map(PathBuf::from) else {
|
|
return Task::none();
|
|
};
|
|
let state = self.import_editors.get_mut(definition_id).unwrap();
|
|
state.is_analyzing = true;
|
|
state.progress = None;
|
|
state.error = None;
|
|
state.result = None;
|
|
let uploads = definition.uploads_folder_path.clone();
|
|
let db_path = self.db_path.clone();
|
|
let data_dir = data_dir.clone();
|
|
let project_id = project.id.clone();
|
|
let definition_id_owned = definition_id.to_string();
|
|
let (sender, receiver) = futures::channel::mpsc::unbounded();
|
|
let worker_definition_id = definition_id_owned.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
let progress_sender = sender.clone();
|
|
let result = (|| {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
let report = engine::wordpress_import::analyze_wxr(
|
|
db.conn(),
|
|
&data_dir,
|
|
&project_id,
|
|
Path::new(&wxr_path),
|
|
uploads.as_deref().map(Path::new),
|
|
Some(&mut |progress| {
|
|
let _ =
|
|
progress_sender.unbounded_send(ImportAnalysisEvent::Progress(progress));
|
|
}),
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
let definition = engine::wordpress_import::update_definition(
|
|
db.conn(),
|
|
&worker_definition_id,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(Some(&report)),
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
Ok((definition, report))
|
|
})();
|
|
let _ = sender.unbounded_send(ImportAnalysisEvent::Finished(Box::new(result)));
|
|
});
|
|
Task::run(receiver, move |event| Message::ImportAnalysisEvent {
|
|
definition_id: definition_id_owned.clone(),
|
|
event,
|
|
})
|
|
}
|
|
|
|
fn start_import_execution(&mut self, definition_id: &str) -> Task<Message> {
|
|
let Some(definition) = self
|
|
.import_editors
|
|
.get(definition_id)
|
|
.map(|state| state.definition.clone())
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let Some(report) = self
|
|
.import_editors
|
|
.get(definition_id)
|
|
.and_then(|state| state.report.clone())
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let Some(project) = self
|
|
.projects
|
|
.iter()
|
|
.find(|project| project.id == definition.project_id)
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = project.data_path.as_deref().map(PathBuf::from) else {
|
|
return Task::none();
|
|
};
|
|
let state = self.import_editors.get_mut(definition_id).unwrap();
|
|
state.is_executing = true;
|
|
state.progress = None;
|
|
state.error = None;
|
|
state.result = None;
|
|
let db_path = self.db_path.clone();
|
|
let data_dir = data_dir.clone();
|
|
let project_id = project.id.clone();
|
|
let default_author = engine::meta::read_project_json(&data_dir)
|
|
.ok()
|
|
.and_then(|metadata| metadata.default_author);
|
|
let definition_id_owned = definition_id.to_string();
|
|
let (sender, receiver) = futures::channel::mpsc::unbounded();
|
|
tokio::task::spawn_blocking(move || {
|
|
let progress_sender = sender.clone();
|
|
let result = (|| {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
engine::wordpress_import::execute_import(
|
|
db.conn(),
|
|
&data_dir,
|
|
&project_id,
|
|
&report,
|
|
default_author.as_deref(),
|
|
Some(&mut |progress| {
|
|
let _ = progress_sender
|
|
.unbounded_send(ImportExecutionEvent::Progress(progress));
|
|
}),
|
|
)
|
|
.map_err(|error| error.to_string())
|
|
})();
|
|
let _ = sender.unbounded_send(ImportExecutionEvent::Finished(result));
|
|
});
|
|
Task::run(receiver, move |event| Message::ImportExecutionEvent {
|
|
definition_id: definition_id_owned.clone(),
|
|
event,
|
|
})
|
|
}
|
|
|
|
fn start_import_auto_mapping(&mut self, definition_id: &str) -> Task<Message> {
|
|
let Some(definition) = self
|
|
.import_editors
|
|
.get(definition_id)
|
|
.map(|state| state.definition.clone())
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let Some(mut report) = self
|
|
.import_editors
|
|
.get(definition_id)
|
|
.and_then(|state| state.report.clone())
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let Some(project) = self
|
|
.projects
|
|
.iter()
|
|
.find(|project| project.id == definition.project_id)
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = project.data_path.as_deref().map(PathBuf::from) else {
|
|
return Task::none();
|
|
};
|
|
let state = self.import_editors.get_mut(definition_id).unwrap();
|
|
state.is_analyzing = true;
|
|
state.error = None;
|
|
let db_path = self.db_path.clone();
|
|
let data_dir = data_dir.clone();
|
|
let project_id = project.id.clone();
|
|
let offline_mode = self.offline_mode;
|
|
let definition_id_owned = definition_id.to_string();
|
|
let result_definition_id = definition_id_owned.clone();
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
let count = engine::wordpress_import::auto_map_taxonomy(
|
|
db.conn(),
|
|
&data_dir,
|
|
&project_id,
|
|
offline_mode,
|
|
&mut report,
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
let definition = engine::wordpress_import::update_definition(
|
|
db.conn(),
|
|
&definition_id_owned,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(Some(&report)),
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
Ok((definition, report, count))
|
|
})
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
},
|
|
move |result| Message::ImportAutoMapFinished {
|
|
definition_id: result_definition_id.clone(),
|
|
result,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn flush_active_post_editor(&mut self) {
|
|
let Some(active_id) = self.active_tab.clone() else {
|
|
return;
|
|
};
|
|
let is_dirty_post = self
|
|
.tabs
|
|
.iter()
|
|
.find(|tab| tab.id == active_id)
|
|
.map(|tab| tab.tab_type == TabType::Post)
|
|
.unwrap_or(false)
|
|
&& self
|
|
.post_editors
|
|
.get(&active_id)
|
|
.map(|state| state.is_dirty)
|
|
.unwrap_or(false);
|
|
if is_dirty_post {
|
|
let _ = self.persist_post_editor_state(&active_id);
|
|
}
|
|
}
|
|
|
|
fn auto_save_due_post_editors(&mut self) {
|
|
let now = bds_core::util::now_unix_ms();
|
|
let due_ids: Vec<String> = self
|
|
.post_editors
|
|
.iter()
|
|
.filter(|&(_post_id, state)| {
|
|
state.is_dirty
|
|
&& state.last_edit_at_ms > 0
|
|
&& now - state.last_edit_at_ms >= POST_AUTO_SAVE_DELAY_MS
|
|
})
|
|
.map(|(post_id, _state)| post_id.clone())
|
|
.collect();
|
|
|
|
for post_id in due_ids {
|
|
if let Err(error) = self.persist_post_editor_state(&post_id) {
|
|
self.notify_operation_failed("common.autoSave", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn operation_failed_text(&self, operation_key: &str, error: impl std::fmt::Display) -> String {
|
|
let operation = t(self.ui_locale, operation_key);
|
|
let error = error.to_string();
|
|
tw(
|
|
self.ui_locale,
|
|
"common.operationFailed",
|
|
&[("operation", &operation), ("error", &error)],
|
|
)
|
|
}
|
|
|
|
fn notify_operation_failed(&mut self, operation_key: &str, error: impl std::fmt::Display) {
|
|
let message = self.operation_failed_text(operation_key, error);
|
|
self.notify(ToastLevel::Error, &message);
|
|
}
|
|
|
|
fn add_output(&mut self, text: &str) {
|
|
self.output_entries.push(OutputEntry {
|
|
timestamp: chrono::Utc::now().timestamp(),
|
|
text: text.to_string(),
|
|
});
|
|
}
|
|
|
|
/// Show a toast notification AND log to output panel.
|
|
fn notify(&mut self, level: ToastLevel, text: &str) {
|
|
self.toasts.push(Toast::new(level, text.to_string()));
|
|
self.add_output(text);
|
|
}
|
|
|
|
fn start_site_validation(&mut self) -> Task<Message> {
|
|
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
|
|
let Some(_db) = self.db.as_ref() else {
|
|
self.site_validation_state.is_running = false;
|
|
self.site_validation_state.error_message =
|
|
Some(t(self.ui_locale, "engine.generateSiteNoProject"));
|
|
return Task::none();
|
|
};
|
|
let Some(project_id) = self
|
|
.active_project
|
|
.as_ref()
|
|
.map(|project| project.id.clone())
|
|
else {
|
|
self.site_validation_state.is_running = false;
|
|
self.site_validation_state.error_message =
|
|
Some(t(self.ui_locale, "engine.generateSiteNoProject"));
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = self.data_dir.clone() else {
|
|
self.site_validation_state.is_running = false;
|
|
self.site_validation_state.error_message =
|
|
Some(t(self.ui_locale, "engine.previewDataDirUnavailable"));
|
|
return Task::none();
|
|
};
|
|
|
|
self.site_validation_state.is_running = true;
|
|
self.site_validation_state.error_message = None;
|
|
let db_path = self.db_path.clone();
|
|
|
|
Task::perform(
|
|
async move {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
engine::validate_site::validate_site(db.conn(), &data_dir, &project_id)
|
|
.map_err(|error| error.to_string())
|
|
},
|
|
Message::SiteValidationLoaded,
|
|
)
|
|
}
|
|
|
|
fn start_metadata_diff(&mut self) -> Task<Message> {
|
|
let (Some(project), Some(data_dir)) =
|
|
(self.active_project.as_ref(), self.data_dir.as_ref())
|
|
else {
|
|
self.metadata_diff_state.error_message =
|
|
Some(t(self.ui_locale, "engine.generateSiteNoProject"));
|
|
return Task::none();
|
|
};
|
|
self.metadata_diff_state.is_running = true;
|
|
self.metadata_diff_state.error_message = None;
|
|
let db_path = self.db_path.clone();
|
|
let project_id = project.id.clone();
|
|
let data_dir = data_dir.clone();
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
engine::metadata_diff::compute_metadata_diff(db.conn(), &data_dir, &project_id)
|
|
.map_err(|error| error.to_string())
|
|
})
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
},
|
|
Message::MetadataDiffLoaded,
|
|
)
|
|
}
|
|
|
|
fn apply_site_validation(&mut self) -> Task<Message> {
|
|
if self.site_validation_state.is_running || self.site_validation_state.is_applying {
|
|
return Task::none();
|
|
}
|
|
|
|
let report = engine::validate_site::SiteValidationReport {
|
|
missing_pages: self.site_validation_state.missing_files.clone(),
|
|
extra_pages: self.site_validation_state.extra_files.clone(),
|
|
stale_pages: self.site_validation_state.stale_files.clone(),
|
|
};
|
|
let sections = engine::generation::sections_from_validation_report(&report);
|
|
if sections.is_empty() {
|
|
return Task::none();
|
|
}
|
|
|
|
self.site_validation_state.is_applying = true;
|
|
self.site_validation_state.error_message = None;
|
|
self.queue_site_generation(Some(report))
|
|
}
|
|
|
|
fn refresh_counts(&mut self) -> Task<Message> {
|
|
if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
|
|
self.post_count =
|
|
bds_core::db::queries::post::count_posts_by_project(db.conn(), &project.id)
|
|
.unwrap_or(0) as usize;
|
|
self.media_count =
|
|
bds_core::db::queries::media::count_media_by_project(db.conn(), &project.id)
|
|
.unwrap_or(0) as usize;
|
|
|
|
self.sidebar_scripts =
|
|
bds_core::db::queries::script::list_scripts_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
self.sidebar_templates =
|
|
bds_core::db::queries::template::list_templates_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
self.sidebar_imports =
|
|
engine::wordpress_import::list_definitions(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
|
|
// Read pico theme from project metadata for status bar badge
|
|
if let Some(ref data_dir) = self.data_dir
|
|
&& let Ok(meta) = engine::meta::read_project_json(data_dir)
|
|
{
|
|
self.theme_badge = StyleViewState::new(meta.pico_theme.as_deref()).applied_theme;
|
|
}
|
|
|
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
|
}
|
|
|
|
// Refresh sidebar data with current filters (async — off main thread)
|
|
let t1 = self.refresh_sidebar_posts();
|
|
let t2 = self.refresh_sidebar_media();
|
|
self.refresh_filter_metadata();
|
|
Task::batch([t1, t2])
|
|
}
|
|
|
|
fn hydrate_dashboard_state(&self) -> DashboardState {
|
|
let Some(project) = &self.active_project else {
|
|
return DashboardState::new(String::new());
|
|
};
|
|
let Some(db) = &self.db else {
|
|
return DashboardState::new(project.name.clone());
|
|
};
|
|
|
|
let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
let media = bds_core::db::queries::media::list_media_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
let tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
|
|
let mut draft_count = 0usize;
|
|
let mut published_count = 0usize;
|
|
let mut archived_count = 0usize;
|
|
let mut monthly_counts: std::collections::BTreeMap<(i32, u32), usize> =
|
|
std::collections::BTreeMap::new();
|
|
let mut category_counts: HashMap<String, usize> = HashMap::new();
|
|
let mut tag_counts: HashMap<String, usize> = HashMap::new();
|
|
|
|
for post in &posts {
|
|
match post.status {
|
|
PostStatus::Draft => draft_count += 1,
|
|
PostStatus::Published => published_count += 1,
|
|
PostStatus::Archived => archived_count += 1,
|
|
}
|
|
|
|
let (year, month, _) =
|
|
bds_core::util::timestamp::year_month_day_from_unix_ms(post.created_at);
|
|
let year = year.parse::<i32>().unwrap_or(0);
|
|
let month = month.parse::<u32>().unwrap_or(0);
|
|
*monthly_counts.entry((year, month)).or_insert(0) += 1;
|
|
|
|
for category in &post.categories {
|
|
let trimmed = category.trim();
|
|
if !trimmed.is_empty() {
|
|
*category_counts.entry(trimmed.to_string()).or_insert(0) += 1;
|
|
}
|
|
}
|
|
for tag in &post.tags {
|
|
let trimmed = tag.trim();
|
|
if !trimmed.is_empty() {
|
|
*tag_counts.entry(trimmed.to_string()).or_insert(0) += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
let image_count = media
|
|
.iter()
|
|
.filter(|item| item.mime_type.starts_with("image/"))
|
|
.count();
|
|
let total_media_size = media.iter().map(|item| item.size).sum::<i64>();
|
|
|
|
// Per bDS2: only the most recent 12 months that actually have posts.
|
|
let timeline = monthly_counts
|
|
.iter()
|
|
.rev()
|
|
.take(12)
|
|
.map(|(&(year, month), &count)| DashboardTimelineMonth {
|
|
label: month_abbreviation(month),
|
|
year,
|
|
count,
|
|
})
|
|
.rev()
|
|
.collect::<Vec<_>>();
|
|
|
|
let tag_colors = tags
|
|
.into_iter()
|
|
.filter_map(|tag| match tag.color {
|
|
Some(color) if !color.is_empty() => Some((tag.name, color)),
|
|
_ => None,
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
let distinct_tag_count = tag_counts.len();
|
|
|
|
// Per bDS2: top 40 tags by count, font scaled 11-22px relative to the
|
|
// min/max counts of the visible set, then sorted alphabetically.
|
|
let mut tag_items = tag_counts.into_iter().collect::<Vec<_>>();
|
|
tag_items.sort_by(|left, right| {
|
|
right
|
|
.1
|
|
.cmp(&left.1)
|
|
.then_with(|| left.0.to_lowercase().cmp(&right.0.to_lowercase()))
|
|
});
|
|
tag_items.truncate(40);
|
|
let max_count = tag_items
|
|
.iter()
|
|
.map(|item| item.1)
|
|
.max()
|
|
.unwrap_or(1)
|
|
.max(1);
|
|
let min_count = tag_items
|
|
.iter()
|
|
.map(|item| item.1)
|
|
.min()
|
|
.unwrap_or(max_count);
|
|
let range = (max_count - min_count).max(1) as f32;
|
|
let mut tag_cloud = tag_items
|
|
.into_iter()
|
|
.map(|(name, count)| DashboardTag {
|
|
font_size: 11.0 + (count - min_count) as f32 / range * 11.0,
|
|
color: tag_colors.get(&name).cloned(),
|
|
name,
|
|
count,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
tag_cloud.sort_by_key(|tag| tag.name.to_lowercase());
|
|
|
|
let mut category_cloud = category_counts
|
|
.into_iter()
|
|
.map(|(name, count)| DashboardCategory { name, count })
|
|
.collect::<Vec<_>>();
|
|
category_cloud.sort_by(|left, right| {
|
|
right
|
|
.count
|
|
.cmp(&left.count)
|
|
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
|
|
});
|
|
|
|
let mut sorted_posts = posts;
|
|
sorted_posts.sort_by_key(|post| std::cmp::Reverse(post.updated_at));
|
|
let mut recent_posts = sorted_posts
|
|
.into_iter()
|
|
.map(|post| DashboardRecentPost {
|
|
post_id: post.id,
|
|
title: if post.title.trim().is_empty() {
|
|
"Untitled".to_string()
|
|
} else {
|
|
post.title
|
|
},
|
|
status: match post.status {
|
|
PostStatus::Published => "published".to_string(),
|
|
_ => "draft".to_string(),
|
|
},
|
|
date: format_timestamp(post.updated_at),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
recent_posts.truncate(5);
|
|
|
|
DashboardState {
|
|
title: t(self.ui_locale, "dashboard.overview"),
|
|
subtitle: project.name.clone(),
|
|
stats: DashboardStats {
|
|
total_posts: draft_count + published_count + archived_count,
|
|
published_count,
|
|
draft_count,
|
|
archived_count,
|
|
media_count: media.len(),
|
|
image_count,
|
|
total_media_size: format_bytes(total_media_size),
|
|
tag_count: distinct_tag_count,
|
|
category_count: category_cloud.len(),
|
|
},
|
|
timeline,
|
|
tag_cloud,
|
|
category_cloud,
|
|
recent_posts,
|
|
}
|
|
}
|
|
|
|
/// Per layout.allium PanelTabFallback invariant: if the active panel tab
|
|
/// becomes unavailable (post_links when no post tab active, git_log when
|
|
/// neither post nor media tab active), fall back to Tasks.
|
|
fn enforce_panel_tab_fallback(&mut self) {
|
|
let active_tab_type = self
|
|
.active_tab
|
|
.as_ref()
|
|
.and_then(|id| self.tabs.iter().find(|t| t.id == *id).map(|t| &t.tab_type));
|
|
let is_post = active_tab_type == Some(&TabType::Post);
|
|
let is_post_or_media = is_post || active_tab_type == Some(&TabType::Media);
|
|
|
|
match self.panel_tab {
|
|
PanelTab::PostLinks if !is_post => self.panel_tab = PanelTab::Tasks,
|
|
PanelTab::GitLog if !is_post_or_media => self.panel_tab = PanelTab::Tasks,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn active_tab_type(&self) -> Option<TabType> {
|
|
self.active_tab.as_ref().and_then(|id| {
|
|
self.tabs
|
|
.iter()
|
|
.find(|tab| tab.id == *id)
|
|
.map(|tab| tab.tab_type.clone())
|
|
})
|
|
}
|
|
|
|
fn open_tab(&mut self, tab: Tab) -> Task<Message> {
|
|
self.flush_active_post_editor();
|
|
let index = tabs::open_tab(&mut self.tabs, tab);
|
|
let mut semantic_post_id = None;
|
|
if let Some(tab) = self.tabs.get(index).cloned() {
|
|
self.active_tab = Some(tab.id.clone());
|
|
if tab.tab_type == TabType::Post {
|
|
semantic_post_id = Some(tab.id.clone());
|
|
}
|
|
self.load_editor_for_tab(&tab);
|
|
}
|
|
self.enforce_panel_tab_fallback();
|
|
self.sync_menu_state();
|
|
let mut tasks = vec![self.sync_embedded_previews()];
|
|
if let Some(post_id) = semantic_post_id {
|
|
tasks.push(Task::done(Message::LoadSemanticTagSuggestions(post_id)));
|
|
}
|
|
Task::batch(tasks)
|
|
}
|
|
|
|
fn find_next_in_active_editor(&mut self, query: &str) -> bool {
|
|
let Some(id) = self.active_tab.clone() else {
|
|
return false;
|
|
};
|
|
match self.active_tab_type() {
|
|
Some(TabType::Post) => self
|
|
.post_editors
|
|
.get_mut(&id)
|
|
.is_some_and(|state| state.editor_buffer.borrow_mut().find_next(query)),
|
|
Some(TabType::Templates) => self
|
|
.template_editors
|
|
.get_mut(&id)
|
|
.is_some_and(|state| state.editor_buffer.borrow_mut().find_next(query)),
|
|
Some(TabType::Scripts) => self
|
|
.script_editors
|
|
.get_mut(&id)
|
|
.is_some_and(|state| state.editor_buffer.borrow_mut().find_next(query)),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn replace_current_in_active_editor(&mut self, query: &str, replacement: &str) -> bool {
|
|
let Some(id) = self.active_tab.clone() else {
|
|
return false;
|
|
};
|
|
let replaced = match self.active_tab_type() {
|
|
Some(TabType::Post) => self.post_editors.get_mut(&id).is_some_and(|state| {
|
|
let mut buffer = state.editor_buffer.borrow_mut();
|
|
let replaced = replace_current_in_buffer(&mut buffer, query, replacement);
|
|
state.content = buffer.text();
|
|
state.is_dirty |= replaced;
|
|
replaced
|
|
}),
|
|
Some(TabType::Templates) => self.template_editors.get_mut(&id).is_some_and(|state| {
|
|
let mut buffer = state.editor_buffer.borrow_mut();
|
|
let replaced = replace_current_in_buffer(&mut buffer, query, replacement);
|
|
state.content = buffer.text();
|
|
state.is_dirty |= replaced;
|
|
replaced
|
|
}),
|
|
Some(TabType::Scripts) => self.script_editors.get_mut(&id).is_some_and(|state| {
|
|
let mut buffer = state.editor_buffer.borrow_mut();
|
|
let replaced = replace_current_in_buffer(&mut buffer, query, replacement);
|
|
state.content = buffer.text();
|
|
state.is_dirty |= replaced;
|
|
replaced
|
|
}),
|
|
_ => false,
|
|
};
|
|
if replaced && let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
|
tab.is_dirty = true;
|
|
}
|
|
replaced
|
|
}
|
|
|
|
fn replace_all_in_active_editor(&mut self, query: &str, replacement: &str) -> usize {
|
|
let Some(id) = self.active_tab.clone() else {
|
|
return 0;
|
|
};
|
|
let count = match self.active_tab_type() {
|
|
Some(TabType::Post) => self.post_editors.get_mut(&id).map_or(0, |state| {
|
|
let mut buffer = state.editor_buffer.borrow_mut();
|
|
let count = buffer.replace_all(query, replacement);
|
|
state.content = buffer.text();
|
|
state.is_dirty |= count > 0;
|
|
count
|
|
}),
|
|
Some(TabType::Templates) => self.template_editors.get_mut(&id).map_or(0, |state| {
|
|
let mut buffer = state.editor_buffer.borrow_mut();
|
|
let count = buffer.replace_all(query, replacement);
|
|
state.content = buffer.text();
|
|
state.is_dirty |= count > 0;
|
|
count
|
|
}),
|
|
Some(TabType::Scripts) => self.script_editors.get_mut(&id).map_or(0, |state| {
|
|
let mut buffer = state.editor_buffer.borrow_mut();
|
|
let count = buffer.replace_all(query, replacement);
|
|
state.content = buffer.text();
|
|
state.is_dirty |= count > 0;
|
|
count
|
|
}),
|
|
_ => 0,
|
|
};
|
|
if count > 0
|
|
&& let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id)
|
|
{
|
|
tab.is_dirty = true;
|
|
}
|
|
count
|
|
}
|
|
|
|
/// Synchronise menu enabled/disabled state with current app state.
|
|
///
|
|
/// Called after state-changing operations (project switch, tab open/close,
|
|
/// offline toggle) so that menu items reflect what's actually possible.
|
|
fn sync_menu_state(&self) {
|
|
let interactions_enabled = !self.search_index_rebuild_running;
|
|
let active_tab_type = self
|
|
.active_tab
|
|
.as_ref()
|
|
.and_then(|id| self.tabs.iter().find(|t| t.id == *id).map(|t| &t.tab_type));
|
|
for &action in MenuAction::ALL {
|
|
self.menu_registry.set_enabled(
|
|
action,
|
|
menu::action_enabled(
|
|
action,
|
|
self.active_project.is_some(),
|
|
active_tab_type,
|
|
self.offline_mode,
|
|
interactions_enabled,
|
|
),
|
|
);
|
|
}
|
|
self.menu_registry
|
|
.set_enabled(MenuAction::DisconnectServer, self.remote_client.is_some());
|
|
}
|
|
|
|
// ── Editor save/publish helpers ──
|
|
|
|
fn save_post_editor(
|
|
&mut self,
|
|
post_id: &str,
|
|
schedule_auto_translation: bool,
|
|
) -> Task<Message> {
|
|
let saves_canonical_post = self
|
|
.post_editors
|
|
.get(post_id)
|
|
.is_some_and(|editor| editor.active_language == editor.canonical_language);
|
|
match self.persist_post_editor_state(post_id) {
|
|
Ok(()) => {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
if schedule_auto_translation && saves_canonical_post {
|
|
return self.schedule_post_auto_translation(post_id);
|
|
}
|
|
}
|
|
Err(e) => self.notify_operation_failed("common.save", e),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn enqueue_image_drop(&mut self, path: PathBuf) -> Task<Message> {
|
|
let Some(post_id) = dropped_image_target(self.active_tab.as_deref(), &self.tabs, &path)
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
|
|
return Task::none();
|
|
};
|
|
self.pending_image_drops.push_back(ImageDropRequest {
|
|
post_id,
|
|
project_id: project.id.clone(),
|
|
data_dir: data_dir.clone(),
|
|
source_language: self.content_language.clone(),
|
|
offline_mode: self.offline_mode,
|
|
path,
|
|
});
|
|
self.start_next_image_drop_import()
|
|
}
|
|
|
|
fn start_next_image_drop_import(&mut self) -> Task<Message> {
|
|
if self.image_drop_import_running {
|
|
return Task::none();
|
|
}
|
|
let Some(request) = self.pending_image_drops.pop_front() else {
|
|
return Task::none();
|
|
};
|
|
self.image_drop_import_running = true;
|
|
let name = request
|
|
.path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| request.path.display().to_string());
|
|
let label = tw(self.ui_locale, "editor.imageDropImport", &[("name", &name)]);
|
|
let task_id = self.task_manager.submit(&label);
|
|
self.refresh_task_snapshots();
|
|
|
|
let db_path = self.db_path.clone();
|
|
let task_manager = Arc::clone(&self.task_manager);
|
|
let locale = self.ui_locale;
|
|
let message_request = request.clone();
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
if !task_manager.wait_until_runnable(task_id) {
|
|
return Err("cancelled".to_string());
|
|
}
|
|
task_manager.report_progress(
|
|
task_id,
|
|
Some(0.0),
|
|
Some(tw(locale, "editor.imageDropProgress", &[("name", &name)])),
|
|
);
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
let sort_order =
|
|
engine::post_media::list_media_for_post(db.conn(), &request.post_id)
|
|
.map(|items| items.len() as i32)
|
|
.unwrap_or(0);
|
|
engine::gallery_import::import_and_link_image(
|
|
db.conn(),
|
|
&request.data_dir,
|
|
&request.project_id,
|
|
&request.post_id,
|
|
&request.path,
|
|
&request.source_language,
|
|
sort_order,
|
|
)
|
|
.map_err(|error| error.to_string())
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| Err(error.to_string()))
|
|
},
|
|
move |result| Message::ImageDropImported {
|
|
task_id,
|
|
post_id: message_request.post_id.clone(),
|
|
project_id: message_request.project_id.clone(),
|
|
data_dir: message_request.data_dir.clone(),
|
|
source_language: message_request.source_language.clone(),
|
|
offline_mode: message_request.offline_mode,
|
|
path: message_request.path.clone(),
|
|
result,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn finish_image_drop_import(
|
|
&mut self,
|
|
task_id: TaskId,
|
|
request: ImageDropRequest,
|
|
result: Result<Media, String>,
|
|
) -> Task<Message> {
|
|
self.image_drop_import_running = false;
|
|
let cancelled = self.task_manager.status(task_id) == Some(TaskStatus::Cancelled);
|
|
let mut tasks = vec![self.start_next_image_drop_import()];
|
|
match result {
|
|
Ok(media) => {
|
|
if !cancelled {
|
|
self.task_manager.complete(task_id);
|
|
}
|
|
let inserted = self
|
|
.post_editors
|
|
.get_mut(&request.post_id)
|
|
.map(|state| state.insert_dropped_image(&media.file_path))
|
|
.is_some();
|
|
if inserted {
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == request.post_id) {
|
|
tab.is_dirty = true;
|
|
}
|
|
if let Err(error) = self.persist_post_editor_state(&request.post_id) {
|
|
self.notify_operation_failed("common.save", error);
|
|
}
|
|
}
|
|
self.refresh_post_relationships(&request.post_id);
|
|
let name = request
|
|
.path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| request.path.display().to_string());
|
|
self.add_output(&tw(
|
|
self.ui_locale,
|
|
"editor.imageDropAdded",
|
|
&[("name", &name)],
|
|
));
|
|
tasks.push(self.start_image_drop_enrichment(&request, media));
|
|
}
|
|
Err(error) if !cancelled => {
|
|
self.task_manager.fail(task_id, error.clone());
|
|
let path = request
|
|
.path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| request.path.display().to_string());
|
|
self.add_output(&tw(
|
|
self.ui_locale,
|
|
"editor.imageDropFailed",
|
|
&[("path", &path), ("error", &error)],
|
|
));
|
|
}
|
|
Err(_) => {}
|
|
}
|
|
self.refresh_task_snapshots();
|
|
tasks.push(self.refresh_sidebar_media());
|
|
Task::batch(tasks)
|
|
}
|
|
|
|
fn start_image_drop_enrichment(
|
|
&mut self,
|
|
request: &ImageDropRequest,
|
|
media: Media,
|
|
) -> Task<Message> {
|
|
let Some(db) = self.db.as_ref() else {
|
|
return Task::none();
|
|
};
|
|
if !engine::gallery_import::active_ai_endpoint_configured(db.conn(), request.offline_mode) {
|
|
let key = if request.offline_mode {
|
|
"editor.galleryAirplaneGated"
|
|
} else {
|
|
"chat.unavailable.guidance"
|
|
};
|
|
self.notify(ToastLevel::Warning, &t(self.ui_locale, key));
|
|
return Task::none();
|
|
}
|
|
let metadata = engine::meta::read_project_json(&request.data_dir).ok();
|
|
let translate = bds_core::db::queries::post::get_post_by_id(db.conn(), &request.post_id)
|
|
.is_ok_and(|post| !post.do_not_translate);
|
|
let targets = if translate {
|
|
engine::gallery_import::translation_targets(
|
|
metadata
|
|
.as_ref()
|
|
.and_then(|metadata| metadata.main_language.as_deref()),
|
|
metadata
|
|
.as_ref()
|
|
.map(|metadata| metadata.blog_languages.as_slice())
|
|
.unwrap_or_default(),
|
|
&request.source_language,
|
|
)
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let name = request
|
|
.path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| request.path.display().to_string());
|
|
let label = tw(
|
|
self.ui_locale,
|
|
"editor.imageDropEnrichment",
|
|
&[("name", &name)],
|
|
);
|
|
let task_id = self.task_manager.submit(&label);
|
|
self.refresh_task_snapshots();
|
|
let db_path = self.db_path.clone();
|
|
let data_dir = request.data_dir.clone();
|
|
let offline_mode = request.offline_mode;
|
|
let post_id = request.post_id.clone();
|
|
let path = request.path.clone();
|
|
let task_manager = Arc::clone(&self.task_manager);
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
if !task_manager.wait_until_runnable(task_id) {
|
|
return Err("cancelled".to_string());
|
|
}
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
engine::gallery_import::enrich_imported_image(
|
|
db.conn(),
|
|
&data_dir,
|
|
&media,
|
|
offline_mode,
|
|
&targets,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| Err(error.to_string()))
|
|
},
|
|
move |result| Message::ImageDropEnriched {
|
|
task_id,
|
|
post_id: post_id.clone(),
|
|
path: path.clone(),
|
|
result,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn finish_image_drop_enrichment(
|
|
&mut self,
|
|
task_id: TaskId,
|
|
post_id: &str,
|
|
path: &Path,
|
|
result: Result<String, String>,
|
|
) -> Task<Message> {
|
|
let cancelled = self.task_manager.status(task_id) == Some(TaskStatus::Cancelled);
|
|
match result {
|
|
Ok(_) if !cancelled => self.task_manager.complete(task_id),
|
|
Err(error) if !cancelled => {
|
|
self.task_manager.fail(task_id, error.clone());
|
|
let path = path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| path.display().to_string());
|
|
self.add_output(&tw(
|
|
self.ui_locale,
|
|
"editor.imageDropEnrichmentFailed",
|
|
&[("path", &path), ("error", &error)],
|
|
));
|
|
}
|
|
_ => {}
|
|
}
|
|
self.refresh_task_snapshots();
|
|
self.refresh_post_relationships(post_id);
|
|
self.refresh_sidebar_media()
|
|
}
|
|
|
|
fn add_gallery_images(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(db) = self.db.as_ref() else {
|
|
self.add_output(&t(self.ui_locale, "common.databaseUnavailable"));
|
|
return Task::none();
|
|
};
|
|
if self.offline_mode
|
|
&& !engine::gallery_import::active_ai_endpoint_configured(db.conn(), true)
|
|
{
|
|
self.notify(
|
|
ToastLevel::Warning,
|
|
&t(self.ui_locale, "editor.galleryAirplaneGated"),
|
|
);
|
|
}
|
|
crate::platform::dialog::pick_gallery_images(
|
|
post_id.to_string(),
|
|
t(self.ui_locale, "editor.addGalleryImages"),
|
|
t(self.ui_locale, "dialog.imageFilter"),
|
|
)
|
|
}
|
|
|
|
fn publish_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
|
if let Err(e) = self.persist_post_editor_state(post_id) {
|
|
self.notify_operation_failed("editor.publish", e);
|
|
return Task::none();
|
|
}
|
|
let Some(ref db) = self.db else {
|
|
return Task::none();
|
|
};
|
|
let Some(ref data_dir) = self.data_dir else {
|
|
return Task::none();
|
|
};
|
|
match engine::post::publish_post(db.conn(), data_dir, post_id) {
|
|
Ok(post) => {
|
|
if let Some(s) = self.post_editors.get_mut(post_id) {
|
|
s.status = post.status.clone();
|
|
s.is_dirty = false;
|
|
s.updated_at = post.updated_at;
|
|
s.published_at = post.published_at;
|
|
for draft in s.translation_drafts.values_mut() {
|
|
draft.status = bds_core::model::PostStatus::Published;
|
|
draft.is_dirty = false;
|
|
}
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.published"));
|
|
return self.schedule_post_auto_translation(post_id);
|
|
}
|
|
Err(e) => {
|
|
self.notify_operation_failed("editor.publish", e);
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn schedule_post_auto_translation(&mut self, post_id: &str) -> Task<Message> {
|
|
let (Some(db), Some(data_dir)) = (self.db.as_ref(), self.data_dir.as_ref()) else {
|
|
return Task::none();
|
|
};
|
|
let Ok(meta) = engine::meta::read_project_json(data_dir) else {
|
|
return Task::none();
|
|
};
|
|
let Ok(post) = bds_core::db::queries::post::get_post_by_id(db.conn(), post_id) else {
|
|
return Task::none();
|
|
};
|
|
let main_language = meta.main_language.unwrap_or_else(|| "en".to_string());
|
|
let configured =
|
|
engine::auto_translation::configured_languages(&main_language, &meta.blog_languages);
|
|
let Ok(targets) =
|
|
engine::auto_translation::missing_languages(db.conn(), &post, &configured)
|
|
else {
|
|
return Task::none();
|
|
};
|
|
if targets.is_empty() {
|
|
return Task::none();
|
|
}
|
|
if engine::ai::active_endpoint(db.conn(), self.offline_mode).is_err() {
|
|
return Task::none();
|
|
}
|
|
let post_id = post_id.to_string();
|
|
let offline_mode = self.offline_mode;
|
|
let locale = self.ui_locale;
|
|
Task::batch(targets.into_iter().map(|language| {
|
|
let post_id = post_id.clone();
|
|
let configured = configured.clone();
|
|
self.spawn_grouped_engine_task(
|
|
"engine.autoTranslationStarted",
|
|
"AI",
|
|
move |db_path, _project_id, data_dir, task_manager, task_id| {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
let report = engine::auto_translation::translate_missing_language_for_post(
|
|
db.conn(),
|
|
&data_dir,
|
|
&post_id,
|
|
&configured,
|
|
&language,
|
|
offline_mode,
|
|
move || task_manager.is_cancelled(task_id),
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
Ok(tw(
|
|
locale,
|
|
"engine.autoTranslationComplete",
|
|
&[("count", &report.translated_posts.to_string())],
|
|
))
|
|
},
|
|
)
|
|
}))
|
|
}
|
|
|
|
fn ensure_post_editor_tag(&mut self, post_id: &str, name: &str) -> Task<Message> {
|
|
let (Some(db), Some(data_dir)) = (self.db.as_ref(), self.data_dir.as_ref()) else {
|
|
return Task::none();
|
|
};
|
|
let Ok(post) = bds_core::db::queries::post::get_post_by_id(db.conn(), post_id) else {
|
|
return Task::none();
|
|
};
|
|
if bds_core::db::queries::tag::get_tag_by_project_and_name(
|
|
db.conn(),
|
|
&post.project_id,
|
|
name,
|
|
)
|
|
.is_err()
|
|
&& let Err(error) =
|
|
engine::tag::create_tag(db.conn(), data_dir, &post.project_id, name, None)
|
|
{
|
|
self.notify_operation_failed("editor.tags", error);
|
|
return Task::none();
|
|
}
|
|
self.refresh_post_editor_tag_options();
|
|
Task::none()
|
|
}
|
|
|
|
fn insert_link_modal(&mut self, post_id: &str) -> Task<Message> {
|
|
let state = match self.post_editors.get(post_id) {
|
|
Some(s) => s.clone(),
|
|
None => return Task::none(),
|
|
};
|
|
|
|
self.active_modal = Some(modal::ModalState::PostInsertLink {
|
|
post_id: post_id.to_string(),
|
|
title: state.title,
|
|
results: self.query_post_link_results(post_id, ""),
|
|
search_query: String::new(),
|
|
active_tab: modal::PostInsertLinkTab::Internal,
|
|
external_url: String::new(),
|
|
external_text: String::new(),
|
|
});
|
|
Task::none()
|
|
}
|
|
|
|
fn insert_media_modal(&mut self, post_id: &str, link_only: bool) -> Task<Message> {
|
|
let state = match self.post_editors.get(post_id) {
|
|
Some(s) => s.clone(),
|
|
None => return Task::none(),
|
|
};
|
|
|
|
self.active_modal = Some(modal::ModalState::InsertMedia {
|
|
post_id: post_id.to_string(),
|
|
title: state.title,
|
|
media_list: self.query_post_insert_media_results(""),
|
|
search_query: String::new(),
|
|
link_only,
|
|
});
|
|
Task::none()
|
|
}
|
|
|
|
fn post_gallery(&mut self, post_id: &str) -> Task<Message> {
|
|
let state = match self.post_editors.get(post_id) {
|
|
Some(s) => s.clone(),
|
|
None => return Task::none(),
|
|
};
|
|
|
|
let media_list = if let Some(ref db) = self.db {
|
|
engine::post_media::list_media_for_post(db.conn(), post_id).unwrap_or_default()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
self.active_modal = Some(modal::ModalState::PostGallery {
|
|
post_id: post_id.to_string(),
|
|
title: state.title,
|
|
media_list,
|
|
selected_index: None,
|
|
});
|
|
Task::none()
|
|
}
|
|
|
|
fn query_post_link_results(
|
|
&self,
|
|
current_post_id: &str,
|
|
search_query: &str,
|
|
) -> Vec<modal::InsertLinkResult> {
|
|
let (Some(db), Some(project)) = (&self.db, &self.active_project) else {
|
|
return Vec::new();
|
|
};
|
|
let query = search_query.trim();
|
|
if query.chars().count() < 2 {
|
|
return Vec::new();
|
|
}
|
|
|
|
let filters = bds_core::db::fts::PostSearchFilters {
|
|
limit: Some(20),
|
|
..Default::default()
|
|
};
|
|
|
|
let mut ids = bds_core::db::fts::search_posts_filtered(
|
|
db.conn(),
|
|
query,
|
|
&self.content_language,
|
|
&filters,
|
|
)
|
|
.map(|results| results.post_ids)
|
|
.unwrap_or_default();
|
|
|
|
if let Some(data_dir) = &self.data_dir
|
|
&& let Ok(scores) = engine::embedding::EmbeddingService::production(db.conn(), data_dir)
|
|
.compute_similarities(current_post_id, &ids)
|
|
{
|
|
ids.sort_by(|a, b| {
|
|
scores
|
|
.get(b)
|
|
.copied()
|
|
.unwrap_or_default()
|
|
.total_cmp(&scores.get(a).copied().unwrap_or_default())
|
|
});
|
|
}
|
|
|
|
ids.into_iter()
|
|
.filter_map(|post_id| {
|
|
bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok()
|
|
})
|
|
.filter(|post| post.project_id == project.id && post.id != current_post_id)
|
|
.map(|post| modal::InsertLinkResult {
|
|
post_id: post.id,
|
|
title: post.title,
|
|
status: match post.status {
|
|
bds_core::model::PostStatus::Draft => "draft".to_string(),
|
|
bds_core::model::PostStatus::Published => "published".to_string(),
|
|
bds_core::model::PostStatus::Archived => "archived".to_string(),
|
|
},
|
|
canonical_url: bds_core::engine::post::canonical_url(post.created_at, &post.slug),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn query_post_insert_media_results(&self, search_query: &str) -> Vec<Media> {
|
|
let (Some(db), Some(project)) = (&self.db, &self.active_project) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let query = search_query.trim();
|
|
if query.is_empty() {
|
|
return bds_core::db::queries::media::list_media_filtered(
|
|
db.conn(),
|
|
&project.id,
|
|
&Default::default(),
|
|
24,
|
|
0,
|
|
)
|
|
.unwrap_or_default();
|
|
}
|
|
|
|
let filters = bds_core::db::fts::MediaSearchFilters {
|
|
project_id: Some(&project.id),
|
|
limit: Some(24),
|
|
..Default::default()
|
|
};
|
|
bds_core::db::fts::search_media_filtered(db.conn(), query, &self.content_language, &filters)
|
|
.map(|results| results.media_ids)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|media_id| {
|
|
bds_core::db::queries::media::get_media_by_id(db.conn(), &media_id).ok()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn query_media_post_picker_results(
|
|
&self,
|
|
media_id: &str,
|
|
search_query: &str,
|
|
) -> Vec<LinkedPostItem> {
|
|
let (Some(db), Some(project)) = (&self.db, &self.active_project) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let linked_ids: std::collections::HashSet<String> =
|
|
bds_core::engine::post_media::list_posts_for_media(db.conn(), media_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|post| post.id)
|
|
.collect();
|
|
|
|
let query = search_query.trim().to_lowercase();
|
|
bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(|post| !linked_ids.contains(&post.id))
|
|
.filter(|post| query.is_empty() || post.title.to_lowercase().contains(&query))
|
|
.take(10)
|
|
.map(|post| LinkedPostItem {
|
|
post_id: post.id,
|
|
title: if post.title.is_empty() {
|
|
t(self.ui_locale, "editor.untitled")
|
|
} else {
|
|
post.title
|
|
},
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn insert_markdown_into_post(&mut self, post_id: &str, markdown: &str) -> Task<Message> {
|
|
let Some(state) = self.post_editors.get_mut(post_id) else {
|
|
return Task::none();
|
|
};
|
|
state.insert_markdown_at_cursor(markdown);
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == post_id) {
|
|
tab.is_dirty = true;
|
|
}
|
|
self.active_modal = None;
|
|
self.save_post_editor(post_id, false)
|
|
}
|
|
|
|
fn insert_selected_post_link(&mut self, post_id: &str, linked_post_id: &str) -> Task<Message> {
|
|
let Some(ref db) = self.db else {
|
|
return Task::none();
|
|
};
|
|
let Ok(linked_post) =
|
|
bds_core::db::queries::post::get_post_by_id(db.conn(), linked_post_id)
|
|
else {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "modal.postInsertLink.loadFailed"),
|
|
);
|
|
return Task::none();
|
|
};
|
|
|
|
let markdown = bds_core::engine::post::post_insert_link(&linked_post.slug).replacen(
|
|
"title",
|
|
&linked_post.title,
|
|
1,
|
|
);
|
|
self.insert_markdown_into_post(post_id, &markdown)
|
|
}
|
|
|
|
fn insert_created_post_link(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(modal::ModalState::PostInsertLink { search_query, .. }) =
|
|
self.active_modal.clone()
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let title = search_query.trim();
|
|
if title.is_empty() {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "modal.postInsertLink.titleRequired"),
|
|
);
|
|
return Task::none();
|
|
}
|
|
|
|
let Some(ref db) = self.db else {
|
|
return Task::none();
|
|
};
|
|
let Some(ref data_dir) = self.data_dir else {
|
|
return Task::none();
|
|
};
|
|
let Some(ref project) = self.active_project else {
|
|
return Task::none();
|
|
};
|
|
|
|
match engine::post::create_post(
|
|
db.conn(),
|
|
data_dir,
|
|
&project.id,
|
|
title,
|
|
Some(""),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
None,
|
|
None,
|
|
None,
|
|
) {
|
|
Ok(post) => {
|
|
let markdown = bds_core::engine::post::post_insert_link(&post.slug).replacen(
|
|
"title",
|
|
&post.title,
|
|
1,
|
|
);
|
|
self.insert_markdown_into_post(post_id, &markdown)
|
|
}
|
|
Err(_) => {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "modal.postInsertLink.createFailed"),
|
|
);
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn insert_selected_media(&mut self, post_id: &str, media_id: &str) -> Task<Message> {
|
|
let Some(ref db) = self.db else {
|
|
return Task::none();
|
|
};
|
|
let Ok(media) = bds_core::db::queries::media::get_media_by_id(db.conn(), media_id) else {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "modal.insertMedia.loadFailed"),
|
|
);
|
|
return Task::none();
|
|
};
|
|
|
|
let link_only = matches!(
|
|
self.active_modal,
|
|
Some(modal::ModalState::InsertMedia {
|
|
link_only: true,
|
|
..
|
|
})
|
|
);
|
|
|
|
if let (Some(data_dir), Some(project)) = (&self.data_dir, &self.active_project) {
|
|
let already_linked = engine::post_media::list_media_for_post(db.conn(), post_id)
|
|
.map(|items| items.into_iter().any(|item| item.id == media_id))
|
|
.unwrap_or(false);
|
|
if !already_linked {
|
|
let sort_order = engine::post_media::list_media_for_post(db.conn(), post_id)
|
|
.map(|items| items.len() as i32)
|
|
.unwrap_or(0);
|
|
let _ = engine::post_media::link_media_to_post(
|
|
db.conn(),
|
|
data_dir,
|
|
&project.id,
|
|
post_id,
|
|
media_id,
|
|
sort_order,
|
|
);
|
|
}
|
|
}
|
|
|
|
self.refresh_post_relationships(post_id);
|
|
|
|
if link_only {
|
|
self.active_modal = None;
|
|
return Task::none();
|
|
}
|
|
|
|
let markdown = bds_core::engine::post::post_insert_media(
|
|
&media.file_path,
|
|
media.mime_type.starts_with("image/"),
|
|
&media.original_name,
|
|
);
|
|
self.insert_markdown_into_post(post_id, &markdown)
|
|
}
|
|
|
|
fn refresh_post_insert_link_modal(
|
|
&mut self,
|
|
active_tab: Option<modal::PostInsertLinkTab>,
|
|
search_query: Option<String>,
|
|
external_url: Option<String>,
|
|
external_text: Option<String>,
|
|
) {
|
|
let Some(modal::ModalState::PostInsertLink {
|
|
post_id,
|
|
title,
|
|
search_query: current_query,
|
|
active_tab: current_tab,
|
|
external_url: current_url,
|
|
external_text: current_text,
|
|
..
|
|
}) = self.active_modal.clone()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let next_query = search_query.unwrap_or(current_query);
|
|
let next_tab = active_tab.unwrap_or(current_tab);
|
|
let next_url = external_url.unwrap_or(current_url);
|
|
let next_text = external_text.unwrap_or(current_text);
|
|
|
|
self.active_modal = Some(modal::ModalState::PostInsertLink {
|
|
post_id: post_id.clone(),
|
|
title,
|
|
results: self.query_post_link_results(&post_id, &next_query),
|
|
search_query: next_query,
|
|
active_tab: next_tab,
|
|
external_url: next_url,
|
|
external_text: next_text,
|
|
});
|
|
}
|
|
|
|
fn refresh_insert_media_modal(&mut self, search_query: String) {
|
|
let Some(modal::ModalState::InsertMedia {
|
|
post_id,
|
|
title,
|
|
link_only,
|
|
..
|
|
}) = self.active_modal.clone()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
self.active_modal = Some(modal::ModalState::InsertMedia {
|
|
post_id,
|
|
title,
|
|
media_list: self.query_post_insert_media_results(&search_query),
|
|
search_query,
|
|
link_only,
|
|
});
|
|
}
|
|
|
|
fn update_gallery_selection(&mut self, next_index: Option<usize>) {
|
|
let Some(modal::ModalState::PostGallery {
|
|
post_id,
|
|
title,
|
|
media_list,
|
|
..
|
|
}) = self.active_modal.clone()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
self.active_modal = Some(modal::ModalState::PostGallery {
|
|
post_id,
|
|
title,
|
|
media_list,
|
|
selected_index: next_index,
|
|
});
|
|
}
|
|
|
|
fn step_gallery_selection(&mut self, delta: isize) {
|
|
let Some(modal::ModalState::PostGallery {
|
|
selected_index,
|
|
media_list,
|
|
..
|
|
}) = self.active_modal.clone()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let image_count = media_list
|
|
.iter()
|
|
.filter(|media| media.mime_type.starts_with("image/"))
|
|
.count();
|
|
if image_count == 0 {
|
|
return;
|
|
}
|
|
|
|
let current = selected_index.unwrap_or(0);
|
|
let next = if delta < 0 {
|
|
(current + image_count - 1) % image_count
|
|
} else {
|
|
(current + 1) % image_count
|
|
};
|
|
self.update_gallery_selection(Some(next));
|
|
}
|
|
|
|
fn save_media_editor(&mut self, media_id: &str) -> Task<Message> {
|
|
match self.persist_media_editor_state(media_id) {
|
|
Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")),
|
|
Err(e) => self.notify_operation_failed("common.save", e),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn load_media_linked_posts(&self, media_id: &str) -> Vec<LinkedPostItem> {
|
|
let Some(ref db) = self.db else {
|
|
return Vec::new();
|
|
};
|
|
|
|
bds_core::engine::post_media::list_posts_for_media(db.conn(), media_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|post| LinkedPostItem {
|
|
post_id: post.id,
|
|
title: if post.title.is_empty() {
|
|
t(self.ui_locale, "editor.untitled")
|
|
} else {
|
|
post.title
|
|
},
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn refresh_media_relationships(&mut self, media_id: &str) {
|
|
let linked_posts = self.load_media_linked_posts(media_id);
|
|
let search = self
|
|
.media_editors
|
|
.get(media_id)
|
|
.map(|state| state.post_picker_search.clone())
|
|
.unwrap_or_default();
|
|
let post_picker_results = self.query_media_post_picker_results(media_id, &search);
|
|
if let Some(state) = self.media_editors.get_mut(media_id) {
|
|
state.linked_posts = linked_posts;
|
|
state.post_picker_results = post_picker_results;
|
|
}
|
|
}
|
|
|
|
fn link_media_to_post(&mut self, media_id: &str, post_id: &str) -> Task<Message> {
|
|
if let (Some(db), Some(data_dir), Some(project)) =
|
|
(&self.db, &self.data_dir, &self.active_project)
|
|
{
|
|
let sort_order = bds_core::engine::post_media::list_media_for_post(db.conn(), post_id)
|
|
.map(|items| items.len() as i32)
|
|
.unwrap_or(0);
|
|
match bds_core::engine::post_media::link_media_to_post(
|
|
db.conn(),
|
|
data_dir,
|
|
&project.id,
|
|
post_id,
|
|
media_id,
|
|
sort_order,
|
|
) {
|
|
Ok(_) => {
|
|
self.refresh_media_relationships(media_id);
|
|
self.refresh_post_relationships(post_id);
|
|
}
|
|
Err(error) => {
|
|
self.notify_operation_failed("editor.linkToPost", error);
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn unlink_media_from_post(&mut self, media_id: &str, post_id: &str) -> Task<Message> {
|
|
if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) {
|
|
match bds_core::engine::post_media::unlink_media_from_post(
|
|
db.conn(),
|
|
data_dir,
|
|
post_id,
|
|
media_id,
|
|
) {
|
|
Ok(()) => {
|
|
self.refresh_media_relationships(media_id);
|
|
self.refresh_post_relationships(post_id);
|
|
}
|
|
Err(error) => {
|
|
self.notify_operation_failed("editor.unlinkMedia", error);
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn save_template_editor(&mut self, template_id: &str) -> Task<Message> {
|
|
let Some(state) = self.template_editors.get(template_id) else {
|
|
return Task::none();
|
|
};
|
|
let Some(ref db) = self.db else {
|
|
return Task::none();
|
|
};
|
|
let Some(ref project) = self.active_project else {
|
|
return Task::none();
|
|
};
|
|
|
|
match save_template_editor_state_impl(db, &project.id, state) {
|
|
Ok(tmpl) => {
|
|
let s = self.template_editors.get_mut(template_id).unwrap();
|
|
s.is_dirty = false;
|
|
s.updated_at = tmpl.updated_at;
|
|
s.validation_error = None;
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tmpl.id) {
|
|
tab.is_dirty = false;
|
|
tab.title = tmpl.title.clone();
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => {
|
|
if let Some(s) = self.template_editors.get_mut(template_id) {
|
|
s.validation_error = Some(e.clone());
|
|
}
|
|
self.notify_operation_failed("common.save", e);
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn save_script_editor(&mut self, script_id: &str) -> Task<Message> {
|
|
let Some(state) = self.script_editors.get(script_id) else {
|
|
return Task::none();
|
|
};
|
|
let Some(ref db) = self.db else {
|
|
return Task::none();
|
|
};
|
|
let Some(ref project) = self.active_project else {
|
|
return Task::none();
|
|
};
|
|
|
|
match save_script_editor_state_impl(db, &project.id, state) {
|
|
Ok(script) => {
|
|
let s = self.script_editors.get_mut(script_id).unwrap();
|
|
s.is_dirty = false;
|
|
s.updated_at = script.updated_at;
|
|
s.discovered_entrypoints = engine::script::discover_entrypoints(&s.content);
|
|
s.validation_error = None;
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == script.id) {
|
|
tab.is_dirty = false;
|
|
tab.title = script.title.clone();
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => {
|
|
if let Some(s) = self.script_editors.get_mut(script_id) {
|
|
s.validation_error = Some(e.clone());
|
|
}
|
|
self.notify_operation_failed("common.save", e);
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn apply_persisted_post_state(
|
|
&mut self,
|
|
post_id: &str,
|
|
state: &PostEditorState,
|
|
persisted: PersistedPostState,
|
|
) {
|
|
match persisted {
|
|
PersistedPostState::Translation(translation) => {
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.is_dirty = false;
|
|
editor.last_edit_at_ms = 0;
|
|
if let Some(draft) = editor.translation_drafts.get_mut(&state.active_language) {
|
|
draft.title = translation.title.clone();
|
|
draft.excerpt = translation.excerpt.clone().unwrap_or_default();
|
|
draft.content = translation.content.clone().unwrap_or_default();
|
|
draft.status = translation.status.clone();
|
|
draft.is_dirty = false;
|
|
}
|
|
}
|
|
}
|
|
PersistedPostState::Canonical(post) => {
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.title = post.title.clone();
|
|
editor.slug = post.slug.clone();
|
|
editor.excerpt = post.excerpt.clone().unwrap_or_default();
|
|
editor.author = post.author.clone().unwrap_or_default();
|
|
editor.language = post.language.clone().unwrap_or_default();
|
|
editor.template_slug = post.template_slug.clone().unwrap_or_default();
|
|
editor.do_not_translate = post.do_not_translate;
|
|
editor.tags = post.tags.clone();
|
|
editor.categories = post.categories.clone();
|
|
editor.status = post.status.clone();
|
|
editor.updated_at = post.updated_at;
|
|
editor.published_at = post.published_at;
|
|
editor.is_dirty = false;
|
|
editor.last_edit_at_ms = 0;
|
|
}
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post.id) {
|
|
tab.is_dirty = false;
|
|
if !post.title.is_empty() {
|
|
tab.title = post.title.clone();
|
|
}
|
|
}
|
|
self.refresh_post_relationships(post_id);
|
|
}
|
|
}
|
|
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post_id) {
|
|
tab.is_dirty = false;
|
|
}
|
|
}
|
|
|
|
fn persist_post_editor_state(&mut self, post_id: &str) -> Result<(), String> {
|
|
let state = self
|
|
.post_editors
|
|
.get(post_id)
|
|
.cloned()
|
|
.ok_or_else(|| "missing post editor".to_string())?;
|
|
let db = self
|
|
.db
|
|
.as_ref()
|
|
.ok_or_else(|| "database unavailable".to_string())?;
|
|
let data_dir = self
|
|
.data_dir
|
|
.as_ref()
|
|
.ok_or_else(|| "project data directory unavailable".to_string())?;
|
|
|
|
let persisted = persist_post_editor_state_impl(db, data_dir, &state)?;
|
|
self.apply_persisted_post_state(post_id, &state, persisted);
|
|
Ok(())
|
|
}
|
|
|
|
fn persist_post_editor_preview_state(&mut self, post_id: &str) -> Result<(), String> {
|
|
let state = self
|
|
.post_editors
|
|
.get(post_id)
|
|
.cloned()
|
|
.ok_or_else(|| "missing post editor".to_string())?;
|
|
let db = self
|
|
.db
|
|
.as_ref()
|
|
.ok_or_else(|| "database unavailable".to_string())?;
|
|
|
|
let persisted = persist_post_editor_preview_state_impl(db, &state)?;
|
|
self.apply_persisted_post_state(post_id, &state, persisted);
|
|
Ok(())
|
|
}
|
|
|
|
fn persist_media_editor_state(&mut self, media_id: &str) -> Result<(), String> {
|
|
let state = self
|
|
.media_editors
|
|
.get(media_id)
|
|
.cloned()
|
|
.ok_or_else(|| "missing media editor".to_string())?;
|
|
let db = self
|
|
.db
|
|
.as_ref()
|
|
.ok_or_else(|| "database unavailable".to_string())?;
|
|
let data_dir = self
|
|
.data_dir
|
|
.as_ref()
|
|
.ok_or_else(|| "project data directory unavailable".to_string())?;
|
|
|
|
match persist_media_editor_state_impl(db, data_dir, &state)? {
|
|
PersistedMediaState::Translation => {
|
|
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
|
editor.is_dirty = false;
|
|
}
|
|
}
|
|
PersistedMediaState::Canonical { media, tags } => {
|
|
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
|
editor.title = media.title.clone().unwrap_or_default();
|
|
editor.alt = media.alt.clone().unwrap_or_default();
|
|
editor.caption = media.caption.clone().unwrap_or_default();
|
|
editor.author = media.author.clone().unwrap_or_default();
|
|
editor.language = media.language.clone().unwrap_or_default();
|
|
editor.tags = tags;
|
|
editor.tags_input = editor.tags.join(", ");
|
|
editor.updated_at = media.updated_at;
|
|
editor.is_dirty = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == media_id) {
|
|
tab.is_dirty = false;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn close_entity_tab(&mut self, entity_id: &str) {
|
|
if let Some(next_idx) = tabs::close_tab(&mut self.tabs, entity_id) {
|
|
self.active_tab = self.tabs.get(next_idx).map(|tab| tab.id.clone());
|
|
} else if self.active_tab.as_deref() == Some(entity_id) {
|
|
self.active_tab = None;
|
|
}
|
|
self.enforce_panel_tab_fallback();
|
|
self.sync_menu_state();
|
|
}
|
|
|
|
fn delete_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else {
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = &self.data_dir else {
|
|
return Task::none();
|
|
};
|
|
match engine::post::delete_post(db.conn(), data_dir, post_id) {
|
|
Ok(()) => {
|
|
self.post_editors.remove(post_id);
|
|
self.close_entity_tab(post_id);
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted"));
|
|
}
|
|
Err(e) => self.notify_operation_failed("modal.confirmDelete.delete", e),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn discard_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else {
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = &self.data_dir else {
|
|
return Task::none();
|
|
};
|
|
match engine::post::discard_post_draft(db.conn(), data_dir, post_id) {
|
|
Ok(post) => {
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.title = post.title.clone();
|
|
editor.slug = post.slug.clone();
|
|
editor.excerpt = post.excerpt.clone().unwrap_or_default();
|
|
editor.content = post.content.clone().unwrap_or_default();
|
|
editor.author = post.author.clone().unwrap_or_default();
|
|
editor.language = post.language.clone().unwrap_or_default();
|
|
editor.active_language = editor.language.clone();
|
|
editor.canonical_language = editor.language.clone();
|
|
editor.template_slug = post.template_slug.clone().unwrap_or_default();
|
|
editor.tags = post.tags.clone();
|
|
editor.categories = post.categories.clone();
|
|
editor.status = post.status.clone();
|
|
editor.do_not_translate = post.do_not_translate;
|
|
editor.updated_at = post.updated_at;
|
|
editor.published_at = post.published_at;
|
|
editor.is_dirty = false;
|
|
editor.translation_drafts.clear();
|
|
}
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post.id) {
|
|
tab.is_dirty = false;
|
|
tab.title = post.title.clone();
|
|
}
|
|
self.refresh_post_relationships(post_id);
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify_operation_failed("editor.discard", e),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn unarchive_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
|
if let Err(error) = self.persist_post_editor_state(post_id) {
|
|
self.notify_operation_failed("editor.unarchive", error);
|
|
return Task::none();
|
|
}
|
|
let (Some(db), Some(data_dir)) = (self.db.as_ref(), self.data_dir.as_ref()) else {
|
|
return Task::none();
|
|
};
|
|
match engine::post::unarchive_post(db.conn(), data_dir, post_id) {
|
|
Ok(post) => {
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.content = post.content.clone().unwrap_or_default();
|
|
editor.status = post.status;
|
|
editor.updated_at = post.updated_at;
|
|
editor.is_dirty = false;
|
|
editor.last_edit_at_ms = 0;
|
|
}
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post_id) {
|
|
tab.is_dirty = false;
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.unarchived"));
|
|
return self.refresh_sidebar_posts();
|
|
}
|
|
Err(error) => self.notify_operation_failed("editor.unarchive", error),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn archive_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
|
if let Err(error) = self.persist_post_editor_state(post_id) {
|
|
self.notify_operation_failed("editor.archive", error);
|
|
return Task::none();
|
|
}
|
|
let (Some(db), Some(data_dir)) = (self.db.as_ref(), self.data_dir.as_ref()) else {
|
|
return Task::none();
|
|
};
|
|
if let Err(error) = engine::post::archive_post(db.conn(), data_dir, post_id) {
|
|
self.notify_operation_failed("editor.archive", error);
|
|
return Task::none();
|
|
}
|
|
match bds_core::db::queries::post::get_post_by_id(db.conn(), post_id) {
|
|
Ok(post) => {
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.status = post.status;
|
|
editor.updated_at = post.updated_at;
|
|
editor.is_dirty = false;
|
|
editor.last_edit_at_ms = 0;
|
|
}
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post_id) {
|
|
tab.is_dirty = false;
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.archived"));
|
|
return self.refresh_sidebar_posts();
|
|
}
|
|
Err(error) => self.notify_operation_failed("editor.archive", error),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn delete_media_editor(&mut self, media_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else {
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = &self.data_dir else {
|
|
return Task::none();
|
|
};
|
|
match engine::media::delete_media(db.conn(), data_dir, media_id) {
|
|
Ok(()) => {
|
|
self.media_editors.remove(media_id);
|
|
self.close_entity_tab(media_id);
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted"));
|
|
}
|
|
Err(e) => self.notify_operation_failed("modal.confirmDelete.delete", e),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn delete_template_editor(&mut self, template_id: &str, force: bool) -> Task<Message> {
|
|
let Some(db) = &self.db else {
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = &self.data_dir else {
|
|
return Task::none();
|
|
};
|
|
match engine::template::delete_template(db.conn(), data_dir, template_id, force) {
|
|
Ok(()) => {
|
|
self.template_editors.remove(template_id);
|
|
self.close_entity_tab(template_id);
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted"));
|
|
}
|
|
Err(e) => self.notify_operation_failed("modal.confirmDelete.delete", e),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn show_template_delete_confirmation(&mut self, template_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else {
|
|
return Task::none();
|
|
};
|
|
let Ok(template) =
|
|
bds_core::db::queries::template::get_template_by_id(db.conn(), template_id)
|
|
else {
|
|
return Task::none();
|
|
};
|
|
|
|
let referencing_posts =
|
|
bds_core::db::queries::post::list_posts_by_project(db.conn(), &template.project_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(|post| post.template_slug.as_deref() == Some(template.slug.as_str()))
|
|
.count();
|
|
let referencing_tags =
|
|
bds_core::db::queries::tag::list_tags_by_project(db.conn(), &template.project_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(|tag| tag.post_template_slug.as_deref() == Some(template.slug.as_str()))
|
|
.count();
|
|
|
|
if referencing_posts > 0 || referencing_tags > 0 {
|
|
let title = t(self.ui_locale, "template.forceDeleteTitle");
|
|
let message = tw(
|
|
self.ui_locale,
|
|
"template.forceDeleteMessage",
|
|
&[
|
|
("posts", &referencing_posts.to_string()),
|
|
("tags", &referencing_tags.to_string()),
|
|
],
|
|
);
|
|
return Task::done(Message::ShowModal(modal::ModalState::Confirm {
|
|
title,
|
|
message,
|
|
on_confirm: modal::ConfirmAction::ForceDeleteTemplate(template_id.to_string()),
|
|
}));
|
|
}
|
|
|
|
Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
|
entity_name: template.title,
|
|
references: Vec::new(),
|
|
on_confirm: modal::ConfirmAction::DeleteTemplate(template_id.to_string()),
|
|
}))
|
|
}
|
|
|
|
fn delete_script_editor(&mut self, script_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else {
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = &self.data_dir else {
|
|
return Task::none();
|
|
};
|
|
match engine::script::delete_script(db.conn(), data_dir, script_id) {
|
|
Ok(()) => {
|
|
self.script_editors.remove(script_id);
|
|
self.close_entity_tab(script_id);
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted"));
|
|
}
|
|
Err(e) => self.notify_operation_failed("modal.confirmDelete.delete", e),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn reload_tags_state(&mut self) {
|
|
let Some(db) = &self.db else { return };
|
|
let Some(project) = &self.active_project else {
|
|
return;
|
|
};
|
|
let tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
let template_options =
|
|
bds_core::db::queries::template::list_templates_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|template| template.slug)
|
|
.collect::<Vec<_>>();
|
|
let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
let mut counts = HashMap::new();
|
|
for post in &posts {
|
|
for tag_name in &post.tags {
|
|
*counts.entry(tag_name.to_lowercase()).or_insert(0usize) += 1;
|
|
}
|
|
}
|
|
if let Some(state) = self.tags_view_state.as_mut() {
|
|
state.tags = tags;
|
|
state.tag_post_counts = counts;
|
|
state.template_options = template_options;
|
|
state
|
|
.selected_tags
|
|
.retain(|selected_id| state.tags.iter().any(|tag| &tag.id == selected_id));
|
|
if state.selected_tags.len() == 1 {
|
|
if let Some(tag) = state
|
|
.tags
|
|
.iter()
|
|
.find(|tag| state.selected_tags[0] == tag.id)
|
|
{
|
|
state.editing_tag = Some(tags_view::EditingTag {
|
|
id: tag.id.clone(),
|
|
original_name: tag.name.clone(),
|
|
name: tag.name.clone(),
|
|
color: tag.color.clone().unwrap_or_default(),
|
|
template_slug: tag.post_template_slug.clone().unwrap_or_default(),
|
|
});
|
|
}
|
|
} else {
|
|
state.editing_tag = None;
|
|
}
|
|
if let Some(target_id) = state.merge_target.as_ref()
|
|
&& !state
|
|
.selected_tags
|
|
.iter()
|
|
.any(|selected_id| selected_id == target_id)
|
|
{
|
|
state.merge_target = None;
|
|
}
|
|
} else {
|
|
self.tags_view_state = Some(TagsViewState::new(tags, counts, template_options));
|
|
}
|
|
}
|
|
|
|
fn delete_tag(&mut self, tag_id: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else {
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = &self.data_dir else {
|
|
return Task::none();
|
|
};
|
|
let Some(project) = &self.active_project else {
|
|
return Task::none();
|
|
};
|
|
match engine::tag::delete_tag(db.conn(), data_dir, &project.id, tag_id) {
|
|
Ok(()) => {
|
|
self.reload_tags_state();
|
|
if let Some(state) = self.tags_view_state.as_mut()
|
|
&& state.editing_tag.as_ref().map(|tag| tag.id.as_str()) == Some(tag_id)
|
|
{
|
|
state.editing_tag = None;
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted"));
|
|
}
|
|
Err(e) => self.notify_operation_failed("modal.confirmDelete.delete", e),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn merge_tags(&mut self, sources: &[String], target: &str) -> Task<Message> {
|
|
let Some(db) = &self.db else {
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = &self.data_dir else {
|
|
return Task::none();
|
|
};
|
|
let Some(project) = &self.active_project else {
|
|
return Task::none();
|
|
};
|
|
let source_refs = sources.iter().map(String::as_str).collect::<Vec<_>>();
|
|
match engine::tag::merge_tags(db.conn(), data_dir, &project.id, &source_refs, target) {
|
|
Ok(()) => {
|
|
self.reload_tags_state();
|
|
if let Some(state) = self.tags_view_state.as_mut() {
|
|
state.selected_tags.clear();
|
|
state.merge_target = None;
|
|
state.editing_tag = None;
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify_operation_failed("tags.merge", e),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn hydrate_settings_state(&self) -> SettingsViewState {
|
|
let mut state = SettingsViewState::default();
|
|
if let Some(project) = &self.active_project {
|
|
state.project_name = project.name.clone();
|
|
state.project_description = iced::widget::text_editor::Content::with_text(
|
|
&project.description.clone().unwrap_or_default(),
|
|
);
|
|
state.data_path = project.data_path.clone().unwrap_or_default();
|
|
}
|
|
if let Some(data_dir) = &self.data_dir {
|
|
if let Ok(meta) = engine::meta::read_project_json(data_dir) {
|
|
state.public_url = meta.public_url.unwrap_or_default();
|
|
state.main_language = meta
|
|
.main_language
|
|
.unwrap_or_else(|| self.content_language.clone());
|
|
state.default_author = meta.default_author.unwrap_or_default();
|
|
state.max_posts_per_page = meta.max_posts_per_page.to_string();
|
|
state.image_import_concurrency = meta.image_import_concurrency.to_string();
|
|
state.blogmark_category = meta.blogmark_category.unwrap_or_default();
|
|
state.blog_languages = if meta.blog_languages.is_empty() {
|
|
vec![state.main_language.clone()]
|
|
} else {
|
|
meta.blog_languages
|
|
};
|
|
if !state
|
|
.blog_languages
|
|
.iter()
|
|
.any(|language| language == &state.main_language)
|
|
{
|
|
state.blog_languages.push(state.main_language.clone());
|
|
}
|
|
state.semantic_similarity_enabled = meta.semantic_similarity_enabled;
|
|
}
|
|
let categories = engine::meta::read_categories_json(data_dir).unwrap_or_else(|_| {
|
|
default_category_rows()
|
|
.into_iter()
|
|
.map(|row| row.name)
|
|
.collect()
|
|
});
|
|
let category_meta = engine::meta::read_category_meta_json(data_dir).unwrap_or_default();
|
|
state.categories = categories
|
|
.into_iter()
|
|
.map(|name| {
|
|
let meta = category_meta.get(&name);
|
|
SettingsCategoryRow {
|
|
title: meta
|
|
.and_then(|value| value.title.clone())
|
|
.unwrap_or_else(|| name.clone()),
|
|
render_in_lists: meta.map(|value| value.render_in_lists).unwrap_or(true),
|
|
show_title: meta.map(|value| value.show_title).unwrap_or(true),
|
|
post_template_slug: meta
|
|
.and_then(|value| value.post_template_slug.clone())
|
|
.unwrap_or_default(),
|
|
list_template_slug: meta
|
|
.and_then(|value| value.list_template_slug.clone())
|
|
.unwrap_or_default(),
|
|
is_protected: ["article", "aside", "page", "picture"]
|
|
.contains(&name.as_str()),
|
|
name,
|
|
}
|
|
})
|
|
.collect();
|
|
if let Ok(pub_prefs) = engine::meta::read_publishing_json(data_dir) {
|
|
state.ssh_host = pub_prefs.ssh_host.unwrap_or_default();
|
|
state.ssh_username = pub_prefs.ssh_user.unwrap_or_default();
|
|
state.ssh_remote_path = pub_prefs.ssh_remote_path.unwrap_or_default();
|
|
state.ssh_mode = match pub_prefs.ssh_mode {
|
|
SshMode::Scp => "scp".to_string(),
|
|
SshMode::Rsync => "rsync".to_string(),
|
|
};
|
|
}
|
|
}
|
|
if let Some(db) = &self.db {
|
|
if let Some(project) = &self.active_project {
|
|
state.template_options =
|
|
bds_core::db::queries::template::list_templates_by_project(
|
|
db.conn(),
|
|
&project.id,
|
|
)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|template| template.slug)
|
|
.collect();
|
|
}
|
|
if let Ok(Some(value)) =
|
|
engine::settings::get_effective(db.conn(), "editor.default_mode")
|
|
{
|
|
state.default_mode = value;
|
|
}
|
|
if let Ok(Some(value)) =
|
|
engine::settings::get_effective(db.conn(), "editor.diff_view_style")
|
|
{
|
|
state.diff_view_style = value;
|
|
}
|
|
if let Ok(Some(value)) =
|
|
engine::settings::get_effective(db.conn(), "editor.wrap_long_lines")
|
|
{
|
|
state.wrap_long_lines = value == "true";
|
|
}
|
|
if let Ok(Some(value)) =
|
|
engine::settings::get_effective(db.conn(), "editor.hide_unchanged_regions")
|
|
{
|
|
state.hide_unchanged_regions = value == "true";
|
|
}
|
|
if let Ok(setting) =
|
|
bds_core::db::queries::setting::get_setting_by_key(db.conn(), "ai.system_prompt")
|
|
{
|
|
state.system_prompt = iced::widget::text_editor::Content::with_text(&setting.value);
|
|
}
|
|
if let Ok(ai_settings) = ai::load_ai_settings(db.conn(), self.offline_mode) {
|
|
state.online_ai = Self::ai_mode_view_state(ai_settings.online);
|
|
state.airplane_ai = Self::ai_mode_view_state(ai_settings.airplane);
|
|
}
|
|
state.mcp_enabled = engine::settings::get_effective(db.conn(), "mcp.http.enabled")
|
|
.ok()
|
|
.flatten()
|
|
.is_some_and(|value| value == "true");
|
|
state.mcp_running = self.mcp_server.is_some();
|
|
state.mcp_endpoint = self
|
|
.mcp_server
|
|
.as_ref()
|
|
.map(engine::mcp::McpHttpServer::endpoint)
|
|
.unwrap_or_else(|| {
|
|
format!("http://127.0.0.1:{}/mcp", engine::mcp::DEFAULT_HTTP_PORT)
|
|
});
|
|
if let Some(project) = &self.active_project {
|
|
state.mcp_proposals =
|
|
engine::mcp::list_pending_proposals(db.conn(), &project.id).unwrap_or_default();
|
|
}
|
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
|
state.mcp_agents = engine::mcp::McpAgent::all()
|
|
.into_iter()
|
|
.map(|agent| crate::views::settings_view::SettingsMcpAgentRow {
|
|
agent,
|
|
label: agent.label().to_string(),
|
|
configured: engine::mcp::is_agent_configured(agent, &home),
|
|
config_path: engine::mcp::agent_config_path(agent, &home)
|
|
.to_string_lossy()
|
|
.into_owned(),
|
|
})
|
|
.collect();
|
|
}
|
|
state
|
|
}
|
|
|
|
fn handle_tags_msg(&mut self, msg: TagsMsg) -> Task<Message> {
|
|
// Ensure tags view state exists
|
|
if self.tags_view_state.is_none() {
|
|
self.reload_tags_state();
|
|
if self.tags_view_state.is_none() {
|
|
self.tags_view_state =
|
|
Some(TagsViewState::new(Vec::new(), HashMap::new(), Vec::new()));
|
|
}
|
|
}
|
|
let state = self.tags_view_state.as_mut().unwrap();
|
|
match msg {
|
|
TagsMsg::SetSection(s) => {
|
|
state.section = s;
|
|
}
|
|
TagsMsg::SearchChanged(q) => {
|
|
state.search_query = q;
|
|
}
|
|
TagsMsg::ToggleTagSelection(id) => {
|
|
if let Some(pos) = state
|
|
.selected_tags
|
|
.iter()
|
|
.position(|selected_id| selected_id == &id)
|
|
{
|
|
state.selected_tags.remove(pos);
|
|
} else {
|
|
state.selected_tags.push(id.clone());
|
|
}
|
|
|
|
if state.selected_tags.len() == 1 {
|
|
let selected_id = state.selected_tags[0].clone();
|
|
if let Some(tag) = state.tags.iter().find(|tag| tag.id == selected_id) {
|
|
state.editing_tag = Some(tags_view::EditingTag {
|
|
id: tag.id.clone(),
|
|
original_name: tag.name.clone(),
|
|
name: tag.name.clone(),
|
|
color: tag.color.clone().unwrap_or_default(),
|
|
template_slug: tag.post_template_slug.clone().unwrap_or_default(),
|
|
});
|
|
}
|
|
state.section = TagsSection::Manage;
|
|
} else {
|
|
state.editing_tag = None;
|
|
}
|
|
|
|
if let Some(target_id) = state.merge_target.as_ref()
|
|
&& !state
|
|
.selected_tags
|
|
.iter()
|
|
.any(|selected_id| selected_id == target_id)
|
|
{
|
|
state.merge_target = None;
|
|
}
|
|
}
|
|
TagsMsg::ClearSelection => {
|
|
state.selected_tags.clear();
|
|
state.merge_target = None;
|
|
state.editing_tag = None;
|
|
}
|
|
TagsMsg::CreateNameChanged(name) => {
|
|
state.create_name = name;
|
|
}
|
|
TagsMsg::CreateColorChanged(color) => {
|
|
state.create_color = color;
|
|
}
|
|
TagsMsg::CreateTag => {
|
|
let mut created_editing = None;
|
|
if let (Some(db), Some(data_dir), Some(project)) =
|
|
(&self.db, &self.data_dir, &self.active_project)
|
|
{
|
|
match engine::tag::create_tag(
|
|
db.conn(),
|
|
data_dir,
|
|
&project.id,
|
|
&state.create_name,
|
|
if state.create_color.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.create_color.trim())
|
|
},
|
|
) {
|
|
Ok(tag) => {
|
|
created_editing = Some(tags_view::EditingTag {
|
|
id: tag.id,
|
|
original_name: tag.name.clone(),
|
|
name: tag.name,
|
|
color: tag.color.unwrap_or_default(),
|
|
template_slug: tag.post_template_slug.unwrap_or_default(),
|
|
});
|
|
}
|
|
Err(e) => self.notify_operation_failed("common.save", e),
|
|
}
|
|
}
|
|
if let Some(editing_tag) = created_editing {
|
|
self.reload_tags_state();
|
|
if let Some(state) = self.tags_view_state.as_mut() {
|
|
state.selected_tags = vec![editing_tag.id.clone()];
|
|
state.editing_tag = Some(editing_tag);
|
|
state.create_name.clear();
|
|
state.create_color.clear();
|
|
state.section = TagsSection::Manage;
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
}
|
|
TagsMsg::EditTagName(s) => {
|
|
if let Some(ref mut e) = state.editing_tag {
|
|
e.name = s;
|
|
}
|
|
}
|
|
TagsMsg::EditTagColor(s) => {
|
|
if let Some(ref mut e) = state.editing_tag {
|
|
e.color = s;
|
|
}
|
|
}
|
|
TagsMsg::EditTagTemplate(option) => {
|
|
if let Some(ref mut e) = state.editing_tag {
|
|
e.template_slug = option.slug;
|
|
}
|
|
}
|
|
TagsMsg::SaveTag => {
|
|
if let Some(editing) = state.editing_tag.clone()
|
|
&& let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir)
|
|
{
|
|
let rename_result = if editing.name != editing.original_name {
|
|
engine::tag::rename_tag(
|
|
db.conn(),
|
|
data_dir,
|
|
self.active_project
|
|
.as_ref()
|
|
.map(|project| project.id.as_str())
|
|
.unwrap_or_default(),
|
|
&editing.id,
|
|
&editing.name,
|
|
)
|
|
} else {
|
|
Ok(())
|
|
};
|
|
match rename_result.and_then(|_| {
|
|
engine::tag::update_tag(
|
|
db.conn(),
|
|
data_dir,
|
|
&editing.id,
|
|
None,
|
|
Some(&editing.color),
|
|
Some(&editing.template_slug),
|
|
)
|
|
}) {
|
|
Ok(()) => {
|
|
self.reload_tags_state();
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify_operation_failed("common.save", e),
|
|
}
|
|
}
|
|
}
|
|
TagsMsg::DeleteTag(id) => {
|
|
let name = state
|
|
.tags
|
|
.iter()
|
|
.find(|t| t.id == id)
|
|
.map(|t| t.name.clone())
|
|
.unwrap_or_default();
|
|
let count = state
|
|
.tags
|
|
.iter()
|
|
.find(|t| t.id == id)
|
|
.and_then(|tag| state.tag_post_counts.get(&tag.name.to_lowercase()).copied())
|
|
.unwrap_or(0);
|
|
return Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
|
entity_name: name,
|
|
references: vec![tw(
|
|
self.ui_locale,
|
|
"tags.deleteUsage",
|
|
&[("count", &count.to_string())],
|
|
)],
|
|
on_confirm: modal::ConfirmAction::DeleteTag(id),
|
|
}));
|
|
}
|
|
TagsMsg::SetMergeTarget(option) => {
|
|
state.merge_target = Some(option.id);
|
|
}
|
|
TagsMsg::MergeTags => {
|
|
if let Some(target) = &state.merge_target {
|
|
let target_name = state
|
|
.tags
|
|
.iter()
|
|
.find(|tag| &tag.id == target)
|
|
.map(|tag| tag.name.clone())
|
|
.unwrap_or_default();
|
|
let sources = state
|
|
.selected_tags
|
|
.iter()
|
|
.filter(|tag_id| *tag_id != target)
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
return Task::done(Message::ShowModal(modal::ModalState::Confirm {
|
|
title: t(self.ui_locale, "tags.mergeConfirmTitle"),
|
|
message: tw(
|
|
self.ui_locale,
|
|
"tags.mergeConfirmMessage",
|
|
&[
|
|
("count", &sources.len().to_string()),
|
|
("target", &target_name),
|
|
],
|
|
),
|
|
on_confirm: modal::ConfirmAction::MergeTags {
|
|
sources,
|
|
target: target.clone(),
|
|
},
|
|
}));
|
|
}
|
|
}
|
|
TagsMsg::SyncTags => {
|
|
if let (Some(db), Some(data_dir), Some(project)) =
|
|
(&self.db, &self.data_dir, &self.active_project)
|
|
{
|
|
match engine::tag::discover_tags(db.conn(), data_dir, &project.id) {
|
|
Ok(_) => {
|
|
self.reload_tags_state();
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify_operation_failed("tags.discoverButton", e),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn hydrate_style_state(&self) -> Option<StyleViewState> {
|
|
self.active_project.as_ref()?;
|
|
let data_dir = self.data_dir.as_deref()?;
|
|
let metadata = engine::meta::read_project_json(data_dir).ok()?;
|
|
Some(StyleViewState::new(metadata.pico_theme.as_deref()))
|
|
}
|
|
|
|
fn handle_style_msg(&mut self, message: StyleMsg) -> Task<Message> {
|
|
match message {
|
|
StyleMsg::SelectTheme(theme) => {
|
|
if let Some(state) = self.style_view_state.as_mut() {
|
|
state.select_theme(&theme);
|
|
}
|
|
self.sync_embedded_preview_for_style()
|
|
}
|
|
StyleMsg::PreviewModeChanged(mode) => {
|
|
if let Some(state) = self.style_view_state.as_mut() {
|
|
state.set_preview_mode(mode);
|
|
}
|
|
self.sync_embedded_preview_for_style()
|
|
}
|
|
StyleMsg::Apply => {
|
|
let Some(theme) = self
|
|
.style_view_state
|
|
.as_ref()
|
|
.filter(|state| state.can_apply())
|
|
.map(|state| state.selected_theme.clone())
|
|
else {
|
|
return Task::none();
|
|
};
|
|
let result = (|| {
|
|
let db = self.db.as_ref().ok_or("database unavailable")?;
|
|
let data_dir = self
|
|
.data_dir
|
|
.as_deref()
|
|
.ok_or("project data directory unavailable")?;
|
|
let project = self.active_project.as_ref().ok_or("project unavailable")?;
|
|
let mut metadata = engine::meta::read_project_json(data_dir)
|
|
.map_err(|error| error.to_string())?;
|
|
metadata.pico_theme = Some(theme.clone());
|
|
engine::meta::update_project_metadata(db.conn(), data_dir, project, &metadata)
|
|
.map_err(|error| error.to_string())
|
|
})();
|
|
match result {
|
|
Ok(_) => {
|
|
if let Some(state) = self.style_view_state.as_mut() {
|
|
state.mark_applied();
|
|
}
|
|
self.theme_badge = theme;
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "style.applied"));
|
|
}
|
|
Err(error) => self.notify_operation_failed("style.apply", error),
|
|
}
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_settings_msg(&mut self, msg: SettingsMsg) -> Task<Message> {
|
|
// Ensure settings state exists
|
|
if self.settings_state.is_none() {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
}
|
|
let state = self.settings_state.as_mut().unwrap();
|
|
match msg {
|
|
SettingsMsg::SearchChanged(q) => {
|
|
state.search_query = q;
|
|
}
|
|
SettingsMsg::ToggleSection(section) => {
|
|
if let Some(pos) = state.collapsed.iter().position(|s| *s == section) {
|
|
state.collapsed.remove(pos);
|
|
} else {
|
|
state.collapsed.push(section);
|
|
}
|
|
}
|
|
SettingsMsg::ProjectNameChanged(s) => {
|
|
state.project_name = s;
|
|
}
|
|
SettingsMsg::ProjectDescriptionAction(action) => {
|
|
state.project_description.perform(action);
|
|
}
|
|
SettingsMsg::DataPathChanged(s) => {
|
|
state.data_path = s;
|
|
}
|
|
SettingsMsg::BrowseDataPath => {
|
|
return crate::platform::dialog::pick_folder(t(
|
|
self.ui_locale,
|
|
"dialog.selectFolder",
|
|
));
|
|
}
|
|
SettingsMsg::ResetDataPath => {
|
|
if let Some(ref project) = self.active_project {
|
|
state.data_path = project.data_path.clone().unwrap_or_default();
|
|
}
|
|
}
|
|
SettingsMsg::PublicUrlChanged(s) => {
|
|
state.public_url = s;
|
|
}
|
|
SettingsMsg::MainLanguageChanged(s) => {
|
|
state.main_language = s.clone();
|
|
if !state.blog_languages.iter().any(|language| language == &s) {
|
|
state.blog_languages.push(s);
|
|
}
|
|
}
|
|
SettingsMsg::ToggleBlogLanguage(language) => {
|
|
if language == state.main_language {
|
|
if !state.blog_languages.iter().any(|item| item == &language) {
|
|
state.blog_languages.push(language);
|
|
}
|
|
} else if let Some(index) = state
|
|
.blog_languages
|
|
.iter()
|
|
.position(|item| item == &language)
|
|
{
|
|
state.blog_languages.remove(index);
|
|
} else {
|
|
state.blog_languages.push(language);
|
|
}
|
|
state.blog_languages.sort();
|
|
state.blog_languages.dedup();
|
|
}
|
|
SettingsMsg::DefaultAuthorChanged(s) => {
|
|
state.default_author = s;
|
|
}
|
|
SettingsMsg::MaxPostsPerPageChanged(s) => {
|
|
state.max_posts_per_page = s;
|
|
}
|
|
SettingsMsg::ImageImportConcurrencyChanged(s) => {
|
|
state.image_import_concurrency = s;
|
|
}
|
|
SettingsMsg::BlogmarkCategoryChanged(s) => {
|
|
state.blogmark_category = s;
|
|
}
|
|
SettingsMsg::CopyBlogmarkBookmarklet => {
|
|
if let Some(project) = &self.active_project {
|
|
let bookmarklet = engine::blogmark::bookmarklet(&project.id);
|
|
self.notify(
|
|
ToastLevel::Success,
|
|
&t(self.ui_locale, "settings.blogmarkBookmarkletCopied"),
|
|
);
|
|
return iced::clipboard::write(bookmarklet);
|
|
}
|
|
}
|
|
SettingsMsg::SaveProject => {
|
|
if let (Some(db), Some(data_dir), Some(project)) =
|
|
(&self.db, &self.data_dir, self.active_project.as_mut())
|
|
{
|
|
let max_posts = match state.max_posts_per_page.trim().parse::<i32>() {
|
|
Ok(value) => value,
|
|
Err(_) => {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "settings.maxPostsPerPageInvalid"),
|
|
);
|
|
return Task::none();
|
|
}
|
|
};
|
|
let image_import_concurrency =
|
|
match state.image_import_concurrency.trim().parse::<i32>() {
|
|
Ok(value) => value.clamp(1, 8),
|
|
Err(_) => {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "settings.imageImportConcurrencyInvalid"),
|
|
);
|
|
return Task::none();
|
|
}
|
|
};
|
|
let mut meta = engine::meta::read_project_json(data_dir).unwrap_or(
|
|
bds_core::model::metadata::ProjectMetadata {
|
|
name: state.project_name.clone(),
|
|
description: None,
|
|
public_url: None,
|
|
main_language: None,
|
|
default_author: None,
|
|
max_posts_per_page: 50,
|
|
image_import_concurrency: 4,
|
|
blogmark_category: None,
|
|
pico_theme: None,
|
|
semantic_similarity_enabled: false,
|
|
blog_languages: Vec::new(),
|
|
},
|
|
);
|
|
let semantic_was_enabled = meta.semantic_similarity_enabled;
|
|
meta.name = state.project_name.clone();
|
|
meta.description = {
|
|
let value = state.project_description.text();
|
|
if value.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(value)
|
|
}
|
|
};
|
|
meta.public_url = if state.public_url.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.public_url.clone())
|
|
};
|
|
meta.main_language = if state.main_language.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.main_language.clone())
|
|
};
|
|
meta.default_author = if state.default_author.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.default_author.clone())
|
|
};
|
|
meta.max_posts_per_page = max_posts;
|
|
meta.image_import_concurrency = image_import_concurrency;
|
|
meta.blogmark_category = if state.blogmark_category.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.blogmark_category.clone())
|
|
};
|
|
meta.blog_languages = state.blog_languages.clone();
|
|
meta.semantic_similarity_enabled = state.semantic_similarity_enabled;
|
|
if let Err(e) = meta.validate() {
|
|
self.notify_operation_failed("common.save", e);
|
|
return Task::none();
|
|
}
|
|
project.name = state.project_name.clone();
|
|
project.description = meta.description.clone();
|
|
project.data_path = if state.data_path.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.data_path.clone())
|
|
};
|
|
project.updated_at = bds_core::util::now_unix_ms();
|
|
match engine::meta::update_project_metadata(db.conn(), data_dir, project, &meta)
|
|
{
|
|
Ok(_) => {
|
|
let semantic_should_backfill =
|
|
state.semantic_similarity_enabled && !semantic_was_enabled;
|
|
if let Some(listing) =
|
|
self.projects.iter_mut().find(|p| p.id == project.id)
|
|
{
|
|
*listing = project.clone();
|
|
}
|
|
self.content_language = state.main_language.clone();
|
|
self.blog_languages = state.blog_languages.clone();
|
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
if semantic_should_backfill {
|
|
return Task::done(Message::EmbeddingBackfill);
|
|
}
|
|
}
|
|
Err(e) => self.notify_operation_failed("common.save", e),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::DefaultModeChanged(s) => {
|
|
state.default_mode = s;
|
|
}
|
|
SettingsMsg::DiffViewStyleChanged(s) => {
|
|
state.diff_view_style = s;
|
|
}
|
|
SettingsMsg::WrapLongLinesChanged(b) => {
|
|
state.wrap_long_lines = b;
|
|
}
|
|
SettingsMsg::HideUnchangedRegionsChanged(b) => {
|
|
state.hide_unchanged_regions = b;
|
|
}
|
|
SettingsMsg::SaveEditor => {
|
|
if let Some(db) = &self.db {
|
|
match save_editor_settings_state_impl(db, state) {
|
|
Ok(_) => {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"))
|
|
}
|
|
Err(e) => self.notify_operation_failed("common.save", e),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::AddCategoryNameChanged(value) => {
|
|
state.new_category_name = value;
|
|
}
|
|
SettingsMsg::AddCategory => {
|
|
let category_name = state.new_category_name.trim();
|
|
if category_name.is_empty() {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "settings.categoryNameRequired"),
|
|
);
|
|
return Task::none();
|
|
}
|
|
if state
|
|
.categories
|
|
.iter()
|
|
.any(|row| row.name.eq_ignore_ascii_case(category_name))
|
|
{
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "settings.categoryAlreadyExists"),
|
|
);
|
|
return Task::none();
|
|
}
|
|
if let Some(data_dir) = &self.data_dir {
|
|
match engine::meta::add_category(data_dir, category_name) {
|
|
Ok(()) => {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
if let Some(state) = self.settings_state.as_mut() {
|
|
state.new_category_name.clear();
|
|
}
|
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify_operation_failed("common.save", e),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::CategoryTitleChanged(name, value) => {
|
|
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
|
|
row.title = value;
|
|
}
|
|
}
|
|
SettingsMsg::CategoryRenderInListsChanged(name, value) => {
|
|
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
|
|
row.render_in_lists = value;
|
|
}
|
|
}
|
|
SettingsMsg::CategoryShowTitleChanged(name, value) => {
|
|
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
|
|
row.show_title = value;
|
|
}
|
|
}
|
|
SettingsMsg::CategoryPostTemplateChanged(name, value) => {
|
|
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
|
|
row.post_template_slug = value;
|
|
}
|
|
}
|
|
SettingsMsg::CategoryListTemplateChanged(name, value) => {
|
|
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
|
|
row.list_template_slug = value;
|
|
}
|
|
}
|
|
SettingsMsg::SaveCategory(name) => {
|
|
if let Some(data_dir) = &self.data_dir
|
|
&& let Some(row) = state.categories.iter().find(|row| row.name == name)
|
|
{
|
|
let mut category_meta =
|
|
engine::meta::read_category_meta_json(data_dir).unwrap_or_default();
|
|
category_meta.insert(
|
|
row.name.clone(),
|
|
bds_core::model::metadata::CategorySettings {
|
|
title: Some(row.title.clone()).filter(|title| !title.trim().is_empty()),
|
|
render_in_lists: row.render_in_lists,
|
|
show_title: row.show_title,
|
|
post_template_slug: (!row.post_template_slug.is_empty())
|
|
.then(|| row.post_template_slug.clone()),
|
|
list_template_slug: (!row.list_template_slug.is_empty())
|
|
.then(|| row.list_template_slug.clone()),
|
|
},
|
|
);
|
|
match engine::meta::write_category_meta_json(data_dir, &category_meta) {
|
|
Ok(()) => {
|
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify_operation_failed("common.save", e),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::RemoveCategory(name) => {
|
|
if let Some(data_dir) = &self.data_dir {
|
|
match engine::meta::remove_category(data_dir, &name) {
|
|
Ok(()) => {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => self.notify_operation_failed("common.save", e),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::ResetCategoriesToDefaults => {
|
|
if let Some(data_dir) = &self.data_dir {
|
|
let default_names = default_category_rows()
|
|
.into_iter()
|
|
.map(|row| row.name)
|
|
.collect::<Vec<_>>();
|
|
let default_meta = default_category_rows()
|
|
.into_iter()
|
|
.map(|row| {
|
|
(
|
|
row.name,
|
|
bds_core::model::metadata::CategorySettings {
|
|
title: Some(row.title.clone())
|
|
.filter(|title| !title.trim().is_empty()),
|
|
render_in_lists: row.render_in_lists,
|
|
show_title: row.show_title,
|
|
post_template_slug: None,
|
|
list_template_slug: None,
|
|
},
|
|
)
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
match (
|
|
engine::meta::write_categories_json(data_dir, &default_names),
|
|
engine::meta::write_category_meta_json(data_dir, &default_meta),
|
|
) {
|
|
(Ok(()), Ok(())) => {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
(Err(e), _) | (_, Err(e)) => self.notify_operation_failed("common.save", e),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::SshModeChanged(s) => {
|
|
state.ssh_mode = s;
|
|
}
|
|
SettingsMsg::SshHostChanged(s) => {
|
|
state.ssh_host = s;
|
|
}
|
|
SettingsMsg::SshUsernameChanged(s) => {
|
|
state.ssh_username = s;
|
|
}
|
|
SettingsMsg::SshRemotePathChanged(s) => {
|
|
state.ssh_remote_path = s;
|
|
}
|
|
SettingsMsg::SavePublishing => {
|
|
if let Some(data_dir) = &self.data_dir {
|
|
let prefs = PublishingPreferences {
|
|
ssh_host: if state.ssh_host.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.ssh_host.clone())
|
|
},
|
|
ssh_user: if state.ssh_username.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.ssh_username.clone())
|
|
},
|
|
ssh_remote_path: if state.ssh_remote_path.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(state.ssh_remote_path.clone())
|
|
},
|
|
ssh_mode: if state.ssh_mode.eq_ignore_ascii_case("scp") {
|
|
SshMode::Scp
|
|
} else {
|
|
SshMode::Rsync
|
|
},
|
|
};
|
|
match engine::meta::write_publishing_json(data_dir, &prefs) {
|
|
Ok(()) => {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"))
|
|
}
|
|
Err(e) => self.notify_operation_failed("common.save", e),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::ClearPublishing => {
|
|
state.ssh_host.clear();
|
|
state.ssh_username.clear();
|
|
state.ssh_remote_path.clear();
|
|
}
|
|
SettingsMsg::AiEndpointUrlChanged(kind, value) => {
|
|
Self::ai_mode_state_mut(state, kind).endpoint_url = value;
|
|
}
|
|
SettingsMsg::AiApiKeyChanged(kind, value) => {
|
|
Self::ai_mode_state_mut(state, kind).api_key_input = value;
|
|
}
|
|
SettingsMsg::AiChatModelChanged(kind, value) => {
|
|
let mode = Self::ai_mode_state_mut(state, kind);
|
|
mode.chat_supports_tools = mode
|
|
.model_options
|
|
.iter()
|
|
.find(|option| option.id == value)
|
|
.is_some_and(|option| option.supports_tools);
|
|
mode.chat_model = value;
|
|
}
|
|
SettingsMsg::AiTitleModelChanged(kind, value) => {
|
|
Self::ai_mode_state_mut(state, kind).title_model = value;
|
|
}
|
|
SettingsMsg::AiImageModelChanged(kind, value) => {
|
|
let mode = Self::ai_mode_state_mut(state, kind);
|
|
mode.image_supports_vision = mode
|
|
.model_options
|
|
.iter()
|
|
.find(|option| option.id == value)
|
|
.is_some_and(|option| option.supports_vision);
|
|
mode.image_model = value;
|
|
}
|
|
SettingsMsg::AiToolsChanged(kind, value) => {
|
|
Self::ai_mode_state_mut(state, kind).chat_supports_tools = value;
|
|
}
|
|
SettingsMsg::AiVisionChanged(kind, value) => {
|
|
Self::ai_mode_state_mut(state, kind).image_supports_vision = value;
|
|
}
|
|
SettingsMsg::RefreshAiModels(kind) => {
|
|
if let Some(db) = &self.db {
|
|
match Self::refresh_ai_models(db, state, kind) {
|
|
Ok(()) => self.notify(
|
|
ToastLevel::Success,
|
|
&t(self.ui_locale, "settings.modelsLoaded"),
|
|
),
|
|
Err(error) => self.notify_operation_failed("settings.refreshModels", error),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::TestAi(kind) => {
|
|
if let Some(db) = &self.db {
|
|
match Self::test_ai_settings(db, state, kind) {
|
|
Ok(()) => self.notify(
|
|
ToastLevel::Success,
|
|
&t(self.ui_locale, "settings.testChatSuccess"),
|
|
),
|
|
Err(error) => self.notify_operation_failed("settings.testChat", error),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::SystemPromptAction(action) => {
|
|
state.system_prompt.perform(action);
|
|
}
|
|
SettingsMsg::SaveAi => {
|
|
if let Some(db) = &self.db {
|
|
match Self::save_ai_settings_state(db, state) {
|
|
Ok(()) => {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"))
|
|
}
|
|
Err(e) => self.notify_operation_failed("common.save", e),
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::ResetSystemPrompt => {
|
|
state.system_prompt = iced::widget::text_editor::Content::new();
|
|
}
|
|
SettingsMsg::SemanticSimilarityChanged(value) => {
|
|
state.semantic_similarity_enabled = value;
|
|
}
|
|
SettingsMsg::RebuildPosts => {
|
|
return self.spawn_engine_task(
|
|
"settings.rebuildPosts",
|
|
|db_path, project_id, data_dir, _tm, _tid| {
|
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
|
let report = engine::post::rebuild_posts_from_filesystem(
|
|
db.conn(),
|
|
&data_dir,
|
|
&project_id,
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(format!(
|
|
"created={}, updated={}, translations={}",
|
|
report.posts_created,
|
|
report.posts_updated,
|
|
report.translations_created + report.translations_updated,
|
|
))
|
|
},
|
|
);
|
|
}
|
|
SettingsMsg::RebuildMedia => {
|
|
return self.spawn_engine_task(
|
|
"settings.rebuildMedia",
|
|
|db_path, project_id, data_dir, _tm, _tid| {
|
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
|
let report = engine::media::rebuild_media_from_filesystem(
|
|
db.conn(),
|
|
&data_dir,
|
|
&project_id,
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(format!(
|
|
"created={}, updated={}, translations={}",
|
|
report.media_created,
|
|
report.media_updated,
|
|
report.translations_created + report.translations_updated,
|
|
))
|
|
},
|
|
);
|
|
}
|
|
SettingsMsg::RebuildScripts => {
|
|
return self.spawn_engine_task(
|
|
"settings.rebuildScripts",
|
|
|db_path, project_id, data_dir, _tm, _tid| {
|
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
|
let report = engine::script_rebuild::rebuild_scripts_from_filesystem(
|
|
db.conn(),
|
|
&data_dir,
|
|
&project_id,
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(format!(
|
|
"created={}, updated={}, errors={}",
|
|
report.created,
|
|
report.updated,
|
|
report.errors.len(),
|
|
))
|
|
},
|
|
);
|
|
}
|
|
SettingsMsg::RebuildTemplates => {
|
|
return self.spawn_engine_task(
|
|
"settings.rebuildTemplates",
|
|
|db_path, project_id, data_dir, _tm, _tid| {
|
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
|
let report = engine::template_rebuild::rebuild_templates_from_filesystem(
|
|
db.conn(),
|
|
&data_dir,
|
|
&project_id,
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(format!(
|
|
"created={}, updated={}, errors={}",
|
|
report.created,
|
|
report.updated,
|
|
report.errors.len(),
|
|
))
|
|
},
|
|
);
|
|
}
|
|
SettingsMsg::RebuildLinks => {
|
|
return self.spawn_engine_task(
|
|
"settings.rebuildLinks",
|
|
|db_path, project_id, data_dir, _tm, _tid| {
|
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
|
let rebuilt = bds_core::engine::post::rebuild_all_links(
|
|
db.conn(),
|
|
&data_dir,
|
|
&project_id,
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(format!("rebuilt={rebuilt}"))
|
|
},
|
|
);
|
|
}
|
|
SettingsMsg::RebuildSearchIndex => return Task::done(Message::ReindexText),
|
|
SettingsMsg::RegenerateThumbnails => {
|
|
let locale = self.ui_locale;
|
|
return self.spawn_engine_task(
|
|
"settings.regenerateThumbnails",
|
|
move |db_path, project_id, data_dir, tm, tid| {
|
|
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
|
let regenerated = Self::regenerate_project_thumbnails(
|
|
&db,
|
|
&data_dir,
|
|
&project_id,
|
|
|| tm.is_cancelled(tid),
|
|
|index, total, name| {
|
|
let progress = if total > 0 {
|
|
index as f32 / total as f32
|
|
} else {
|
|
1.0
|
|
};
|
|
tm.report_progress(
|
|
tid,
|
|
Some(progress),
|
|
Some(tw(locale, "engine.regeneratingItem", &[("name", name)])),
|
|
);
|
|
},
|
|
)?;
|
|
Ok(format!("regenerated={regenerated}"))
|
|
},
|
|
);
|
|
}
|
|
SettingsMsg::OpenDataFolder => {
|
|
if let Some(ref dir) = self.data_dir {
|
|
let _ = open::that(dir);
|
|
}
|
|
}
|
|
SettingsMsg::InstallCli => {
|
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
|
match engine::cli_launcher::install_packaged_launcher(&home) {
|
|
Ok(path) => self.notify(
|
|
ToastLevel::Success,
|
|
&tw(
|
|
self.ui_locale,
|
|
"settings.cliInstalled",
|
|
&[("path", &path.to_string_lossy())],
|
|
),
|
|
),
|
|
Err(error) => {
|
|
self.notify_operation_failed("settings.installCli", error.to_string())
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::McpEnabledChanged(enabled) => {
|
|
let Some(db) = &self.db else {
|
|
return Task::none();
|
|
};
|
|
if enabled {
|
|
if self.mcp_server.is_none() {
|
|
match engine::mcp::McpHttpServer::start(
|
|
self.db_path.clone(),
|
|
engine::mcp::DEFAULT_HTTP_PORT,
|
|
) {
|
|
Ok(server) => self.mcp_server = Some(server),
|
|
Err(error) => {
|
|
self.notify_operation_failed(
|
|
"settings.mcpEnable",
|
|
error.to_string(),
|
|
);
|
|
return Task::none();
|
|
}
|
|
}
|
|
}
|
|
} else if let Some(server) = self.mcp_server.take()
|
|
&& let Err(error) = server.stop()
|
|
{
|
|
self.notify_operation_failed("settings.mcpEnable", error.to_string());
|
|
return Task::none();
|
|
}
|
|
match engine::settings::set(
|
|
db.conn(),
|
|
"mcp.http.enabled",
|
|
if enabled { "true" } else { "false" },
|
|
) {
|
|
Ok(()) => {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(error) => {
|
|
self.notify_operation_failed("settings.mcpEnable", error.to_string())
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::McpProposalAccepted(proposal_id) => {
|
|
if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) {
|
|
match engine::mcp::accept_proposal(db.conn(), data_dir, &proposal_id) {
|
|
Ok(_) => {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
self.notify(
|
|
ToastLevel::Success,
|
|
&t(self.ui_locale, "settings.mcpProposalApproved"),
|
|
);
|
|
}
|
|
Err(error) => {
|
|
self.notify_operation_failed("settings.mcpApprove", error.to_string())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::McpProposalRejected(proposal_id) => {
|
|
if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) {
|
|
match engine::mcp::reject_proposal(db.conn(), data_dir, &proposal_id) {
|
|
Ok(_) => {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
self.notify(
|
|
ToastLevel::Success,
|
|
&t(self.ui_locale, "settings.mcpProposalRejected"),
|
|
);
|
|
}
|
|
Err(error) => {
|
|
self.notify_operation_failed("settings.mcpReject", error.to_string())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::McpAgentToggled(agent) => {
|
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
|
let result = if engine::mcp::is_agent_configured(agent, &home) {
|
|
engine::mcp::remove_agent_config(agent, &home)
|
|
} else {
|
|
engine::mcp::packaged_mcp_executable().and_then(|executable| {
|
|
engine::mcp::install_agent_config(agent, &home, &executable)
|
|
})
|
|
};
|
|
match result {
|
|
Ok(_) => {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(error) => {
|
|
self.notify_operation_failed("settings.mcpAgents", error.to_string())
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::McpRefresh => {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
}
|
|
SettingsMsg::FocusSection(section) => {
|
|
state.focus_section(section);
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
/// Load outlinks and backlinks for a post, resolving target post titles.
|
|
fn load_post_links(&self, post_id: &str) -> (Vec<ResolvedPostLink>, Vec<ResolvedPostLink>) {
|
|
let Some(ref db) = self.db else {
|
|
return (Vec::new(), Vec::new());
|
|
};
|
|
let outlinks = bds_core::db::queries::post_link::list_links_by_source(db.conn(), post_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|link| {
|
|
bds_core::db::queries::post::get_post_by_id(db.conn(), &link.target_post_id)
|
|
.ok()
|
|
.map(|p| ResolvedPostLink {
|
|
post_id: p.id,
|
|
title: p.title,
|
|
})
|
|
})
|
|
.collect();
|
|
let backlinks = bds_core::db::queries::post_link::list_links_by_target(db.conn(), post_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|link| {
|
|
bds_core::db::queries::post::get_post_by_id(db.conn(), &link.source_post_id)
|
|
.ok()
|
|
.map(|p| ResolvedPostLink {
|
|
post_id: p.id,
|
|
title: p.title,
|
|
})
|
|
})
|
|
.collect();
|
|
(outlinks, backlinks)
|
|
}
|
|
|
|
fn load_post_media_items(&self, post_id: &str, content: Option<&str>) -> Vec<LinkedMediaItem> {
|
|
let Some(ref db) = self.db else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let mut media_by_id =
|
|
bds_core::db::queries::post_media::list_post_media_by_post(db.conn(), post_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|link| {
|
|
bds_core::db::queries::media::get_media_by_id(db.conn(), &link.media_id)
|
|
.ok()
|
|
.map(|media| LinkedMediaItem {
|
|
media_id: media.id,
|
|
name: media.title.unwrap_or(media.original_name),
|
|
file_path: media.file_path,
|
|
is_image: media.mime_type.starts_with("image/"),
|
|
sort_order: link.sort_order,
|
|
})
|
|
})
|
|
.map(|media| (media.media_id.clone(), media))
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
for media_id in referenced_media_ids(content.unwrap_or_default()) {
|
|
if media_by_id.contains_key(&media_id) {
|
|
continue;
|
|
}
|
|
|
|
if let Ok(media) = bds_core::db::queries::media::get_media_by_id(db.conn(), &media_id) {
|
|
media_by_id.insert(
|
|
media_id,
|
|
LinkedMediaItem {
|
|
media_id: media.id,
|
|
name: media.title.unwrap_or(media.original_name),
|
|
file_path: media.file_path,
|
|
is_image: media.mime_type.starts_with("image/"),
|
|
sort_order: i32::MAX,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
let mut items = media_by_id.into_values().collect::<Vec<_>>();
|
|
items.sort_by_key(|media| media.sort_order);
|
|
items
|
|
}
|
|
|
|
fn refresh_post_relationships(&mut self, post_id: &str) {
|
|
let (outlinks, backlinks) = self.load_post_links(post_id);
|
|
let current_content = self
|
|
.post_editors
|
|
.get(post_id)
|
|
.map(|state| state.content.clone());
|
|
let linked_media = self.load_post_media_items(post_id, current_content.as_deref());
|
|
if let Some(state) = self.post_editors.get_mut(post_id) {
|
|
state.outlinks = outlinks;
|
|
state.backlinks = backlinks;
|
|
state.linked_media = linked_media;
|
|
}
|
|
}
|
|
|
|
fn project_tag_names(&self, project_id: &str) -> Vec<String> {
|
|
let Some(db) = self.db.as_ref() else {
|
|
return Vec::new();
|
|
};
|
|
let mut names = bds_core::db::queries::tag::list_tags_by_project(db.conn(), project_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|tag| tag.name)
|
|
.collect::<Vec<_>>();
|
|
for tag in bds_core::db::queries::post::list_posts_by_project(db.conn(), project_id)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.flat_map(|post| post.tags)
|
|
{
|
|
if !names
|
|
.iter()
|
|
.any(|existing| existing.eq_ignore_ascii_case(&tag))
|
|
{
|
|
names.push(tag);
|
|
}
|
|
}
|
|
names.sort_by_key(|name| name.to_lowercase());
|
|
names
|
|
}
|
|
|
|
fn refresh_post_editor_tag_options(&mut self) {
|
|
let Some(project_id) = self
|
|
.active_project
|
|
.as_ref()
|
|
.map(|project| project.id.clone())
|
|
else {
|
|
return;
|
|
};
|
|
let names = self.project_tag_names(&project_id);
|
|
for editor in self.post_editors.values_mut() {
|
|
editor.available_tags.clone_from(&names);
|
|
}
|
|
}
|
|
|
|
/// Load editor state when a tab is opened for an entity.
|
|
fn load_editor_for_tab(&mut self, tab: &Tab) {
|
|
let Some(ref db) = self.db else { return };
|
|
match tab.tab_type {
|
|
TabType::Post => {
|
|
if !self.post_editors.contains_key(&tab.id) {
|
|
match bds_core::db::queries::post::get_post_by_id(db.conn(), &tab.id) {
|
|
Ok(mut post) => {
|
|
// Published posts don't store body in DB — read from file
|
|
if post.content.is_none()
|
|
&& let Some(ref data_dir) = self.data_dir
|
|
{
|
|
let rel = bds_core::util::paths::post_file_path(
|
|
post.created_at,
|
|
&post.slug,
|
|
);
|
|
let path = data_dir.join(&rel);
|
|
if let Ok(raw) = std::fs::read_to_string(&path)
|
|
&& let Ok((_fm, body)) =
|
|
bds_core::util::frontmatter::read_post_file(&raw)
|
|
{
|
|
post.content = Some(body);
|
|
}
|
|
}
|
|
// Load translations for translation flags bar
|
|
let mut translations = bds_core::db::queries::post_translation::list_post_translations_by_post(
|
|
db.conn(), &post.id,
|
|
).unwrap_or_default();
|
|
// Published translations don't store body in DB — read from file
|
|
if let Some(ref data_dir) = self.data_dir {
|
|
for tr in &mut translations {
|
|
if tr.content.is_none() {
|
|
let rel = bds_core::util::paths::translation_file_path(
|
|
post.created_at,
|
|
&post.slug,
|
|
&tr.language,
|
|
);
|
|
let path = data_dir.join(&rel);
|
|
if let Ok(raw) = std::fs::read_to_string(&path)
|
|
&& let Ok((_fm, body)) =
|
|
bds_core::util::frontmatter::read_translation_file(
|
|
&raw,
|
|
)
|
|
{
|
|
tr.content = Some(body);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
|
let linked_media =
|
|
self.load_post_media_items(&post.id, post.content.as_deref());
|
|
let mut editor = PostEditorState::from_post(
|
|
&post,
|
|
default_post_editor_mode(self.settings_state.as_ref()),
|
|
&self.blog_languages,
|
|
&translations,
|
|
outlinks,
|
|
backlinks,
|
|
linked_media,
|
|
);
|
|
editor.available_tags = self.project_tag_names(&post.project_id);
|
|
self.post_editors.insert(post.id.clone(), editor);
|
|
}
|
|
Err(e) => {
|
|
self.notify_operation_failed("activity.posts", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TabType::Media => {
|
|
if !self.media_editors.contains_key(&tab.id) {
|
|
match bds_core::db::queries::media::get_media_by_id(db.conn(), &tab.id) {
|
|
Ok(media) => {
|
|
let translations = bds_core::db::queries::media_translation::list_media_translations_by_media(
|
|
db.conn(), &media.id,
|
|
).unwrap_or_default();
|
|
let linked_posts = self.load_media_linked_posts(&media.id);
|
|
self.media_editors.insert(
|
|
media.id.clone(),
|
|
MediaEditorState::from_media(
|
|
&media,
|
|
&self.blog_languages,
|
|
&translations,
|
|
linked_posts,
|
|
),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
self.notify_operation_failed("activity.media", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TabType::Templates => {
|
|
if !self.template_editors.contains_key(&tab.id) {
|
|
match bds_core::db::queries::template::get_template_by_id(db.conn(), &tab.id) {
|
|
Ok(mut template) => {
|
|
// Published templates: read content from file, strip frontmatter
|
|
if template.content.is_none()
|
|
&& let Some(ref data_dir) = self.data_dir
|
|
{
|
|
let rel = bds_core::util::paths::template_file_path(&template.slug);
|
|
let path = data_dir.join(&rel);
|
|
if let Ok(raw) = std::fs::read_to_string(&path)
|
|
&& let Ok((_fm, body)) =
|
|
bds_core::util::frontmatter::read_template_file(&raw)
|
|
{
|
|
template.content = Some(body);
|
|
}
|
|
}
|
|
self.template_editors.insert(
|
|
template.id.clone(),
|
|
TemplateEditorState::from_template(&template),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
self.notify_operation_failed("activity.templates", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TabType::Scripts => {
|
|
if !self.script_editors.contains_key(&tab.id) {
|
|
match bds_core::db::queries::script::get_script_by_id(db.conn(), &tab.id) {
|
|
Ok(mut script) => {
|
|
// Published scripts: read content from file using actual file_path
|
|
if script.content.is_none()
|
|
&& let Some(ref data_dir) = self.data_dir
|
|
{
|
|
let path = data_dir.join(&script.file_path);
|
|
if let Ok(raw) = std::fs::read_to_string(&path)
|
|
&& let Ok((_fm, body)) =
|
|
bds_core::util::frontmatter::read_script_file(&raw)
|
|
{
|
|
script.content = Some(body);
|
|
}
|
|
}
|
|
self.script_editors
|
|
.insert(script.id.clone(), ScriptEditorState::from_script(&script));
|
|
}
|
|
Err(e) => {
|
|
self.notify_operation_failed("activity.scripts", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TabType::Import => {
|
|
if !self.import_editors.contains_key(&tab.id) {
|
|
match engine::wordpress_import::get_definition(db.conn(), &tab.id) {
|
|
Ok(definition) => {
|
|
let project_data_dir = self
|
|
.projects
|
|
.iter()
|
|
.find(|project| project.id == definition.project_id)
|
|
.and_then(|project| project.data_path.as_deref())
|
|
.map(PathBuf::from)
|
|
.or_else(|| self.data_dir.clone());
|
|
let categories = project_data_dir
|
|
.as_deref()
|
|
.and_then(|path| engine::meta::read_categories_json(path).ok())
|
|
.unwrap_or_default();
|
|
let tags = bds_core::db::queries::tag::list_tags_by_project(
|
|
db.conn(),
|
|
&definition.project_id,
|
|
)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|tag| tag.name)
|
|
.collect();
|
|
self.import_editors.insert(
|
|
definition.id.clone(),
|
|
ImportEditorState::new(definition, categories, tags),
|
|
);
|
|
}
|
|
Err(error) => self.notify_operation_failed("activity.import", error),
|
|
}
|
|
}
|
|
}
|
|
TabType::Chat => {
|
|
if !self.chat_editors.contains_key(&tab.id) {
|
|
if self.settings_state.is_none() {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
}
|
|
match (
|
|
engine::chat::get_conversation(db.conn(), &tab.id),
|
|
engine::chat::list_messages(db.conn(), &tab.id),
|
|
) {
|
|
(Ok(conversation), Ok(messages)) => {
|
|
let models = self.chat_model_options();
|
|
self.chat_editors.insert(
|
|
tab.id.clone(),
|
|
ChatEditorState::new(conversation, messages, models),
|
|
);
|
|
}
|
|
(Err(error), _) | (_, Err(error)) => {
|
|
self.notify(ToastLevel::Error, &error.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TabType::Tags => {
|
|
if self.tags_view_state.is_none() {
|
|
let project_id = self
|
|
.active_project
|
|
.as_ref()
|
|
.map(|p| p.id.as_str())
|
|
.unwrap_or("");
|
|
// Import tags from file first, then sync from posts (additive only)
|
|
if let Some(ref data_dir) = self.data_dir {
|
|
let _ = bds_core::engine::tag::import_tags_from_file(
|
|
db.conn(),
|
|
data_dir,
|
|
project_id,
|
|
);
|
|
let _ = bds_core::engine::tag::sync_tags_from_posts(db.conn(), project_id);
|
|
}
|
|
let tags =
|
|
bds_core::db::queries::tag::list_tags_by_project(db.conn(), project_id)
|
|
.unwrap_or_default();
|
|
let template_options =
|
|
bds_core::db::queries::template::list_templates_by_project(
|
|
db.conn(),
|
|
project_id,
|
|
)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|template| template.slug)
|
|
.collect::<Vec<_>>();
|
|
// Compute post counts per tag for cloud sizing
|
|
let posts =
|
|
bds_core::db::queries::post::list_posts_by_project(db.conn(), project_id)
|
|
.unwrap_or_default();
|
|
let mut tag_post_counts = std::collections::HashMap::new();
|
|
for post in &posts {
|
|
for tag_name in &post.tags {
|
|
*tag_post_counts
|
|
.entry(tag_name.to_lowercase())
|
|
.or_insert(0usize) += 1;
|
|
}
|
|
}
|
|
self.tags_view_state =
|
|
Some(TagsViewState::new(tags, tag_post_counts, template_options));
|
|
}
|
|
}
|
|
TabType::Settings if self.settings_state.is_none() => {
|
|
self.settings_state = Some(self.hydrate_settings_state());
|
|
}
|
|
TabType::Style if self.style_view_state.is_none() => {
|
|
self.style_view_state = self.hydrate_style_state();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn refresh_ai_models(
|
|
db: &Database,
|
|
state: &mut SettingsViewState,
|
|
kind: AiEndpointKind,
|
|
) -> Result<(), String> {
|
|
let endpoint = Self::compose_ai_endpoint(state, kind)?;
|
|
let models = ai::refresh_model_catalog(&endpoint).map_err(|error| error.to_string())?;
|
|
ai::save_endpoint_models(db.conn(), kind, &models).map_err(|error| error.to_string())?;
|
|
let options = models
|
|
.into_iter()
|
|
.map(|model| AiModelOption {
|
|
id: model.id,
|
|
label: model.name,
|
|
supports_tools: model.supports_tools,
|
|
supports_vision: model.supports_vision,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
Self::ai_mode_state_mut(state, kind).model_options = options;
|
|
Ok(())
|
|
}
|
|
|
|
fn test_ai_settings(
|
|
_db: &Database,
|
|
state: &SettingsViewState,
|
|
kind: AiEndpointKind,
|
|
) -> Result<(), String> {
|
|
let endpoint = Self::compose_ai_endpoint(state, kind)?;
|
|
let mode = Self::ai_mode_state(state, kind);
|
|
let models = [&mode.chat_model, &mode.title_model, &mode.image_model]
|
|
.into_iter()
|
|
.filter(|model| !model.trim().is_empty())
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
if models.is_empty() {
|
|
return Err("select at least one model".to_string());
|
|
}
|
|
for model in models {
|
|
ai::test_chat(&endpoint, model).map_err(|error| error.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn save_ai_settings_state(db: &Database, state: &mut SettingsViewState) -> Result<(), String> {
|
|
for kind in [AiEndpointKind::Online, AiEndpointKind::Airplane] {
|
|
if Self::endpoint_has_configuration(state, kind) {
|
|
let endpoint = Self::compose_ai_endpoint(state, kind)?;
|
|
ai::save_endpoint(db.conn(), &endpoint).map_err(|error| error.to_string())?;
|
|
}
|
|
let mode = Self::ai_mode_state(state, kind);
|
|
ai::save_model_preferences(
|
|
db.conn(),
|
|
kind,
|
|
(!mode.title_model.trim().is_empty()).then_some(mode.title_model.as_str()),
|
|
(!mode.image_model.trim().is_empty()).then_some(mode.image_model.as_str()),
|
|
Some(mode.chat_supports_tools),
|
|
Some(mode.image_supports_vision),
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
}
|
|
ai::save_system_prompt(db.conn(), &state.system_prompt.text())
|
|
.map_err(|error| error.to_string())?;
|
|
for kind in [AiEndpointKind::Online, AiEndpointKind::Airplane] {
|
|
let mode = Self::ai_mode_state_mut(state, kind);
|
|
if !mode.api_key_input.trim().is_empty() {
|
|
mode.api_key_configured = true;
|
|
}
|
|
mode.api_key_input.clear();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn endpoint_has_configuration(state: &SettingsViewState, kind: AiEndpointKind) -> bool {
|
|
let mode = Self::ai_mode_state(state, kind);
|
|
!mode.endpoint_url.trim().is_empty()
|
|
|| !mode.chat_model.trim().is_empty()
|
|
|| !mode.api_key_input.trim().is_empty()
|
|
|| mode.api_key_configured
|
|
}
|
|
|
|
fn compose_ai_endpoint(
|
|
state: &SettingsViewState,
|
|
kind: AiEndpointKind,
|
|
) -> Result<AiEndpointConfig, String> {
|
|
let mode = Self::ai_mode_state(state, kind);
|
|
let input = mode.api_key_input.trim();
|
|
let api_key = if !input.is_empty() {
|
|
Some(input.to_string())
|
|
} else if mode.api_key_configured {
|
|
ai::load_endpoint_api_key(kind).map_err(|error| error.to_string())?
|
|
} else {
|
|
None
|
|
};
|
|
Ok(AiEndpointConfig {
|
|
kind,
|
|
url: mode.endpoint_url.trim().to_string(),
|
|
model: mode.chat_model.trim().to_string(),
|
|
api_key,
|
|
})
|
|
}
|
|
|
|
fn ai_mode_state(state: &SettingsViewState, kind: AiEndpointKind) -> &AiModeViewState {
|
|
match kind {
|
|
AiEndpointKind::Online => &state.online_ai,
|
|
AiEndpointKind::Airplane => &state.airplane_ai,
|
|
}
|
|
}
|
|
|
|
fn ai_mode_state_mut(
|
|
state: &mut SettingsViewState,
|
|
kind: AiEndpointKind,
|
|
) -> &mut AiModeViewState {
|
|
match kind {
|
|
AiEndpointKind::Online => &mut state.online_ai,
|
|
AiEndpointKind::Airplane => &mut state.airplane_ai,
|
|
}
|
|
}
|
|
|
|
fn ai_mode_view_state(settings: ai::AiModeSettings) -> AiModeViewState {
|
|
AiModeViewState {
|
|
endpoint_url: settings.endpoint.url,
|
|
chat_model: settings.endpoint.model,
|
|
title_model: settings.title_model.unwrap_or_default(),
|
|
image_model: settings.image_model.unwrap_or_default(),
|
|
api_key_configured: settings.endpoint.api_key_configured,
|
|
chat_supports_tools: settings.chat_supports_tools.unwrap_or(false),
|
|
image_supports_vision: settings.image_supports_vision.unwrap_or(false),
|
|
model_options: settings
|
|
.models
|
|
.into_iter()
|
|
.map(|model| AiModelOption {
|
|
id: model.id,
|
|
label: model.name,
|
|
supports_tools: model.supports_tools,
|
|
supports_vision: model.supports_vision,
|
|
})
|
|
.collect(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn ensure_preview_server(&mut self) -> Result<(), String> {
|
|
let Some(project) = self.active_project.as_ref() else {
|
|
return Err(t(self.ui_locale, "engine.generateSiteNoProject"));
|
|
};
|
|
let Some(data_dir) = self.data_dir.clone() else {
|
|
return Err(t(self.ui_locale, "engine.previewDataDirUnavailable"));
|
|
};
|
|
|
|
let should_restart = self
|
|
.preview_session
|
|
.as_ref()
|
|
.map(|session| session.project_id != project.id)
|
|
.unwrap_or(true);
|
|
if should_restart {
|
|
self.preview_session = None;
|
|
match engine::preview::start_preview_server(
|
|
self.db_path.clone(),
|
|
data_dir,
|
|
project.id.clone(),
|
|
) {
|
|
Ok(handle) => {
|
|
self.preview_session = Some(PreviewSession {
|
|
project_id: project.id.clone(),
|
|
handle,
|
|
});
|
|
}
|
|
Err(engine::EngineError::Conflict(_)) if self.preview_session.is_none() => {
|
|
return Err(t(self.ui_locale, "engine.previewPortInUse"));
|
|
}
|
|
Err(error) => {
|
|
return Err(error.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(session) = &self.preview_session {
|
|
let _ = &session.handle;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn preview_url_for_post(&mut self, post_id: &str) -> Result<String, String> {
|
|
self.persist_post_editor_preview_state(post_id)?;
|
|
self.ensure_preview_server()?;
|
|
|
|
let language = self
|
|
.post_editors
|
|
.get(post_id)
|
|
.map(|editor| editor.active_language.clone())
|
|
.filter(|language| !language.is_empty())
|
|
.unwrap_or_else(|| self.content_language.clone());
|
|
let db = self
|
|
.db
|
|
.as_ref()
|
|
.ok_or_else(|| t(self.ui_locale, "common.databaseUnavailable"))?;
|
|
let post = bds_core::db::queries::post::get_post_by_id(db.conn(), post_id)
|
|
.map_err(|error| error.to_string())?;
|
|
let main_language = self
|
|
.data_dir
|
|
.as_deref()
|
|
.and_then(|data_dir| engine::meta::read_project_json(data_dir).ok())
|
|
.and_then(|metadata| metadata.main_language)
|
|
.unwrap_or_else(|| language.clone());
|
|
|
|
Ok(post_preview_url(&post, &language, &main_language))
|
|
}
|
|
|
|
fn active_post_uses_embedded_preview(&self) -> bool {
|
|
self.active_tab
|
|
.as_ref()
|
|
.and_then(|tab_id| self.post_editors.get(tab_id))
|
|
.map(|editor| editor.editor_mode == "preview")
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn hide_embedded_preview(&self) {
|
|
if let Some(preview) = &self.embedded_preview {
|
|
preview.controller.set_visible(false);
|
|
}
|
|
}
|
|
|
|
fn active_style_uses_embedded_preview(&self) -> bool {
|
|
let active_is_style = self.active_tab.as_ref().is_some_and(|tab_id| {
|
|
self.tabs
|
|
.iter()
|
|
.any(|tab| tab.id == *tab_id && tab.tab_type == TabType::Style)
|
|
});
|
|
active_is_style && self.active_project.is_some() && self.style_view_state.is_some()
|
|
}
|
|
|
|
fn hide_embedded_style_preview(&self) {
|
|
if let Some(preview) = &self.embedded_style_preview {
|
|
preview.controller.set_visible(false);
|
|
}
|
|
}
|
|
|
|
fn sync_embedded_previews(&mut self) -> Task<Message> {
|
|
let post = self.sync_embedded_preview_for_active_post();
|
|
let style = self.sync_embedded_preview_for_style();
|
|
Task::batch([post, style])
|
|
}
|
|
|
|
fn sync_embedded_preview_for_style(&mut self) -> Task<Message> {
|
|
if !self.active_style_uses_embedded_preview() {
|
|
self.hide_embedded_style_preview();
|
|
return Task::none();
|
|
}
|
|
if let Err(error) = self.ensure_preview_server() {
|
|
self.notify(ToastLevel::Error, &error);
|
|
return Task::none();
|
|
}
|
|
let Some(url) = self
|
|
.style_view_state
|
|
.as_ref()
|
|
.map(StyleViewState::preview_url)
|
|
else {
|
|
return Task::none();
|
|
};
|
|
|
|
if let Some(preview) = &mut self.embedded_style_preview {
|
|
preview.current_url = Some(url.clone());
|
|
if preview.controller.is_active() {
|
|
preview.controller.navigate(&url);
|
|
preview.controller.set_visible(true);
|
|
return Task::none();
|
|
}
|
|
} else {
|
|
self.embedded_style_preview = Some(EmbeddedPreviewState {
|
|
controller: WebViewController::new(WebViewConfig::default().url(url.clone())),
|
|
current_url: Some(url.clone()),
|
|
creation_pending: false,
|
|
});
|
|
}
|
|
|
|
let Some(window_id) = self.main_window_id else {
|
|
return window::get_oldest().map(Message::MainWindowLoaded);
|
|
};
|
|
if let Some(preview) = &mut self.embedded_style_preview
|
|
&& should_start_embedded_preview_creation(
|
|
preview.controller.is_active(),
|
|
preview.creation_pending,
|
|
)
|
|
{
|
|
preview.controller = WebViewController::new(WebViewConfig::default().url(url));
|
|
preview.creation_pending = true;
|
|
return preview
|
|
.controller
|
|
.create_task(window_id, Message::EmbeddedStylePreviewReady);
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn sync_embedded_preview_for_active_post(&mut self) -> Task<Message> {
|
|
let Some(active_id) = self.active_tab.clone() else {
|
|
self.hide_embedded_preview();
|
|
return Task::none();
|
|
};
|
|
let Some(editor) = self.post_editors.get(&active_id) else {
|
|
self.hide_embedded_preview();
|
|
return Task::none();
|
|
};
|
|
if editor.editor_mode != "preview" {
|
|
self.hide_embedded_preview();
|
|
return Task::none();
|
|
}
|
|
|
|
let url = match self.preview_url_for_post(&active_id) {
|
|
Ok(url) => url,
|
|
Err(error) => {
|
|
self.notify(ToastLevel::Error, &error);
|
|
return Task::none();
|
|
}
|
|
};
|
|
|
|
if let Some(preview) = &mut self.embedded_preview {
|
|
preview.current_url = Some(url.clone());
|
|
if preview.controller.is_active() {
|
|
preview.controller.navigate(&url);
|
|
preview.controller.set_visible(true);
|
|
return Task::none();
|
|
}
|
|
} else {
|
|
self.embedded_preview = Some(EmbeddedPreviewState {
|
|
controller: WebViewController::new(WebViewConfig::default().url(url.clone())),
|
|
current_url: Some(url.clone()),
|
|
creation_pending: false,
|
|
});
|
|
}
|
|
|
|
let Some(window_id) = self.main_window_id else {
|
|
return window::get_oldest().map(Message::MainWindowLoaded);
|
|
};
|
|
|
|
if let Some(preview) = &mut self.embedded_preview
|
|
&& should_start_embedded_preview_creation(
|
|
preview.controller.is_active(),
|
|
preview.creation_pending,
|
|
)
|
|
{
|
|
preview.controller = WebViewController::new(WebViewConfig::default().url(url));
|
|
preview.creation_pending = true;
|
|
return preview
|
|
.controller
|
|
.create_task(window_id, Message::EmbeddedPreviewReady);
|
|
}
|
|
|
|
Task::none()
|
|
}
|
|
|
|
fn preview_active_post(&mut self) -> Task<Message> {
|
|
let Some(active_id) = self.active_tab.clone() else {
|
|
return Task::none();
|
|
};
|
|
if !self
|
|
.tabs
|
|
.iter()
|
|
.any(|tab| tab.id == active_id && tab.tab_type == TabType::Post)
|
|
{
|
|
return Task::none();
|
|
}
|
|
|
|
let url = match self.preview_url_for_post(&active_id) {
|
|
Ok(url) => url,
|
|
Err(error) => {
|
|
self.notify(ToastLevel::Error, &error);
|
|
return Task::none();
|
|
}
|
|
};
|
|
|
|
if let Err(error) = open::that(&url) {
|
|
self.notify(ToastLevel::Error, &error.to_string());
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn open_preview_in_browser(&mut self) -> Task<Message> {
|
|
let active_post = active_post_tab_id(self.active_tab.as_deref(), &self.tabs);
|
|
let url = match active_post {
|
|
Some(post_id) => self.preview_url_for_post(&post_id),
|
|
None => self
|
|
.ensure_preview_server()
|
|
.map(|()| format!("{}/", preview_base_url())),
|
|
};
|
|
match url {
|
|
Ok(url) => {
|
|
if let Err(error) = open::that(url) {
|
|
self.notify(ToastLevel::Error, &error.to_string());
|
|
}
|
|
}
|
|
Err(error) => self.notify(ToastLevel::Error, &error),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn start_one_shot_ai(
|
|
&self,
|
|
entity_id: String,
|
|
action: OneShotAiAction,
|
|
request: ai::OneShotRequest,
|
|
) -> Task<Message> {
|
|
let db_path = self.db_path.clone();
|
|
let offline_mode = self.offline_mode;
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
|
ai::run_one_shot(db.conn(), offline_mode, &request)
|
|
.map(|(response, _usage)| response)
|
|
.map_err(|error| error.to_string())
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
|
},
|
|
move |result| Message::OneShotAiFinished {
|
|
entity_id: entity_id.clone(),
|
|
action: action.clone(),
|
|
result,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn run_post_ai_analysis(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(state) = self.post_editors.get(post_id).cloned() else {
|
|
return Task::none();
|
|
};
|
|
let request = ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::AnalyzePost,
|
|
content: json!({
|
|
"title": state.title,
|
|
"excerpt": state.excerpt,
|
|
"content": content_sample(&state.content, 2000),
|
|
}),
|
|
};
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.ai_activity = Some(t(self.ui_locale, "editor.aiAnalyze"));
|
|
}
|
|
self.start_one_shot_ai(post_id.to_string(), OneShotAiAction::PostAnalysis, request)
|
|
}
|
|
|
|
fn run_post_taxonomy_analysis(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(state) = self.post_editors.get(post_id).cloned() else {
|
|
return Task::none();
|
|
};
|
|
let request = ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::AnalyzeTaxonomy,
|
|
content: json!({
|
|
"title": state.title,
|
|
"excerpt": state.excerpt,
|
|
"content": content_sample(&state.content, 2000),
|
|
"tags": state.tags,
|
|
"categories": state.categories,
|
|
}),
|
|
};
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.ai_activity = Some(t(self.ui_locale, "editor.suggestTaxonomy"));
|
|
}
|
|
self.start_one_shot_ai(post_id.to_string(), OneShotAiAction::PostTaxonomy, request)
|
|
}
|
|
|
|
fn detect_post_language(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(state) = self.post_editors.get(post_id).cloned() else {
|
|
return Task::none();
|
|
};
|
|
let request = ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::DetectLanguage,
|
|
content: json!({
|
|
"text": format!("{}\n\n{}\n\n{}", state.title, state.excerpt, content_sample(&state.content, 2000)),
|
|
}),
|
|
};
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.ai_activity = Some(t(self.ui_locale, "editor.detectLanguage"));
|
|
}
|
|
self.start_one_shot_ai(post_id.to_string(), OneShotAiAction::PostLanguage, request)
|
|
}
|
|
|
|
fn open_post_translation_modal(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(state) = self.post_editors.get(post_id) else {
|
|
return Task::none();
|
|
};
|
|
let targets = self
|
|
.blog_languages
|
|
.iter()
|
|
.filter(|language| **language != state.active_language)
|
|
.map(|language| modal::LanguageTarget {
|
|
code: language.clone(),
|
|
name: language_label(self.ui_locale, language),
|
|
flag_emoji: bds_core::i18n::normalize_language(language)
|
|
.flag_emoji()
|
|
.to_string(),
|
|
has_existing_translation: state.translation_drafts.contains_key(language),
|
|
existing_status: state.translation_drafts.get(language).map(|draft| {
|
|
match draft.status {
|
|
PostStatus::Draft => "draft".to_string(),
|
|
PostStatus::Published => "published".to_string(),
|
|
PostStatus::Archived => "archived".to_string(),
|
|
}
|
|
}),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
self.active_modal = Some(modal::ModalState::LanguagePicker {
|
|
target: modal::AiEntityTarget::Post(post_id.to_string()),
|
|
source_language: state.active_language.clone(),
|
|
available_targets: targets,
|
|
});
|
|
Task::none()
|
|
}
|
|
|
|
fn translate_post_to(&mut self, post_id: &str, target_language: &str) -> Task<Message> {
|
|
self.active_modal = None;
|
|
let Some(db) = &self.db else {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "common.databaseUnavailable"),
|
|
);
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = &self.data_dir else {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "engine.previewDataDirUnavailable"),
|
|
);
|
|
return Task::none();
|
|
};
|
|
let Some(state) = self.post_editors.get(post_id).cloned() else {
|
|
return Task::none();
|
|
};
|
|
|
|
if state.active_language == state.canonical_language
|
|
&& state.status == PostStatus::Published
|
|
{
|
|
match engine::post::update_post(
|
|
db.conn(),
|
|
data_dir,
|
|
post_id,
|
|
Some(&state.title),
|
|
None,
|
|
Some(Some(state.excerpt.as_str())),
|
|
Some(&state.content),
|
|
Some(state.tags.clone()),
|
|
Some(state.categories.clone()),
|
|
Some(Some(state.author.as_str())),
|
|
Some(Some(state.language.as_str())),
|
|
Some(Some(state.template_slug.as_str())),
|
|
Some(state.do_not_translate),
|
|
) {
|
|
Ok(updated) => {
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.status = updated.status;
|
|
}
|
|
}
|
|
Err(error) => {
|
|
self.notify(ToastLevel::Error, &error.to_string());
|
|
return Task::none();
|
|
}
|
|
}
|
|
}
|
|
|
|
let request = ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::TranslatePost {
|
|
target_language: target_language.to_string(),
|
|
},
|
|
content: json!({
|
|
"title": state.title,
|
|
"excerpt": state.excerpt,
|
|
"content": state.content,
|
|
}),
|
|
};
|
|
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
|
editor.ai_activity = Some(t(self.ui_locale, "editor.translate"));
|
|
}
|
|
self.start_one_shot_ai(
|
|
post_id.to_string(),
|
|
OneShotAiAction::PostTranslation {
|
|
target_language: target_language.to_string(),
|
|
},
|
|
request,
|
|
)
|
|
}
|
|
|
|
fn run_media_ai_analysis(&mut self, media_id: &str) -> Task<Message> {
|
|
let Some(_db) = &self.db else {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "common.databaseUnavailable"),
|
|
);
|
|
return Task::none();
|
|
};
|
|
let Some(data_dir) = &self.data_dir else {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "engine.previewDataDirUnavailable"),
|
|
);
|
|
return Task::none();
|
|
};
|
|
let Some(state) = self.media_editors.get(media_id).cloned() else {
|
|
return Task::none();
|
|
};
|
|
let image_data_url = match engine::gallery_import::build_ai_image_data_url(
|
|
data_dir,
|
|
&state.media_id,
|
|
&state.file_path,
|
|
&state.mime_type,
|
|
) {
|
|
Ok(value) => value,
|
|
Err(error) => {
|
|
self.notify(ToastLevel::Error, &error);
|
|
return Task::none();
|
|
}
|
|
};
|
|
let request = ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::AnalyzeImage,
|
|
content: json!({
|
|
"title": state.title,
|
|
"alt": state.alt,
|
|
"caption": state.caption,
|
|
"filename": state.original_name,
|
|
"mime_type": state.mime_type,
|
|
"image_data_url": image_data_url,
|
|
}),
|
|
};
|
|
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
|
editor.ai_activity = Some(t(self.ui_locale, "editor.aiAnalyze"));
|
|
}
|
|
self.start_one_shot_ai(
|
|
media_id.to_string(),
|
|
OneShotAiAction::MediaAnalysis,
|
|
request,
|
|
)
|
|
}
|
|
|
|
fn detect_media_language(&mut self, media_id: &str) -> Task<Message> {
|
|
let Some(_db) = &self.db else {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "common.databaseUnavailable"),
|
|
);
|
|
return Task::none();
|
|
};
|
|
let Some(state) = self.media_editors.get(media_id).cloned() else {
|
|
return Task::none();
|
|
};
|
|
let request = ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::DetectLanguage,
|
|
content: json!({
|
|
"text": format!("{}\n{}\n{}", state.title, state.alt, state.caption),
|
|
}),
|
|
};
|
|
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
|
editor.ai_activity = Some(t(self.ui_locale, "editor.detectLanguage"));
|
|
}
|
|
self.start_one_shot_ai(
|
|
media_id.to_string(),
|
|
OneShotAiAction::MediaLanguage,
|
|
request,
|
|
)
|
|
}
|
|
|
|
fn open_media_translation_modal(&mut self, media_id: &str) -> Task<Message> {
|
|
let Some(state) = self.media_editors.get(media_id) else {
|
|
return Task::none();
|
|
};
|
|
let targets = self
|
|
.blog_languages
|
|
.iter()
|
|
.filter(|language| **language != state.active_language)
|
|
.map(|language| modal::LanguageTarget {
|
|
code: language.clone(),
|
|
name: language_label(self.ui_locale, language),
|
|
flag_emoji: bds_core::i18n::normalize_language(language)
|
|
.flag_emoji()
|
|
.to_string(),
|
|
has_existing_translation: state.translation_drafts.contains_key(language),
|
|
existing_status: state
|
|
.translation_drafts
|
|
.get(language)
|
|
.map(|_| "draft".to_string()),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
self.active_modal = Some(modal::ModalState::LanguagePicker {
|
|
target: modal::AiEntityTarget::Media(media_id.to_string()),
|
|
source_language: state.active_language.clone(),
|
|
available_targets: targets,
|
|
});
|
|
Task::none()
|
|
}
|
|
|
|
fn translate_media_to(&mut self, media_id: &str, target_language: &str) -> Task<Message> {
|
|
self.active_modal = None;
|
|
let Some(_db) = &self.db else {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&t(self.ui_locale, "common.databaseUnavailable"),
|
|
);
|
|
return Task::none();
|
|
};
|
|
let Some(state) = self.media_editors.get(media_id).cloned() else {
|
|
return Task::none();
|
|
};
|
|
if let Some(editor) = self.media_editors.get_mut(media_id)
|
|
&& editor.language.is_empty()
|
|
{
|
|
editor.language = state.canonical_language.clone();
|
|
}
|
|
let request = ai::OneShotRequest {
|
|
operation: ai::OneShotOperation::TranslateMedia {
|
|
target_language: target_language.to_string(),
|
|
},
|
|
content: json!({
|
|
"title": state.title,
|
|
"alt": state.alt,
|
|
"caption": state.caption,
|
|
}),
|
|
};
|
|
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
|
editor.ai_activity = Some(t(self.ui_locale, "editor.translate"));
|
|
}
|
|
self.start_one_shot_ai(
|
|
media_id.to_string(),
|
|
OneShotAiAction::MediaTranslation {
|
|
target_language: target_language.to_string(),
|
|
},
|
|
request,
|
|
)
|
|
}
|
|
|
|
fn finish_one_shot_ai(
|
|
&mut self,
|
|
entity_id: &str,
|
|
action: OneShotAiAction,
|
|
result: Result<ai::OneShotResponse, String>,
|
|
) -> Task<Message> {
|
|
if matches!(
|
|
action,
|
|
OneShotAiAction::PostAnalysis
|
|
| OneShotAiAction::PostTaxonomy
|
|
| OneShotAiAction::PostLanguage
|
|
| OneShotAiAction::PostTranslation { .. }
|
|
) {
|
|
if let Some(editor) = self.post_editors.get_mut(entity_id) {
|
|
editor.ai_activity = None;
|
|
}
|
|
} else if let Some(editor) = self.media_editors.get_mut(entity_id) {
|
|
editor.ai_activity = None;
|
|
}
|
|
|
|
let response = match result {
|
|
Ok(response) => response,
|
|
Err(error) => {
|
|
self.notify(ToastLevel::Error, &error);
|
|
return Task::none();
|
|
}
|
|
};
|
|
|
|
match (action, response) {
|
|
(OneShotAiAction::PostAnalysis, ai::OneShotResponse::PostAnalysis(result)) => {
|
|
if let Some(state) = self.post_editors.get(entity_id).cloned() {
|
|
self.active_modal = Some(modal::ModalState::AISuggestions {
|
|
target: modal::AiEntityTarget::Post(entity_id.to_string()),
|
|
fields: vec![
|
|
modal::AiSuggestionField {
|
|
key: "title".to_string(),
|
|
label: t(self.ui_locale, "editor.title"),
|
|
current_value: state.title,
|
|
suggested_value: result.title,
|
|
accepted: true,
|
|
locked: false,
|
|
},
|
|
modal::AiSuggestionField {
|
|
key: "excerpt".to_string(),
|
|
label: t(self.ui_locale, "editor.excerpt"),
|
|
current_value: state.excerpt,
|
|
suggested_value: result.excerpt,
|
|
accepted: true,
|
|
locked: false,
|
|
},
|
|
modal::AiSuggestionField {
|
|
key: "slug".to_string(),
|
|
label: t(self.ui_locale, "editor.slug"),
|
|
current_value: state.slug,
|
|
suggested_value: result.slug,
|
|
accepted: state.published_at.is_none(),
|
|
locked: state.published_at.is_some(),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|
|
(OneShotAiAction::PostTaxonomy, ai::OneShotResponse::Taxonomy(result)) => {
|
|
if let Some(state) = self.post_editors.get(entity_id).cloned() {
|
|
self.active_modal = Some(modal::ModalState::AISuggestions {
|
|
target: modal::AiEntityTarget::Post(entity_id.to_string()),
|
|
fields: vec![
|
|
modal::AiSuggestionField {
|
|
key: "tags".to_string(),
|
|
label: t(self.ui_locale, "sidebar.filter.tags"),
|
|
current_value: state.tags.join(", "),
|
|
suggested_value: result.tags.join(", "),
|
|
accepted: true,
|
|
locked: false,
|
|
},
|
|
modal::AiSuggestionField {
|
|
key: "categories".to_string(),
|
|
label: t(self.ui_locale, "sidebar.filter.categories"),
|
|
current_value: state.categories.join(", "),
|
|
suggested_value: result.categories.join(", "),
|
|
accepted: true,
|
|
locked: false,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|
|
(OneShotAiAction::PostLanguage, ai::OneShotResponse::LanguageDetection(result)) => {
|
|
if let Some(editor) = self.post_editors.get_mut(entity_id) {
|
|
editor.language = result.language_code;
|
|
editor.mark_dirty();
|
|
}
|
|
if let Err(error) = self.persist_post_editor_state(entity_id) {
|
|
self.notify(ToastLevel::Error, &error);
|
|
} else {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
}
|
|
(
|
|
OneShotAiAction::PostTranslation { target_language },
|
|
ai::OneShotResponse::Translation(result),
|
|
) => {
|
|
if let Some(editor) = self.post_editors.get_mut(entity_id) {
|
|
editor.switch_language(&target_language);
|
|
editor.title = result.title.clone();
|
|
editor.excerpt = result.excerpt.clone();
|
|
editor.content = result.content.clone();
|
|
editor.editor_buffer =
|
|
std::cell::RefCell::new(bds_editor::EditorBuffer::new(&result.content));
|
|
editor.mark_dirty();
|
|
}
|
|
if let Err(error) = self.persist_post_editor_state(entity_id) {
|
|
self.notify(ToastLevel::Error, &error);
|
|
} else {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
}
|
|
(OneShotAiAction::MediaAnalysis, ai::OneShotResponse::ImageAnalysis(result)) => {
|
|
if let Some(state) = self.media_editors.get(entity_id).cloned() {
|
|
self.active_modal = Some(modal::ModalState::AISuggestions {
|
|
target: modal::AiEntityTarget::Media(entity_id.to_string()),
|
|
fields: vec![
|
|
modal::AiSuggestionField {
|
|
key: "title".to_string(),
|
|
label: t(self.ui_locale, "editor.title"),
|
|
current_value: state.title,
|
|
suggested_value: result.title,
|
|
accepted: true,
|
|
locked: false,
|
|
},
|
|
modal::AiSuggestionField {
|
|
key: "alt".to_string(),
|
|
label: t(self.ui_locale, "editor.alt"),
|
|
current_value: state.alt,
|
|
suggested_value: result.alt,
|
|
accepted: true,
|
|
locked: false,
|
|
},
|
|
modal::AiSuggestionField {
|
|
key: "caption".to_string(),
|
|
label: t(self.ui_locale, "editor.caption"),
|
|
current_value: state.caption,
|
|
suggested_value: result.caption,
|
|
accepted: true,
|
|
locked: false,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|
|
(OneShotAiAction::MediaLanguage, ai::OneShotResponse::LanguageDetection(result)) => {
|
|
if let Some(editor) = self.media_editors.get_mut(entity_id) {
|
|
editor.language = result.language_code;
|
|
editor.is_dirty = true;
|
|
}
|
|
if let Err(error) = self.persist_media_editor_state(entity_id) {
|
|
self.notify(ToastLevel::Error, &error);
|
|
} else {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
}
|
|
(
|
|
OneShotAiAction::MediaTranslation { target_language },
|
|
ai::OneShotResponse::MediaTranslation(result),
|
|
) => {
|
|
if let Some(editor) = self.media_editors.get_mut(entity_id) {
|
|
editor.switch_language(&target_language);
|
|
editor.title = result.title.clone();
|
|
editor.alt = result.alt.clone();
|
|
editor.caption = result.caption.clone();
|
|
editor.is_dirty = true;
|
|
}
|
|
if let Err(error) = self.persist_media_editor_state(entity_id) {
|
|
self.notify(ToastLevel::Error, &error);
|
|
} else {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn apply_ai_suggestions(
|
|
&mut self,
|
|
target: modal::AiEntityTarget,
|
|
fields: &[modal::AiSuggestionField],
|
|
) -> Task<Message> {
|
|
match target {
|
|
modal::AiEntityTarget::Post(post_id) => {
|
|
if let Some(editor) = self.post_editors.get_mut(&post_id) {
|
|
for field in fields
|
|
.iter()
|
|
.filter(|field| field.accepted && !field.locked)
|
|
{
|
|
match field.key.as_str() {
|
|
"title" => editor.title = field.suggested_value.clone(),
|
|
"excerpt" => editor.excerpt = field.suggested_value.clone(),
|
|
"slug" => editor.slug = field.suggested_value.clone(),
|
|
"tags" => {
|
|
editor.tags = split_csv_values(&field.suggested_value);
|
|
editor.tags_input.clear();
|
|
}
|
|
"categories" => {
|
|
editor.categories = split_csv_values(&field.suggested_value);
|
|
editor.categories_input.clear();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
editor.mark_dirty();
|
|
}
|
|
if let Err(error) = self.persist_post_editor_state(&post_id) {
|
|
self.notify(ToastLevel::Error, &error);
|
|
} else {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
}
|
|
modal::AiEntityTarget::Media(media_id) => {
|
|
if let Some(editor) = self.media_editors.get_mut(&media_id) {
|
|
for field in fields
|
|
.iter()
|
|
.filter(|field| field.accepted && !field.locked)
|
|
{
|
|
match field.key.as_str() {
|
|
"title" => editor.title = field.suggested_value.clone(),
|
|
"alt" => editor.alt = field.suggested_value.clone(),
|
|
"caption" => editor.caption = field.suggested_value.clone(),
|
|
_ => {}
|
|
}
|
|
}
|
|
editor.is_dirty = true;
|
|
}
|
|
if let Err(error) = self.persist_media_editor_state(&media_id) {
|
|
self.notify(ToastLevel::Error, &error);
|
|
} else {
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
}
|
|
|
|
impl Drop for BdsApp {
|
|
fn drop(&mut self) {
|
|
let _ = engine::embedding::EmbeddingService::flush_all();
|
|
}
|
|
}
|
|
|
|
fn content_sample(content: &str, max_len: usize) -> String {
|
|
content.chars().take(max_len).collect()
|
|
}
|
|
|
|
fn dropped_image_target(active_tab: Option<&str>, tabs: &[Tab], path: &Path) -> Option<String> {
|
|
if !engine::media::is_supported_image_path(path) {
|
|
return None;
|
|
}
|
|
let active_tab = active_tab?;
|
|
tabs.iter()
|
|
.find(|tab| tab.id == active_tab && tab.tab_type == TabType::Post)
|
|
.map(|tab| tab.id.clone())
|
|
}
|
|
|
|
fn active_post_tab_id(active_tab: Option<&str>, tabs: &[Tab]) -> Option<String> {
|
|
let active_tab = active_tab?;
|
|
tabs.iter()
|
|
.find(|tab| tab.id == active_tab && tab.tab_type == TabType::Post)
|
|
.map(|tab| tab.id.clone())
|
|
}
|
|
|
|
fn split_csv_values(value: &str) -> Vec<String> {
|
|
value
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|item| !item.is_empty())
|
|
.map(|item| item.to_string())
|
|
.collect()
|
|
}
|
|
|
|
fn language_label(locale: UiLocale, code: &str) -> String {
|
|
let key = format!("language.{code}");
|
|
let value = t(locale, &key);
|
|
if value == key {
|
|
code.to_string()
|
|
} else {
|
|
value
|
|
}
|
|
}
|
|
|
|
fn localize_chat_error(locale: UiLocale, error: &str) -> String {
|
|
if error.contains("AI unavailable - configure") {
|
|
t(locale, "chat.unavailable.guidance")
|
|
} else if let Some(detail) = error.strip_prefix("parse error: AI provider returned ") {
|
|
format!("{} {detail}", t(locale, "chat.providerError"))
|
|
} else {
|
|
error.to_string()
|
|
}
|
|
}
|
|
|
|
fn remote_error_closes_connection(code: &str) -> bool {
|
|
code == "connection_lost"
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState,
|
|
PostStatus, SettingsMsg, active_post_tab_id, dropped_image_target, localize_chat_error,
|
|
month_abbreviation, persist_media_editor_state_impl,
|
|
persist_post_editor_preview_state_impl, persist_post_editor_state_impl,
|
|
remote_error_closes_connection, save_editor_settings_state_impl,
|
|
save_script_editor_state_impl, save_template_editor_state_impl,
|
|
should_start_embedded_preview_creation,
|
|
};
|
|
use crate::i18n::t;
|
|
use crate::platform::menu::MenuAction;
|
|
use crate::state::ToastLevel;
|
|
use crate::state::navigation::SidebarView;
|
|
use crate::state::sidebar_filter::{MediaFilter, PostFilter};
|
|
use crate::state::tabs::{Tab, TabType};
|
|
use crate::views::chat_view::ChatEditorState;
|
|
use crate::views::documentation::{DocumentLoad, DocumentationKind};
|
|
use crate::views::media_editor::{MediaEditorMsg, MediaEditorState};
|
|
use crate::views::menu_editor::{MenuEditorMsg, MenuEditorState, MenuEditorStatus};
|
|
use crate::views::modal;
|
|
use crate::views::post_editor::{PostEditorMsg, PostEditorState};
|
|
use crate::views::script_editor::{ScriptEditorMsg, ScriptEditorState};
|
|
use crate::views::settings_view::{AiModeViewState, SettingsSection, SettingsViewState};
|
|
use crate::views::style_view::{PreviewMode, StyleMsg, StyleViewState};
|
|
use crate::views::template_editor::TemplateEditorState;
|
|
use bds_core::db::Database;
|
|
use bds_core::db::fts::ensure_fts_tables;
|
|
use bds_core::db::queries::project::insert_project;
|
|
use bds_core::engine::generation::GenerationReport;
|
|
use bds_core::engine::task::{TaskStatus, TaskStatus::*};
|
|
use bds_core::engine::{
|
|
ai, blogmark, chat, media, menu, meta, post, script, tag, template, wordpress_import,
|
|
};
|
|
use bds_core::i18n::UiLocale;
|
|
use bds_core::model::{
|
|
ChatRole, DomainEntity, DomainEvent, NotificationAction, Project, ScriptKind, TemplateKind,
|
|
};
|
|
use chrono::{Datelike, TimeZone};
|
|
use std::io::{Read, Write};
|
|
use std::net::TcpListener;
|
|
use std::path::{Path, PathBuf};
|
|
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 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)
|
|
}
|
|
|
|
#[test]
|
|
fn remote_connection_menu_opens_localized_selection_and_failure_states() {
|
|
let (db, project, temp) = setup();
|
|
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
|
|
let _ = app.dispatch_menu_action(MenuAction::ConnectServer);
|
|
assert!(matches!(
|
|
app.active_modal,
|
|
Some(modal::ModalState::RemoteConnection {
|
|
connected: false,
|
|
ref projects,
|
|
..
|
|
}) if projects.is_empty()
|
|
));
|
|
|
|
let _ = app.update(Message::RemoteTargetChanged("not-a-target".into()));
|
|
let _ = app.update(Message::RemoteConnectRequested);
|
|
assert!(matches!(
|
|
app.active_modal,
|
|
Some(modal::ModalState::RemoteConnection {
|
|
connecting: false,
|
|
error: Some(ref error),
|
|
..
|
|
}) if error == &t(UiLocale::En, "remoteConnection.invalidTarget")
|
|
));
|
|
|
|
let _ = app.update(Message::RemoteConnected(Err("refused".into())));
|
|
assert!(matches!(
|
|
app.active_modal,
|
|
Some(modal::ModalState::RemoteConnection {
|
|
connected: false,
|
|
error: Some(ref error),
|
|
..
|
|
}) if error.contains("refused") && error != "refused"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn only_transport_loss_closes_an_open_remote_session() {
|
|
assert!(remote_error_closes_connection("connection_lost"));
|
|
assert!(!remote_error_closes_connection("sync_watcher_error"));
|
|
assert!(!remote_error_closes_connection("engine_error"));
|
|
}
|
|
|
|
#[test]
|
|
fn imported_blogmark_activates_posts_and_opens_its_editor() {
|
|
let (db, project, temp) = setup();
|
|
ai::save_endpoint(
|
|
db.conn(),
|
|
&ai::AiEndpointConfig {
|
|
kind: ai::AiEndpointKind::Airplane,
|
|
url: "http://127.0.0.1:9".to_string(),
|
|
model: "test".to_string(),
|
|
api_key: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
temp.path(),
|
|
&project.id,
|
|
"Saved From Browser",
|
|
Some("[Saved From Browser](https://example.com/)"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
|
|
let settings = SettingsViewState {
|
|
default_mode: "preview".to_string(),
|
|
..Default::default()
|
|
};
|
|
app.settings_state = Some(settings);
|
|
app.offline_mode = true;
|
|
app.sidebar_view = SidebarView::Settings;
|
|
app.sidebar_visible = false;
|
|
let task_id = app.task_manager.submit("Importing blogmark");
|
|
|
|
let _ = app.update(Message::BlogmarkImported {
|
|
task_id,
|
|
result: Ok(blogmark::BlogmarkImportResult {
|
|
post: created.clone(),
|
|
toasts: Vec::new(),
|
|
transform_errors: Vec::new(),
|
|
}),
|
|
});
|
|
|
|
assert_eq!(app.sidebar_view, SidebarView::Posts);
|
|
assert!(app.sidebar_visible);
|
|
assert_eq!(app.active_tab.as_deref(), Some(created.id.as_str()));
|
|
assert!(app.post_editors.contains_key(&created.id));
|
|
assert!(app.embedded_preview.is_some());
|
|
assert!(
|
|
app.task_manager
|
|
.snapshots()
|
|
.iter()
|
|
.all(|task| task.group_name.as_deref() != Some("AI"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn documentation_external_links_require_confirmation_and_api_help_opens_real_tab() {
|
|
let (db, project, temp) = setup();
|
|
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
|
|
|
|
let _ = app.update(Message::DocumentationLinkClicked(
|
|
DocumentationKind::Guide,
|
|
"https://example.com/guide".to_string(),
|
|
));
|
|
assert!(matches!(
|
|
app.active_modal,
|
|
Some(modal::ModalState::Confirm {
|
|
on_confirm: modal::ConfirmAction::OpenExternalUrl(ref url),
|
|
..
|
|
}) if url == "https://example.com/guide"
|
|
));
|
|
|
|
app.active_modal = None;
|
|
let _ = app.update(Message::DocumentationLinkClicked(
|
|
DocumentationKind::Guide,
|
|
crate::views::documentation::API_DOCUMENTATION_URL.to_string(),
|
|
));
|
|
assert!(
|
|
app.tabs
|
|
.iter()
|
|
.any(|tab| tab.tab_type == TabType::ApiDocumentation)
|
|
);
|
|
assert_eq!(
|
|
app.api_documentation.status,
|
|
crate::views::documentation::DocumentStatus::Loading
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn help_menu_opens_cli_and_mcp_documentation_tabs() {
|
|
let (db, project, temp) = setup();
|
|
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
|
|
|
|
let _ = app.dispatch_menu_action(MenuAction::OpenCliDocumentation);
|
|
assert!(
|
|
app.tabs
|
|
.iter()
|
|
.any(|tab| tab.tab_type == TabType::CliDocumentation)
|
|
);
|
|
assert_eq!(
|
|
app.cli_documentation.status,
|
|
crate::views::documentation::DocumentStatus::Loading
|
|
);
|
|
|
|
let _ = app.dispatch_menu_action(MenuAction::OpenMcpDocumentation);
|
|
assert!(
|
|
app.tabs
|
|
.iter()
|
|
.any(|tab| tab.tab_type == TabType::McpDocumentation)
|
|
);
|
|
assert_eq!(
|
|
app.mcp_documentation.status,
|
|
crate::views::documentation::DocumentStatus::Loading
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn project_switch_keeps_global_documentation_loaded() {
|
|
let (db, project, temp) = setup();
|
|
let second_dir = temp.path().join("second-project");
|
|
std::fs::create_dir_all(second_dir.join("meta")).unwrap();
|
|
for name in [
|
|
"project.json",
|
|
"publishing.json",
|
|
"categories.json",
|
|
"category-meta.json",
|
|
] {
|
|
std::fs::copy(
|
|
temp.path().join("meta").join(name),
|
|
second_dir.join("meta").join(name),
|
|
)
|
|
.unwrap();
|
|
}
|
|
let second = Project {
|
|
id: "p2".to_string(),
|
|
name: "Second Project".to_string(),
|
|
slug: "second-project".to_string(),
|
|
data_path: Some(second_dir.to_string_lossy().into_owned()),
|
|
is_active: false,
|
|
..project.clone()
|
|
};
|
|
insert_project(db.conn(), &second).unwrap();
|
|
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
|
|
app.projects.push(second.clone());
|
|
app.tabs.push(Tab {
|
|
id: "documentation".to_string(),
|
|
tab_type: TabType::Documentation,
|
|
title: "Documentation".to_string(),
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
});
|
|
app.guide_documentation.apply(DocumentLoad::Ready {
|
|
source: "# Global guide".to_string(),
|
|
signature: 42,
|
|
});
|
|
|
|
let _ = app.update(Message::SwitchProject(second.id.clone()));
|
|
|
|
assert_eq!(
|
|
app.active_project.as_ref().map(|item| item.id.as_str()),
|
|
Some("p2")
|
|
);
|
|
assert_eq!(app.data_dir.as_deref(), Some(second_dir.as_path()));
|
|
assert_eq!(
|
|
app.guide_documentation.status,
|
|
crate::views::documentation::DocumentStatus::Ready
|
|
);
|
|
assert_eq!(app.guide_documentation.signature, 42);
|
|
}
|
|
|
|
#[test]
|
|
fn menu_editor_persists_pages_new_categories_and_reload_roundtrip() {
|
|
let (db, project, temp) = setup();
|
|
let page = post::create_post(
|
|
db.conn(),
|
|
temp.path(),
|
|
&project.id,
|
|
"About",
|
|
Some("About body"),
|
|
Vec::new(),
|
|
vec!["page".to_string()],
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
|
|
|
|
let _ = app.update(Message::MenuEditor(MenuEditorMsg::Reload));
|
|
assert_eq!(app.menu_editor_state.status, MenuEditorStatus::Ready);
|
|
assert!(
|
|
app.menu_editor_state
|
|
.pages
|
|
.iter()
|
|
.any(|item| item.id == page.id)
|
|
);
|
|
|
|
let _ = app.update(Message::MenuEditor(MenuEditorMsg::StartDraft(
|
|
crate::views::menu_editor::DraftKind::Page,
|
|
)));
|
|
let _ = app.update(Message::MenuEditor(MenuEditorMsg::ChoosePage(page.id)));
|
|
let _ = app.update(Message::MenuEditor(MenuEditorMsg::StartDraft(
|
|
crate::views::menu_editor::DraftKind::Category,
|
|
)));
|
|
let _ = app.update(Message::MenuEditor(MenuEditorMsg::DraftChanged(
|
|
"Long Form".to_string(),
|
|
)));
|
|
let _ = app.update(Message::MenuEditor(MenuEditorMsg::SubmitDraft));
|
|
assert!(
|
|
meta::read_categories_json(temp.path())
|
|
.unwrap()
|
|
.contains(&"Long Form".to_string())
|
|
);
|
|
assert!(
|
|
meta::read_category_meta_json(temp.path())
|
|
.unwrap()
|
|
.contains_key("Long Form")
|
|
);
|
|
|
|
let _ = app.update(Message::MenuEditor(MenuEditorMsg::Save));
|
|
let saved = menu::read_menu(temp.path()).unwrap();
|
|
assert_eq!(saved[0].kind, menu::MenuItemKind::Home);
|
|
assert!(
|
|
saved
|
|
.iter()
|
|
.any(|item| item.kind == menu::MenuItemKind::Page
|
|
&& item.slug.as_deref() == Some("about"))
|
|
);
|
|
assert!(
|
|
saved
|
|
.iter()
|
|
.any(|item| item.kind == menu::MenuItemKind::CategoryArchive
|
|
&& item.slug.as_deref() == Some("Long Form"))
|
|
);
|
|
assert!(!app.menu_editor_state.dirty);
|
|
|
|
app.menu_editor_state = MenuEditorState::default();
|
|
let _ = app.update(Message::MenuEditor(MenuEditorMsg::Reload));
|
|
assert_eq!(app.menu_editor_state.items.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn stale_documentation_load_cannot_overwrite_a_newer_guide_read() {
|
|
let (db, project, temp) = setup();
|
|
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
|
|
let stale = app.guide_documentation.start_loading();
|
|
let current = app.guide_documentation.start_loading();
|
|
|
|
let _ = app.update(Message::DocumentationLoaded(
|
|
DocumentationKind::Guide,
|
|
stale,
|
|
DocumentLoad::Ready {
|
|
source: "# Old guide".to_string(),
|
|
signature: 1,
|
|
},
|
|
));
|
|
assert_eq!(
|
|
app.guide_documentation.status,
|
|
crate::views::documentation::DocumentStatus::Loading
|
|
);
|
|
|
|
let _ = app.update(Message::DocumentationLoaded(
|
|
DocumentationKind::Guide,
|
|
current,
|
|
DocumentLoad::Ready {
|
|
source: "# New guide".to_string(),
|
|
signature: 2,
|
|
},
|
|
));
|
|
assert_eq!(
|
|
app.guide_documentation.status,
|
|
crate::views::documentation::DocumentStatus::Ready
|
|
);
|
|
assert_eq!(app.guide_documentation.signature, 2);
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
#[test]
|
|
fn import_definition_loads_saved_analysis_into_real_editor_state() {
|
|
let (db, project, tempdir) = setup();
|
|
let definition =
|
|
wordpress_import::create_definition(db.conn(), &project.id, "Legacy").unwrap();
|
|
let mut report = wordpress_import::empty_report();
|
|
report.site.title = "Legacy Blog".to_string();
|
|
report.source_file = tempdir.path().join("legacy.xml").display().to_string();
|
|
wordpress_import::update_definition(
|
|
db.conn(),
|
|
&definition.id,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(Some(&report)),
|
|
)
|
|
.unwrap();
|
|
|
|
let mut app = BdsApp::new_for_tests(db, project, tempdir.path().to_path_buf());
|
|
let _ = app.refresh_counts();
|
|
assert_eq!(app.sidebar_imports.len(), 1);
|
|
let tab = Tab {
|
|
id: definition.id.clone(),
|
|
tab_type: TabType::Import,
|
|
title: "Legacy".to_string(),
|
|
is_transient: true,
|
|
is_dirty: false,
|
|
};
|
|
app.load_editor_for_tab(&tab);
|
|
|
|
let editor = app.import_editors.get(&definition.id).unwrap();
|
|
assert_eq!(editor.definition.name, "Legacy");
|
|
assert_eq!(editor.report.as_ref().unwrap().site.title, "Legacy Blog");
|
|
assert!(editor.category_options.iter().any(|name| name == "article"));
|
|
}
|
|
|
|
fn make_app(db: Database, project: Project, tmp: &TempDir) -> BdsApp {
|
|
BdsApp::new_for_tests(db, project, tmp.path().to_path_buf())
|
|
}
|
|
|
|
#[test]
|
|
fn airplane_mode_is_restored_and_status_bar_changes_are_persisted() {
|
|
let (db, project, tmp) = setup();
|
|
bds_core::engine::settings::set(
|
|
db.conn(),
|
|
bds_core::engine::settings::AIRPLANE_MODE_KEY,
|
|
"true",
|
|
)
|
|
.unwrap();
|
|
|
|
let mut app = make_app(db, project, &tmp);
|
|
assert!(app.offline_mode);
|
|
|
|
let _ = app.update(Message::SetOfflineMode(false));
|
|
|
|
assert!(!app.offline_mode);
|
|
assert_eq!(
|
|
bds_core::engine::settings::get(
|
|
app.db.as_ref().unwrap().conn(),
|
|
bds_core::engine::settings::AIRPLANE_MODE_KEY
|
|
)
|
|
.unwrap()
|
|
.as_deref(),
|
|
Some("false")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn native_paste_menu_action_is_queued_for_the_focused_widget() {
|
|
let (db, project, tmp) = setup();
|
|
let mut app = make_app(db, project, &tmp);
|
|
|
|
let _ = app.dispatch_menu_action(MenuAction::Paste);
|
|
|
|
assert_eq!(
|
|
app.native_edit_commands.lock().unwrap().pop_front(),
|
|
Some(crate::components::native_edit::EditCommand::Paste)
|
|
);
|
|
}
|
|
|
|
fn enable_generation(tmp: &TempDir) {
|
|
let mut metadata = bds_core::engine::meta::read_project_json(tmp.path()).unwrap();
|
|
metadata.public_url = Some("https://example.com".to_string());
|
|
bds_core::engine::meta::write_project_json(tmp.path(), &metadata).unwrap();
|
|
}
|
|
|
|
fn open_post_editor(app: &mut BdsApp, post: &bds_core::model::Post) {
|
|
let tab = crate::state::tabs::Tab {
|
|
id: post.id.clone(),
|
|
tab_type: crate::state::tabs::TabType::Post,
|
|
title: post.title.clone(),
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
};
|
|
app.tabs.push(tab.clone());
|
|
app.active_tab = Some(post.id.clone());
|
|
app.load_editor_for_tab(&tab);
|
|
}
|
|
|
|
#[test]
|
|
fn file_drop_routes_only_supported_images_to_the_active_post_tab() {
|
|
let tabs = vec![
|
|
Tab {
|
|
id: "post-1".to_string(),
|
|
tab_type: TabType::Post,
|
|
title: "Post".to_string(),
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
},
|
|
Tab {
|
|
id: "settings".to_string(),
|
|
tab_type: TabType::Settings,
|
|
title: "Settings".to_string(),
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
},
|
|
];
|
|
|
|
assert_eq!(
|
|
dropped_image_target(Some("post-1"), &tabs, Path::new("photo.PNG")),
|
|
Some("post-1".to_string())
|
|
);
|
|
assert_eq!(
|
|
dropped_image_target(Some("post-1"), &tabs, Path::new("notes.txt")),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
dropped_image_target(Some("settings"), &tabs, Path::new("photo.png")),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
dropped_image_target(None, &tabs, Path::new("photo.png")),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn browser_preview_targets_the_active_post_or_the_site_root() {
|
|
let tabs = vec![
|
|
Tab {
|
|
id: "post-1".to_string(),
|
|
tab_type: TabType::Post,
|
|
title: "Post".to_string(),
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
},
|
|
Tab {
|
|
id: "settings".to_string(),
|
|
tab_type: TabType::Settings,
|
|
title: "Settings".to_string(),
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
},
|
|
];
|
|
|
|
assert_eq!(
|
|
active_post_tab_id(Some("post-1"), &tabs),
|
|
Some("post-1".to_string())
|
|
);
|
|
assert_eq!(active_post_tab_id(Some("settings"), &tabs), None);
|
|
assert_eq!(active_post_tab_id(None, &tabs), None);
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_file_drop_events_queue_one_managed_import_at_a_time() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Drops",
|
|
Some("Body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut app = make_app(db, project, &tmp);
|
|
open_post_editor(&mut app, &created);
|
|
|
|
let _ = app.update(Message::FileDropped(tmp.path().join("first.png")));
|
|
let _ = app.update(Message::FileDropped(tmp.path().join("second.png")));
|
|
|
|
assert!(app.image_drop_import_running);
|
|
assert_eq!(app.pending_image_drops.len(), 1);
|
|
assert_eq!(app.task_manager.snapshots().len(), 1);
|
|
assert!(app.task_manager.snapshots()[0].label.contains("first.png"));
|
|
}
|
|
|
|
#[test]
|
|
fn completed_drop_is_linked_persisted_and_skips_unavailable_airplane_ai() {
|
|
let (db, project, tmp) = setup();
|
|
let project_id = project.id.clone();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project_id,
|
|
"Drop",
|
|
Some("Body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let source = tmp.path().join("dropped.png");
|
|
std::fs::write(&source, tiny_png_bytes()).unwrap();
|
|
let imported = bds_core::engine::gallery_import::import_and_link_image(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project_id,
|
|
&created.id,
|
|
&source,
|
|
"en",
|
|
0,
|
|
)
|
|
.unwrap();
|
|
let expected_url = format!("/{}", imported.file_path);
|
|
let mut app = make_app(db, project, &tmp);
|
|
open_post_editor(&mut app, &created);
|
|
app.offline_mode = true;
|
|
app.post_editors[&created.id]
|
|
.editor_buffer
|
|
.borrow_mut()
|
|
.set_cursor(0, 2);
|
|
let task_id = app.task_manager.submit("drop");
|
|
|
|
let _ = app.update(Message::ImageDropImported {
|
|
task_id,
|
|
post_id: created.id.clone(),
|
|
project_id,
|
|
data_dir: tmp.path().to_path_buf(),
|
|
source_language: "en".to_string(),
|
|
offline_mode: true,
|
|
path: source,
|
|
result: Ok(imported.clone()),
|
|
});
|
|
|
|
let content = &app.post_editors[&created.id].content;
|
|
assert!(content.contains(&format!("")));
|
|
assert!(!content.contains("bds-media://"));
|
|
let saved = bds_core::db::queries::post::get_post_by_id(
|
|
app.db.as_ref().unwrap().conn(),
|
|
&created.id,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(saved.content.as_deref(), Some(content.as_str()));
|
|
assert_eq!(
|
|
bds_core::engine::post_media::list_media_for_post(
|
|
app.db.as_ref().unwrap().conn(),
|
|
&created.id,
|
|
)
|
|
.unwrap()[0]
|
|
.id,
|
|
imported.id
|
|
);
|
|
assert_eq!(
|
|
app.task_manager.status(task_id),
|
|
Some(TaskStatus::Completed)
|
|
);
|
|
assert!(app.toasts.iter().any(|toast| {
|
|
toast.level == ToastLevel::Warning
|
|
&& toast.message == "Automatic AI actions stay gated by airplane mode."
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn gallery_action_warns_but_keeps_import_available_without_a_local_endpoint() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Gallery",
|
|
Some("Body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut app = make_app(db, project, &tmp);
|
|
open_post_editor(&mut app, &created);
|
|
app.offline_mode = true;
|
|
app.post_editors
|
|
.get_mut(&created.id)
|
|
.unwrap()
|
|
.quick_actions_open = true;
|
|
|
|
let _ = app.handle_post_editor_msg(PostEditorMsg::AddGalleryImages);
|
|
|
|
assert!(!app.post_editors[&created.id].quick_actions_open);
|
|
assert!(app.toasts.iter().any(|toast| {
|
|
toast.level == ToastLevel::Warning
|
|
&& toast.message == "Automatic AI actions stay gated by airplane mode."
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn git_network_actions_are_gated_in_airplane_mode() {
|
|
let (db, project, tempdir) = setup();
|
|
let mut app = BdsApp::new_for_tests(db, project, tempdir.path().to_path_buf());
|
|
app.offline_mode = true;
|
|
|
|
let _ = app.update(Message::GitFetch);
|
|
|
|
assert!(app.git_state.network_run.is_none());
|
|
assert!(
|
|
app.toasts
|
|
.iter()
|
|
.any(|toast| toast.message == t(UiLocale::En, "git.airplaneBlocked"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gallery_completion_reports_every_path_inserts_macro_and_refreshes_editor() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Gallery",
|
|
Some("Body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut app = make_app(db, project, &tmp);
|
|
open_post_editor(&mut app, &created);
|
|
|
|
let _ = app.update(Message::GalleryImportFinished {
|
|
post_id: created.id.clone(),
|
|
report: bds_core::engine::gallery_import::GalleryImportReport {
|
|
selected_count: 2,
|
|
outcomes: vec![
|
|
bds_core::engine::gallery_import::GalleryImportOutcome {
|
|
path: PathBuf::from("first.jpg"),
|
|
result: Ok(bds_core::engine::gallery_import::ImportedGalleryImage {
|
|
media_id: "m1".to_string(),
|
|
title: "First".to_string(),
|
|
}),
|
|
},
|
|
bds_core::engine::gallery_import::GalleryImportOutcome {
|
|
path: PathBuf::from("broken.jpg"),
|
|
result: Err("bad image".to_string()),
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
assert!(
|
|
app.post_editors[&created.id]
|
|
.content
|
|
.contains("\n[[gallery]]\n")
|
|
);
|
|
assert!(!app.post_editors[&created.id].is_dirty);
|
|
let saved = bds_core::db::queries::post::get_post_by_id(
|
|
app.db.as_ref().unwrap().conn(),
|
|
&created.id,
|
|
)
|
|
.unwrap();
|
|
assert!(saved.content.unwrap().contains("\n[[gallery]]\n"));
|
|
assert_eq!(app.output_entries.len(), 3);
|
|
assert_eq!(app.output_entries[0].text, "Added First");
|
|
assert!(app.output_entries[1].text.contains("broken.jpg"));
|
|
assert_eq!(app.output_entries[2].text, "Added 2 images to post");
|
|
}
|
|
|
|
#[test]
|
|
fn cancelling_gallery_picker_is_a_no_op() {
|
|
let (db, project, tmp) = setup();
|
|
let mut app = make_app(db, project, &tmp);
|
|
|
|
let _ = app.update(Message::GalleryImagesPicked {
|
|
post_id: "post1".to_string(),
|
|
result: Ok(None),
|
|
});
|
|
|
|
assert!(app.output_entries.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn search_index_rebuild_requires_confirmation_and_blocks_editing() {
|
|
let (db, project, tmp) = setup();
|
|
let mut app = make_app(db, project, &tmp);
|
|
app.search_index_rebuild_required = true;
|
|
|
|
let _ = app.update(Message::PostSearchChanged("query".to_string()));
|
|
assert!(app.post_filter.search_query.is_empty());
|
|
assert!(matches!(
|
|
app.active_modal,
|
|
Some(modal::ModalState::SearchIndexRepair)
|
|
));
|
|
|
|
let _ = app.update(Message::ReindexText);
|
|
assert!(matches!(
|
|
app.active_modal,
|
|
Some(modal::ModalState::SearchIndexRepair)
|
|
));
|
|
|
|
let _ = app.update(Message::ConfirmModal(
|
|
modal::ConfirmAction::RebuildSearchIndex,
|
|
));
|
|
assert!(app.search_index_rebuild_running);
|
|
assert!(matches!(
|
|
app.active_modal,
|
|
Some(modal::ModalState::SearchIndexRebuilding)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn full_generation_queues_five_ordered_section_tasks_before_indexing() {
|
|
let (db, project, tmp) = setup();
|
|
enable_generation(&tmp);
|
|
let mut app = make_app(db, project, &tmp);
|
|
|
|
let _task = app.queue_site_generation(None);
|
|
let snapshots = app.task_manager.snapshots();
|
|
|
|
assert_eq!(
|
|
snapshots
|
|
.iter()
|
|
.map(|task| task.label.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"Render Site Core",
|
|
"Render Single Posts",
|
|
"Render Category Archives",
|
|
"Render Tag Archives",
|
|
"Render Date Archives",
|
|
]
|
|
);
|
|
assert!(
|
|
snapshots
|
|
.iter()
|
|
.all(|task| task.group_name.as_deref() == Some("Render Site"))
|
|
);
|
|
assert_eq!(
|
|
snapshots
|
|
.iter()
|
|
.map(|task| task.status.clone())
|
|
.collect::<Vec<_>>(),
|
|
vec![Running, Running, Running, Pending, Pending]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_index_is_queued_only_after_every_render_task_succeeds() {
|
|
let (db, project, tmp) = setup();
|
|
enable_generation(&tmp);
|
|
let mut app = make_app(db, project, &tmp);
|
|
let _task = app.queue_site_generation(None);
|
|
let group_id = app.task_manager.snapshots()[0].group_id.clone().unwrap();
|
|
let render_ids = app.site_generation_workflows[&group_id]
|
|
.render_task_ids
|
|
.clone();
|
|
|
|
for (index, task_id) in render_ids.into_iter().enumerate() {
|
|
let _task = app.handle_engine_message(Message::SiteGenerationSectionDone {
|
|
group_id: group_id.clone(),
|
|
task_id,
|
|
result: Ok(GenerationReport::default()),
|
|
});
|
|
let has_index = app
|
|
.task_manager
|
|
.snapshots()
|
|
.iter()
|
|
.any(|task| task.label == "Build Search Index");
|
|
assert_eq!(has_index, index == 4);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn failed_render_cancels_its_group_and_never_queues_index() {
|
|
let (db, project, tmp) = setup();
|
|
enable_generation(&tmp);
|
|
let mut app = make_app(db, project, &tmp);
|
|
let _task = app.queue_site_generation(None);
|
|
let snapshots = app.task_manager.snapshots();
|
|
let group_id = snapshots[0].group_id.clone().unwrap();
|
|
let first_id = snapshots[0].id;
|
|
|
|
let _task = app.handle_engine_message(Message::SiteGenerationSectionDone {
|
|
group_id: group_id.clone(),
|
|
task_id: first_id,
|
|
result: Err("render failed".to_string()),
|
|
});
|
|
|
|
let snapshots = app.task_manager.snapshots();
|
|
assert_eq!(
|
|
app.task_manager.status(first_id),
|
|
Some(TaskStatus::Failed("render failed".to_string()))
|
|
);
|
|
assert!(
|
|
snapshots
|
|
.iter()
|
|
.filter(|task| task.group_id.as_deref() == Some(&group_id))
|
|
.all(|task| matches!(task.status, TaskStatus::Failed(_) | TaskStatus::Cancelled))
|
|
);
|
|
assert!(
|
|
!snapshots
|
|
.iter()
|
|
.any(|task| task.label == "Build Search Index")
|
|
);
|
|
assert!(!app.site_generation_workflows.contains_key(&group_id));
|
|
}
|
|
|
|
#[test]
|
|
fn cancelling_a_render_task_cancels_the_generation_group() {
|
|
let (db, project, tmp) = setup();
|
|
enable_generation(&tmp);
|
|
let mut app = make_app(db, project, &tmp);
|
|
let _task = app.queue_site_generation(None);
|
|
let snapshots = app.task_manager.snapshots();
|
|
let group_id = snapshots[0].group_id.clone().unwrap();
|
|
|
|
let _task = app.update(Message::CancelTask(snapshots[0].id));
|
|
|
|
let snapshots = app.task_manager.snapshots();
|
|
assert!(
|
|
snapshots
|
|
.iter()
|
|
.filter(|task| task.group_id.as_deref() == Some(&group_id))
|
|
.all(|task| task.status == TaskStatus::Cancelled)
|
|
);
|
|
assert!(
|
|
!snapshots
|
|
.iter()
|
|
.any(|task| task.label == "Build Search Index")
|
|
);
|
|
assert!(!app.site_generation_workflows.contains_key(&group_id));
|
|
}
|
|
|
|
#[test]
|
|
fn validation_apply_queues_only_affected_sections_then_index() {
|
|
let (db, project, tmp) = setup();
|
|
enable_generation(&tmp);
|
|
let mut app = make_app(db, project, &tmp);
|
|
let validation = bds_core::engine::validate_site::SiteValidationReport {
|
|
stale_pages: vec!["2024/03/09/hello/index.html".to_string()],
|
|
..Default::default()
|
|
};
|
|
|
|
let _task = app.queue_site_generation(Some(validation));
|
|
let snapshots = app.task_manager.snapshots();
|
|
assert_eq!(snapshots.len(), 1);
|
|
assert_eq!(snapshots[0].label, "Render Single Posts");
|
|
assert_eq!(
|
|
snapshots[0].group_name.as_deref(),
|
|
Some("Apply Site Validation")
|
|
);
|
|
let group_id = snapshots[0].group_id.clone().unwrap();
|
|
|
|
let _task = app.handle_engine_message(Message::SiteGenerationSectionDone {
|
|
group_id,
|
|
task_id: snapshots[0].id,
|
|
result: Ok(GenerationReport::default()),
|
|
});
|
|
assert!(
|
|
app.task_manager
|
|
.snapshots()
|
|
.iter()
|
|
.any(|task| task.label == "Build Search Index")
|
|
);
|
|
}
|
|
|
|
#[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 draft_editor_save_refreshes_live_outlinks_without_publishing() {
|
|
let (db, project, tmp) = setup();
|
|
let target = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Link Target",
|
|
Some("Target body"),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let source = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Link Source",
|
|
Some("No links"),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut app = make_app(db, project, &tmp);
|
|
open_post_editor(&mut app, &source);
|
|
let editor = app.post_editors.get_mut(&source.id).unwrap();
|
|
editor.content = "[target](/2024/01/01/link-target)".to_string();
|
|
editor.is_dirty = true;
|
|
|
|
app.persist_post_editor_state(&source.id).unwrap();
|
|
|
|
let editor = &app.post_editors[&source.id];
|
|
assert_eq!(editor.status, PostStatus::Draft);
|
|
assert_eq!(editor.outlinks.len(), 1);
|
|
assert_eq!(editor.outlinks[0].post_id, target.id);
|
|
assert_eq!(editor.outlinks[0].title, "Link Target");
|
|
}
|
|
|
|
#[test]
|
|
fn manual_translation_save_reopens_and_refreshes_published_canonical_editor() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Canonical",
|
|
Some("Canonical body"),
|
|
Vec::new(),
|
|
vec!["article".to_string()],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
post::upsert_translation(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&created.id,
|
|
"de",
|
|
"Kanonisch",
|
|
None,
|
|
Some("Deutscher Inhalt"),
|
|
)
|
|
.unwrap();
|
|
let published = post::publish_post(db.conn(), tmp.path(), &created.id).unwrap();
|
|
let mut app = make_app(db, project, &tmp);
|
|
open_post_editor(&mut app, &published);
|
|
let editor = app.post_editors.get_mut(&created.id).unwrap();
|
|
editor.switch_language("de");
|
|
editor.title = "Neu formuliert".to_string();
|
|
editor.content = "Neuer Entwurf".to_string();
|
|
editor.is_dirty = true;
|
|
|
|
app.persist_post_editor_state(&created.id).unwrap();
|
|
|
|
let reopened = bds_core::db::queries::post::get_post_by_id(
|
|
app.db.as_ref().unwrap().conn(),
|
|
&created.id,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(reopened.status, PostStatus::Draft);
|
|
assert_eq!(reopened.content.as_deref(), Some("Canonical body"));
|
|
|
|
let _ = app.update(Message::DomainEventsTick);
|
|
let refreshed = &app.post_editors[&created.id];
|
|
assert_eq!(refreshed.status, PostStatus::Draft);
|
|
assert_eq!(refreshed.active_language, "de");
|
|
assert_eq!(refreshed.title, "Neu formuliert");
|
|
assert_eq!(refreshed.content, "Neuer Entwurf");
|
|
}
|
|
|
|
#[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();
|
|
bds_core::db::fts::drop_post_index(db.conn()).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_keeps_archived_canonical_post_archived() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Archived",
|
|
Some("Body"),
|
|
Vec::new(),
|
|
vec!["article".to_string()],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
post::archive_post(db.conn(), tmp.path(), &created.id).unwrap();
|
|
let archived = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap();
|
|
let mut editor = PostEditorState::from_post(
|
|
&archived,
|
|
"preview",
|
|
&["en".to_string()],
|
|
&[],
|
|
Vec::new(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
);
|
|
editor.title = "Changed while archived".to_string();
|
|
|
|
let result = persist_post_editor_preview_state_impl(&db, &editor).unwrap();
|
|
|
|
match result {
|
|
PersistedPostState::Canonical(post) => {
|
|
assert_eq!(post.status, PostStatus::Archived);
|
|
assert_eq!(post.title, "Changed while archived");
|
|
}
|
|
PersistedPostState::Translation(_) => panic!("expected canonical post save"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn post_editor_unarchive_action_restores_draft_and_body() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Archived",
|
|
Some("File body"),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
post::publish_post(db.conn(), tmp.path(), &created.id).unwrap();
|
|
post::archive_post(db.conn(), tmp.path(), &created.id).unwrap();
|
|
let archived = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap();
|
|
assert_eq!(archived.content, None);
|
|
|
|
let mut app = make_app(db, project, &tmp);
|
|
open_post_editor(&mut app, &archived);
|
|
let _ = app.handle_post_editor_msg(PostEditorMsg::Unarchive);
|
|
|
|
let from_db = bds_core::db::queries::post::get_post_by_id(
|
|
app.db.as_ref().unwrap().conn(),
|
|
&created.id,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(from_db.status, PostStatus::Draft);
|
|
assert_eq!(from_db.content.as_deref(), Some("File body"));
|
|
assert_eq!(app.post_editors[&created.id].status, PostStatus::Draft);
|
|
assert_eq!(app.post_editors[&created.id].content, "File body");
|
|
assert!(app.toasts.iter().any(|toast| {
|
|
toast.level == ToastLevel::Success
|
|
&& toast.message == t(UiLocale::En, "editor.unarchived")
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn post_editor_archive_action_keeps_published_body_file_only() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Published",
|
|
Some("File body"),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let published = post::publish_post(db.conn(), tmp.path(), &created.id).unwrap();
|
|
let path = tmp.path().join(&published.file_path);
|
|
let original_file = std::fs::read(&path).unwrap();
|
|
let mut app = make_app(db, project, &tmp);
|
|
open_post_editor(&mut app, &published);
|
|
app.post_editors
|
|
.get_mut(&created.id)
|
|
.unwrap()
|
|
.quick_actions_open = true;
|
|
|
|
let _ = app.handle_post_editor_msg(PostEditorMsg::Archive);
|
|
|
|
let from_db = bds_core::db::queries::post::get_post_by_id(
|
|
app.db.as_ref().unwrap().conn(),
|
|
&created.id,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(from_db.status, PostStatus::Archived);
|
|
assert_eq!(from_db.content, None);
|
|
assert_eq!(std::fs::read(path).unwrap(), original_file);
|
|
assert_eq!(app.post_editors[&created.id].status, PostStatus::Archived);
|
|
assert!(!app.post_editors[&created.id].quick_actions_open);
|
|
assert!(app.toasts.iter().any(|toast| {
|
|
toast.level == ToastLevel::Success
|
|
&& toast.message == t(UiLocale::En, "editor.archived")
|
|
}));
|
|
}
|
|
|
|
#[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();
|
|
bds_core::db::fts::drop_post_index(db.conn()).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 {
|
|
airplane_ai: AiModeViewState {
|
|
endpoint_url: spawn_models_server(),
|
|
chat_model: "llama3.2".to_string(),
|
|
..Default::default()
|
|
},
|
|
system_prompt: iced::widget::text_editor::Content::with_text("Use JSON only."),
|
|
..SettingsViewState::default()
|
|
};
|
|
|
|
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_ai.endpoint_url
|
|
);
|
|
assert_eq!(settings.airplane.endpoint.model, "llama3.2");
|
|
assert_eq!(settings.system_prompt.trim_end(), "Use JSON only.");
|
|
}
|
|
|
|
#[test]
|
|
fn settings_change_rehydrates_ai_configuration() {
|
|
let (db, project, tmp) = setup();
|
|
bds_core::engine::settings::set(
|
|
db.conn(),
|
|
"ai.endpoint.online.url",
|
|
"http://127.0.0.1:9000/v1",
|
|
)
|
|
.unwrap();
|
|
bds_core::engine::settings::set(
|
|
db.conn(),
|
|
"ai.endpoint.online.model",
|
|
"mlx-community--gemma-4-12B-8bit",
|
|
)
|
|
.unwrap();
|
|
bds_core::engine::settings::set(db.conn(), "ai.endpoint.online.api_key_configured", "true")
|
|
.unwrap();
|
|
let mut app = make_app(db, project, &tmp);
|
|
app.tabs.push(Tab {
|
|
id: "settings".to_string(),
|
|
tab_type: TabType::Settings,
|
|
title: "Settings".to_string(),
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
});
|
|
let mut settings_state = SettingsViewState::default();
|
|
settings_state.focus_section(SettingsSection::AI);
|
|
app.settings_state = Some(settings_state);
|
|
|
|
app.handle_domain_event(DomainEvent::SettingsChanged {
|
|
project_id: None,
|
|
key: "ai.endpoint.online.url".to_string(),
|
|
});
|
|
|
|
let state = app.settings_state.as_ref().unwrap();
|
|
assert_eq!(state.online_ai.endpoint_url, "http://127.0.0.1:9000/v1");
|
|
assert_eq!(
|
|
state.online_ai.chat_model,
|
|
"mlx-community--gemma-4-12B-8bit"
|
|
);
|
|
assert!(state.online_ai.api_key_configured);
|
|
assert_eq!(state.active_section, Some(SettingsSection::AI));
|
|
}
|
|
|
|
#[test]
|
|
fn ai_unavailable_chat_errors_are_localized() {
|
|
let raw = "validation error: AI unavailable - configure online endpoint in Settings";
|
|
for locale in UiLocale::all() {
|
|
assert_eq!(
|
|
localize_chat_error(*locale, raw),
|
|
t(*locale, "chat.unavailable.guidance")
|
|
);
|
|
}
|
|
let provider =
|
|
"parse error: AI provider returned 400 Bad Request: tokenizer.chat_template is not set";
|
|
for locale in UiLocale::all() {
|
|
assert_eq!(
|
|
localize_chat_error(*locale, provider),
|
|
format!(
|
|
"{} 400 Bad Request: tokenizer.chat_template is not set",
|
|
t(*locale, "chat.providerError")
|
|
)
|
|
);
|
|
}
|
|
assert_eq!(
|
|
localize_chat_error(UiLocale::En, "connection refused"),
|
|
"connection refused"
|
|
);
|
|
}
|
|
|
|
#[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,
|
|
"<article>{{ title }}</article>",
|
|
)
|
|
.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 = "<main>{{ title }}</main>".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("<main>{{ title }}</main>"));
|
|
}
|
|
|
|
#[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,
|
|
"<article>{{ title }}</article>",
|
|
)
|
|
.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("<article>{{ title }}</article>")
|
|
);
|
|
}
|
|
|
|
#[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!("", 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 post_preview_url_uses_the_generated_site_route() {
|
|
let (db, project, tmp) = setup();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Preview Route",
|
|
Some("Body"),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let path = bds_core::render::build_canonical_post_path(&created, "de", "en");
|
|
|
|
assert_eq!(
|
|
super::post_preview_url(&created, "de", "en"),
|
|
format!(
|
|
"http://127.0.0.1:4123{path}?draft=true&post_id={}",
|
|
created.id
|
|
)
|
|
);
|
|
}
|
|
|
|
#[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 style_selection_and_preview_mode_stay_local_until_apply() {
|
|
let mut state = StyleViewState::new(Some("blue"));
|
|
|
|
assert_eq!(state.selected_theme, "blue");
|
|
assert_eq!(state.applied_theme, "blue");
|
|
assert!(!state.can_apply());
|
|
|
|
state.select_theme("green");
|
|
state.set_preview_mode(PreviewMode::Dark);
|
|
|
|
assert_eq!(state.selected_theme, "green");
|
|
assert_eq!(state.applied_theme, "blue");
|
|
assert_eq!(state.preview_mode, PreviewMode::Dark);
|
|
assert!(state.can_apply());
|
|
|
|
state.mark_applied();
|
|
assert!(!state.can_apply());
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_preview_creation_starts_only_when_idle() {
|
|
assert!(should_start_embedded_preview_creation(false, false));
|
|
assert!(!should_start_embedded_preview_creation(false, true));
|
|
assert!(!should_start_embedded_preview_creation(true, false));
|
|
}
|
|
|
|
#[test]
|
|
fn applying_style_persists_project_metadata_emits_event_and_updates_badge() {
|
|
let (db, project, tmp) = setup();
|
|
let events = bds_core::engine::domain_events::subscribe();
|
|
let mut app = make_app(db, project, &tmp);
|
|
app.style_view_state = Some(StyleViewState::new(None));
|
|
|
|
let _ = app.update(Message::Style(StyleMsg::SelectTheme("purple".to_string())));
|
|
let _ = app.update(Message::Style(StyleMsg::Apply));
|
|
|
|
let metadata = bds_core::engine::meta::read_project_json(tmp.path()).unwrap();
|
|
assert_eq!(metadata.pico_theme.as_deref(), Some("purple"));
|
|
let project_json = std::fs::read_to_string(tmp.path().join("meta/project.json")).unwrap();
|
|
assert!(project_json.contains("\"picoTheme\": \"purple\""));
|
|
assert!(!project_json.contains("previewMode"));
|
|
assert_eq!(app.theme_badge, "purple");
|
|
assert_eq!(
|
|
app.style_view_state.as_ref().unwrap().applied_theme,
|
|
"purple"
|
|
);
|
|
assert!(events.drain().iter().any(|event| matches!(
|
|
event,
|
|
DomainEvent::EntityChanged {
|
|
project_id,
|
|
entity: bds_core::model::DomainEntity::Project,
|
|
entity_id,
|
|
action: bds_core::model::NotificationAction::Updated,
|
|
} if project_id == "p1" && entity_id == "p1"
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn task_tick_autosaves_dirty_post_editor() {
|
|
let (db, project, tmp) = setup();
|
|
ai::save_endpoint(
|
|
db.conn(),
|
|
&ai::AiEndpointConfig {
|
|
kind: ai::AiEndpointKind::Airplane,
|
|
url: "http://127.0.0.1:9".to_string(),
|
|
model: "test".to_string(),
|
|
api_key: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
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.offline_mode = true;
|
|
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);
|
|
assert!(
|
|
app.task_manager
|
|
.snapshots()
|
|
.iter()
|
|
.all(|task| task.group_name.as_deref() != Some("AI"))
|
|
);
|
|
}
|
|
|
|
fn auto_translation_test_app(translated_languages: &[&str]) -> (BdsApp, String, TempDir) {
|
|
let (db, project, tmp) = setup();
|
|
let mut metadata = bds_core::engine::meta::read_project_json(tmp.path()).unwrap();
|
|
metadata.main_language = Some("en".to_string());
|
|
metadata.blog_languages = vec!["en".to_string(), "de".to_string(), "fr".to_string()];
|
|
bds_core::engine::meta::write_project_json(tmp.path(), &metadata).unwrap();
|
|
ai::save_endpoint(
|
|
db.conn(),
|
|
&ai::AiEndpointConfig {
|
|
kind: ai::AiEndpointKind::Airplane,
|
|
url: "http://127.0.0.1:9".to_string(),
|
|
model: "test".to_string(),
|
|
api_key: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Original",
|
|
Some("Body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
for language in translated_languages {
|
|
post::upsert_translation(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&created.id,
|
|
language,
|
|
"Translated",
|
|
None,
|
|
Some("Body"),
|
|
)
|
|
.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 editor = PostEditorState::from_post(
|
|
&editor_post,
|
|
"markdown",
|
|
&["en".to_string(), "de".to_string(), "fr".to_string()],
|
|
&translations,
|
|
Vec::new(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
);
|
|
let mut app = make_app(db, project, &tmp);
|
|
app.offline_mode = true;
|
|
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: created.title,
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
});
|
|
(app, created.id, tmp)
|
|
}
|
|
|
|
#[test]
|
|
fn first_manual_save_enqueues_one_task_per_missing_translation() {
|
|
let (mut app, post_id, _tmp) = auto_translation_test_app(&[]);
|
|
|
|
let _ = app.save_post_editor(&post_id, true);
|
|
|
|
assert_eq!(
|
|
app.task_manager
|
|
.snapshots()
|
|
.iter()
|
|
.filter(|task| task.group_name.as_deref() == Some("AI"))
|
|
.count(),
|
|
2
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn later_manual_save_enqueues_no_translation_task_when_languages_exist() {
|
|
let (mut app, post_id, _tmp) = auto_translation_test_app(&["de", "fr"]);
|
|
|
|
let _ = app.save_post_editor(&post_id, true);
|
|
|
|
assert!(
|
|
app.task_manager
|
|
.snapshots()
|
|
.iter()
|
|
.all(|task| task.group_name.as_deref() != Some("AI"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn saving_a_translation_draft_does_not_enqueue_other_languages() {
|
|
let (mut app, post_id, _tmp) = auto_translation_test_app(&["de"]);
|
|
app.post_editors
|
|
.get_mut(&post_id)
|
|
.unwrap()
|
|
.switch_language("de");
|
|
|
|
let _ = app.save_post_editor(&post_id, true);
|
|
|
|
assert!(
|
|
app.task_manager
|
|
.snapshots()
|
|
.iter()
|
|
.all(|task| task.group_name.as_deref() != Some("AI"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn inserting_editor_markdown_persists_without_enqueuing_translations() {
|
|
let (mut app, post_id, _tmp) = auto_translation_test_app(&[]);
|
|
|
|
let _ = app.insert_markdown_into_post(&post_id, "[Link](/target)");
|
|
|
|
assert!(
|
|
app.task_manager
|
|
.snapshots()
|
|
.iter()
|
|
.all(|task| task.group_name.as_deref() != Some("AI"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn post_editor_loads_existing_project_tags_for_partial_suggestions() {
|
|
let (db, project, tmp) = setup();
|
|
tag::create_tag(db.conn(), tmp.path(), &project.id, "Photography", None).unwrap();
|
|
let tagged = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Existing",
|
|
Some("Body"),
|
|
vec!["Photo Essay".to_string()],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let edited = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Edited",
|
|
Some("Body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut app = make_app(db, project, &tmp);
|
|
|
|
let _ = app.open_tab(Tab {
|
|
id: edited.id.clone(),
|
|
tab_type: TabType::Post,
|
|
title: edited.title,
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
});
|
|
|
|
assert_eq!(
|
|
app.post_editors[&edited.id].available_tags,
|
|
vec!["Photo Essay", "Photography"]
|
|
);
|
|
assert_eq!(tagged.tags, vec!["Photo Essay"]);
|
|
}
|
|
|
|
#[test]
|
|
fn adding_new_tag_from_post_editor_creates_portable_tag_metadata() {
|
|
let (db, project, tmp) = setup();
|
|
let edited = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Edited",
|
|
Some("Body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut app = make_app(db, project.clone(), &tmp);
|
|
let _ = app.open_tab(Tab {
|
|
id: edited.id.clone(),
|
|
tab_type: TabType::Post,
|
|
title: edited.title,
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
});
|
|
|
|
let _ = app.handle_post_editor_msg(PostEditorMsg::AddSuggestedTag("New Tag".into()));
|
|
|
|
assert!(
|
|
bds_core::db::queries::tag::get_tag_by_project_and_name(
|
|
app.db.as_ref().unwrap().conn(),
|
|
&project.id,
|
|
"new tag",
|
|
)
|
|
.is_ok()
|
|
);
|
|
assert!(
|
|
app.post_editors[&edited.id]
|
|
.tags
|
|
.contains(&"New Tag".to_string())
|
|
);
|
|
let portable = std::fs::read_to_string(tmp.path().join("meta/tags.json")).unwrap();
|
|
assert!(portable.contains("New Tag"));
|
|
}
|
|
|
|
#[test]
|
|
fn post_refresh_does_not_drop_loaded_semantic_tag_suggestions() {
|
|
let (db, project, tmp) = setup();
|
|
let edited = post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
"Edited",
|
|
Some("Body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut app = make_app(db, project.clone(), &tmp);
|
|
let _ = app.open_tab(Tab {
|
|
id: edited.id.clone(),
|
|
tab_type: TabType::Post,
|
|
title: edited.title,
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
});
|
|
app.post_editors
|
|
.get_mut(&edited.id)
|
|
.unwrap()
|
|
.semantic_tag_suggestions = vec!["science".to_string()];
|
|
|
|
app.handle_domain_event(DomainEvent::EntityChanged {
|
|
project_id: project.id,
|
|
entity: DomainEntity::Post,
|
|
entity_id: edited.id.clone(),
|
|
action: NotificationAction::Updated,
|
|
});
|
|
|
|
assert_eq!(
|
|
app.post_editors[&edited.id].semantic_tag_suggestions,
|
|
vec!["science"]
|
|
);
|
|
}
|
|
|
|
#[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;
|
|
second_post.updated_at = first.updated_at + 1;
|
|
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
|
|
.clone()
|
|
.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(), 1);
|
|
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::<usize>(),
|
|
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");
|
|
|
|
// TagCloud guarantee: alphabetical display order, font size relative
|
|
// to the min/max counts of the visible set (equal counts -> 11px).
|
|
let names = dash
|
|
.tag_cloud
|
|
.iter()
|
|
.map(|tag| tag.name.as_str())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(names, vec!["lua", "rust"]);
|
|
assert!(dash.tag_cloud.iter().all(|tag| tag.font_size == 11.0));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_tag_cloud_takes_most_used_tags_and_scales_fonts() {
|
|
let (db, project, tmp) = setup();
|
|
// 42 distinct tags; "big" appears on 3 posts, "mid" on 2, the rest once.
|
|
for index in 0..3 {
|
|
post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
&format!("Post {index}"),
|
|
Some("Body"),
|
|
vec![
|
|
"big".to_string(),
|
|
format!("solo-{index:02}"),
|
|
format!("solo-x{index:02}"),
|
|
],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
}
|
|
for index in 3..20 {
|
|
post::create_post(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
&format!("Post {index}"),
|
|
Some("Body"),
|
|
vec![
|
|
format!("solo-{index:02}"),
|
|
format!("solo-x{index:02}"),
|
|
if index < 5 { "mid" } else { "big" }.to_string(),
|
|
],
|
|
vec![],
|
|
None,
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let app = make_app(db, project, &tmp);
|
|
let dash = app.hydrate_dashboard_state();
|
|
|
|
// 42 distinct tags overall, but only the 40 most-used are displayed.
|
|
assert_eq!(dash.stats.tag_count, 42);
|
|
assert_eq!(dash.tag_cloud.len(), 40);
|
|
let big = dash.tag_cloud.iter().find(|tag| tag.name == "big").unwrap();
|
|
let mid = dash.tag_cloud.iter().find(|tag| tag.name == "mid").unwrap();
|
|
assert_eq!(big.count, 18);
|
|
assert_eq!(mid.count, 2);
|
|
// Relative scaling over the visible set: max count -> 22px, min -> 11px.
|
|
assert_eq!(big.font_size, 22.0);
|
|
assert!(dash.tag_cloud.iter().any(|tag| tag.font_size == 11.0));
|
|
// Display order is alphabetical.
|
|
let mut sorted = dash
|
|
.tag_cloud
|
|
.iter()
|
|
.map(|tag| tag.name.to_lowercase())
|
|
.collect::<Vec<_>>();
|
|
let displayed = sorted.clone();
|
|
sorted.sort();
|
|
assert_eq!(displayed, sorted);
|
|
}
|
|
|
|
#[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();
|
|
// Only months with posts appear in the timeline: the created_at month
|
|
// (March), not the updated_at month (April).
|
|
assert_eq!(dash.timeline.len(), 1);
|
|
let march = &dash.timeline[0];
|
|
assert_eq!(march.year, 2026);
|
|
assert_eq!(march.label, "Mar");
|
|
assert_eq!(march.count, 1);
|
|
}
|
|
|
|
#[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 script_syntax_check_reports_success_and_failure() {
|
|
let (db, project, tmp) = setup();
|
|
let mut app = make_app(db, project, &tmp);
|
|
let _ = app.update(Message::CreateScript);
|
|
|
|
let _ = app.update(Message::ScriptEditor(ScriptEditorMsg::CheckSyntax));
|
|
assert_eq!(
|
|
app.toasts.last().map(|toast| toast.message.as_str()),
|
|
Some(t(UiLocale::En, "editor.syntaxValid").as_str())
|
|
);
|
|
|
|
let _ = app.update(Message::ScriptEditor(ScriptEditorMsg::ContentChanged(
|
|
"function render(".to_string(),
|
|
)));
|
|
let _ = app.update(Message::ScriptEditor(ScriptEditorMsg::CheckSyntax));
|
|
assert_eq!(
|
|
app.toasts.last().map(|toast| toast.level),
|
|
Some(ToastLevel::Error)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rebuild_completion_refreshes_project_metadata_in_settings() {
|
|
let (db, project, tmp) = setup();
|
|
let mut app = make_app(db, project, &tmp);
|
|
app.settings_state = Some(app.hydrate_settings_state());
|
|
|
|
let mut rebuilt = bds_core::db::queries::project::get_project_by_id(
|
|
app.db.as_ref().unwrap().conn(),
|
|
&app.active_project.as_ref().unwrap().id,
|
|
)
|
|
.unwrap();
|
|
rebuilt.description = Some("Description from project.json".to_string());
|
|
bds_core::db::queries::project::update_project(app.db.as_ref().unwrap().conn(), &rebuilt)
|
|
.unwrap();
|
|
|
|
let label = t(UiLocale::En, "engine.rebuildStarted");
|
|
let task_id = app.task_manager.submit(&label);
|
|
let _ = app.update(Message::EngineTaskDone {
|
|
task_id,
|
|
operation: "engine.rebuildStarted",
|
|
label,
|
|
result: Ok("done".to_string()),
|
|
});
|
|
|
|
assert_eq!(
|
|
app.active_project
|
|
.as_ref()
|
|
.and_then(|project| project.description.as_deref()),
|
|
Some("Description from project.json")
|
|
);
|
|
assert_eq!(
|
|
app.settings_state
|
|
.as_ref()
|
|
.map(|state| state.project_description.text()),
|
|
Some("Description from project.json\n".to_string())
|
|
);
|
|
}
|
|
|
|
#[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 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",
|
|
Some(tmp.path()),
|
|
&filter,
|
|
false,
|
|
(50, 0),
|
|
);
|
|
|
|
assert_eq!(posts.len(), 1);
|
|
assert_eq!(posts[0].id, published.id);
|
|
}
|
|
|
|
#[test]
|
|
fn query_sidebar_media_uses_full_text_metadata() {
|
|
let (_db, project, tmp) = setup();
|
|
let db_path = tmp.path().join("media-sidebar.db");
|
|
let db = Database::open(&db_path).unwrap();
|
|
db.migrate().unwrap();
|
|
ensure_fts_tables(db.conn()).unwrap();
|
|
insert_project(db.conn(), &project).unwrap();
|
|
let source = tmp.path().join("searchable.png");
|
|
std::fs::write(&source, tiny_png_bytes()).unwrap();
|
|
let imported = media::import_media(
|
|
db.conn(),
|
|
tmp.path(),
|
|
&project.id,
|
|
&source,
|
|
"searchable.png",
|
|
Some("Sunset"),
|
|
Some("Mountains under a red sky"),
|
|
None,
|
|
None,
|
|
Some("en"),
|
|
vec!["nature".to_string()],
|
|
)
|
|
.unwrap();
|
|
|
|
let filter = MediaFilter {
|
|
search_query: "mountains".to_string(),
|
|
tag_filter: vec!["nature".to_string()],
|
|
..MediaFilter::default()
|
|
};
|
|
let media =
|
|
BdsApp::query_sidebar_media_blocking(&db_path, &project.id, "en", &filter, 50, 0);
|
|
|
|
assert_eq!(media.len(), 1);
|
|
assert_eq!(media[0].id, imported.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,
|
|
|| false,
|
|
|_index, _total, _name| {},
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(regenerated, 1);
|
|
assert!(small_thumb.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_watcher_consumes_cli_event_refreshes_sidebar_and_marks_seen() {
|
|
let (db, project, tmp) = setup();
|
|
let created = script::create_script(
|
|
db.conn(),
|
|
&project.id,
|
|
"From CLI",
|
|
ScriptKind::Utility,
|
|
"function main() end",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut app = make_app(db, project.clone(), &tmp);
|
|
bds_core::engine::cli_sync::record_cli_event(
|
|
app.db.as_ref().unwrap().conn(),
|
|
&bds_core::model::DomainEvent::EntityChanged {
|
|
project_id: project.id,
|
|
entity: bds_core::model::DomainEntity::Script,
|
|
entity_id: created.id.clone(),
|
|
action: bds_core::model::NotificationAction::Created,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let _ = app.update(Message::DomainEventsTick);
|
|
|
|
assert!(
|
|
app.sidebar_scripts
|
|
.iter()
|
|
.any(|script| script.id == created.id)
|
|
);
|
|
let rows = bds_core::db::queries::db_notification::list_notifications(
|
|
app.db.as_ref().unwrap().conn(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(rows.len(), 1);
|
|
assert!(rows[0].seen_at.is_some());
|
|
assert!(app.toasts.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn locale_setting_is_persisted_and_external_event_reloads_without_feedback() {
|
|
let (db, project, tmp) = setup();
|
|
let mut app = make_app(db, project, &tmp);
|
|
|
|
let _ = app.update(Message::SetUiLocale(UiLocale::De));
|
|
assert_eq!(app.ui_locale, UiLocale::De);
|
|
assert_eq!(
|
|
bds_core::engine::settings::ui_language(app.db.as_ref().unwrap().conn())
|
|
.unwrap()
|
|
.as_deref(),
|
|
Some("de")
|
|
);
|
|
|
|
bds_core::engine::settings::set(
|
|
app.db.as_ref().unwrap().conn(),
|
|
bds_core::engine::settings::UI_LANGUAGE_KEY,
|
|
"fr-FR",
|
|
)
|
|
.unwrap();
|
|
let _ = app.update(Message::DomainEventsTick);
|
|
|
|
assert_eq!(app.ui_locale, UiLocale::Fr);
|
|
assert!(app.toasts.is_empty());
|
|
assert!(
|
|
bds_core::db::queries::db_notification::list_notifications(
|
|
app.db.as_ref().unwrap().conn()
|
|
)
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deleted_entity_event_closes_its_open_editor_without_notification() {
|
|
let (db, project, tmp) = setup();
|
|
let created = script::create_script(
|
|
db.conn(),
|
|
&project.id,
|
|
"Delete externally",
|
|
ScriptKind::Utility,
|
|
"function main() end",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut app = make_app(db, project, &tmp);
|
|
let tab = Tab {
|
|
id: created.id.clone(),
|
|
tab_type: TabType::Scripts,
|
|
title: created.title,
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
};
|
|
app.tabs.push(tab.clone());
|
|
app.load_editor_for_tab(&tab);
|
|
|
|
script::delete_script(app.db.as_ref().unwrap().conn(), tmp.path(), &created.id).unwrap();
|
|
let _ = app.update(Message::DomainEventsTick);
|
|
|
|
assert!(!app.tabs.iter().any(|tab| tab.id == created.id));
|
|
assert!(!app.script_editors.contains_key(&created.id));
|
|
assert!(app.toasts.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn external_update_event_preserves_unsaved_editor_buffer() {
|
|
let (db, project, tmp) = setup();
|
|
let created = script::create_script(
|
|
db.conn(),
|
|
&project.id,
|
|
"Edit externally",
|
|
ScriptKind::Utility,
|
|
"function main() end",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut app = make_app(db, project.clone(), &tmp);
|
|
let tab = Tab {
|
|
id: created.id.clone(),
|
|
tab_type: TabType::Scripts,
|
|
title: created.title,
|
|
is_transient: false,
|
|
is_dirty: true,
|
|
};
|
|
app.tabs.push(tab.clone());
|
|
app.load_editor_for_tab(&tab);
|
|
let editor = app.script_editors.get_mut(&created.id).unwrap();
|
|
editor.content = "local unsaved buffer".to_string();
|
|
editor.is_dirty = true;
|
|
|
|
script::update_script(
|
|
app.db.as_ref().unwrap().conn(),
|
|
&created.id,
|
|
&project.id,
|
|
Some("Remote title"),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let _ = app.update(Message::DomainEventsTick);
|
|
|
|
assert_eq!(
|
|
app.script_editors[&created.id].content,
|
|
"local unsaved buffer"
|
|
);
|
|
assert!(app.script_editors[&created.id].is_dirty);
|
|
assert!(app.toasts.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_settings_review_applies_or_rejects_inert_proposals() {
|
|
let (db, mut project, tmp) = setup();
|
|
project.data_path = Some(tmp.path().to_string_lossy().into_owned());
|
|
bds_core::db::queries::project::update_project(db.conn(), &project).unwrap();
|
|
let now = bds_core::util::now_unix_ms();
|
|
let accepted = bds_core::model::McpProposal {
|
|
id: "accept-me".into(),
|
|
project_id: project.id.clone(),
|
|
kind: bds_core::model::ProposalKind::DraftPost,
|
|
status: bds_core::model::ProposalStatus::Pending,
|
|
entity_id: None,
|
|
data: serde_json::json!({"title":"Approved","content":"Body"}).to_string(),
|
|
result: None,
|
|
created_at: now,
|
|
expires_at: now + 60_000,
|
|
resolved_at: None,
|
|
};
|
|
let mut rejected = accepted.clone();
|
|
rejected.id = "reject-me".into();
|
|
rejected.data = serde_json::json!({"title":"Rejected","content":"Body"}).to_string();
|
|
bds_core::db::queries::mcp_proposal::insert_proposal(db.conn(), &accepted).unwrap();
|
|
bds_core::db::queries::mcp_proposal::insert_proposal(db.conn(), &rejected).unwrap();
|
|
|
|
let mut app = make_app(db, project.clone(), &tmp);
|
|
app.settings_state = Some(app.hydrate_settings_state());
|
|
assert_eq!(app.settings_state.as_ref().unwrap().mcp_proposals.len(), 2);
|
|
let _ = app.handle_settings_msg(SettingsMsg::McpProposalAccepted("accept-me".into()));
|
|
let _ = app.handle_settings_msg(SettingsMsg::McpProposalRejected("reject-me".into()));
|
|
|
|
let posts = bds_core::db::queries::post::list_posts_by_project(
|
|
app.db.as_ref().unwrap().conn(),
|
|
&project.id,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(posts.len(), 1);
|
|
assert_eq!(posts[0].title, "Approved");
|
|
assert_eq!(posts[0].status, PostStatus::Published);
|
|
assert_eq!(
|
|
bds_core::engine::mcp::get_proposal(app.db.as_ref().unwrap().conn(), "accept-me")
|
|
.unwrap()
|
|
.status,
|
|
bds_core::model::ProposalStatus::Accepted
|
|
);
|
|
assert_eq!(
|
|
bds_core::engine::mcp::get_proposal(app.db.as_ref().unwrap().conn(), "reject-me")
|
|
.unwrap()
|
|
.status,
|
|
bds_core::model::ProposalStatus::Rejected
|
|
);
|
|
assert!(
|
|
app.settings_state
|
|
.as_ref()
|
|
.unwrap()
|
|
.mcp_proposals
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn chat_ui_creates_reopens_renames_and_deletes_persistent_conversations() {
|
|
let (db, project, tmp) = setup();
|
|
let mut app = make_app(db, project, &tmp);
|
|
|
|
let _ = app.update(Message::ChatCreate);
|
|
assert_eq!(app.chat_conversations.len(), 1);
|
|
let conversation = app.chat_conversations[0].clone();
|
|
let tab = Tab {
|
|
id: conversation.id.clone(),
|
|
tab_type: TabType::Chat,
|
|
title: conversation.title,
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
};
|
|
let _ = app.update(Message::OpenTab(tab));
|
|
assert!(app.chat_editors.contains_key(&conversation.id));
|
|
|
|
let _ = app.update(Message::ChatRenameInputChanged("Research".to_string()));
|
|
let _ = app.update(Message::ChatRename);
|
|
assert_eq!(app.chat_conversations[0].title, "Research");
|
|
assert_eq!(
|
|
bds_core::engine::chat::get_conversation(
|
|
app.db.as_ref().unwrap().conn(),
|
|
&conversation.id,
|
|
)
|
|
.unwrap()
|
|
.title,
|
|
"Research"
|
|
);
|
|
|
|
let _ = app.update(Message::ChatDelete(conversation.id.clone()));
|
|
assert!(app.chat_conversations.is_empty());
|
|
assert!(!app.chat_editors.contains_key(&conversation.id));
|
|
assert!(
|
|
bds_core::engine::chat::list_conversations(app.db.as_ref().unwrap().conn())
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn chat_surfaces_stream_persist_reopen_and_refuse_unknown_actions() {
|
|
let (db, project, tmp) = setup();
|
|
let conversation =
|
|
chat::create_conversation_titled(db.conn(), Some("test-model"), "Surfaces").unwrap();
|
|
let tool_calls = serde_json::json!([
|
|
{
|
|
"id": "form-call",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "render_form",
|
|
"arguments": serde_json::json!({
|
|
"title": "Preferences",
|
|
"fields": [{"key": "topic", "label": "Topic", "inputType": "text"}],
|
|
"submitAction": "switchView"
|
|
}).to_string()
|
|
}
|
|
},
|
|
{
|
|
"id": "tabs-call",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "render_tabs",
|
|
"arguments": serde_json::json!({
|
|
"tabs": [
|
|
{"label": "One", "content": [{"type": "text", "text": "First"}]},
|
|
{"label": "Two", "content": [{"type": "text", "text": "Second"}]}
|
|
]
|
|
}).to_string()
|
|
}
|
|
}
|
|
])
|
|
.to_string();
|
|
let message = chat::insert_message(
|
|
db.conn(),
|
|
&conversation.id,
|
|
ChatRole::Assistant,
|
|
Some("Structured answer"),
|
|
None,
|
|
Some(&tool_calls),
|
|
bds_core::engine::ai::TokenUsage::default(),
|
|
)
|
|
.unwrap();
|
|
let mut app = make_app(db, project, &tmp);
|
|
let tab = Tab {
|
|
id: conversation.id.clone(),
|
|
tab_type: TabType::Chat,
|
|
title: conversation.title.clone(),
|
|
is_transient: false,
|
|
is_dirty: false,
|
|
};
|
|
let _ = app.update(Message::OpenTab(tab));
|
|
let form_id = format!("{}-surface-0", message.id);
|
|
let tabs_id = format!("{}-surface-1", message.id);
|
|
{
|
|
let state = app.chat_editors.get_mut(&conversation.id).unwrap();
|
|
assert_eq!(state.message_surfaces[&message.id].len(), 2);
|
|
state.streaming = true;
|
|
state.add_streaming_surface(
|
|
"render_card",
|
|
&serde_json::json!({"title": "Later"}),
|
|
"later-message-surface-0".into(),
|
|
);
|
|
assert_eq!(state.message_surfaces[&message.id].len(), 2);
|
|
assert_eq!(state.streaming_surfaces.len(), 1);
|
|
}
|
|
|
|
let _ = app.update(Message::ChatSurfaceFieldChanged {
|
|
surface_id: form_id.clone(),
|
|
field: "topic".into(),
|
|
value: "Rust".into(),
|
|
});
|
|
app.chat_editors
|
|
.get_mut(&conversation.id)
|
|
.unwrap()
|
|
.surface_state_dirty_since =
|
|
Some(std::time::Instant::now() - std::time::Duration::from_millis(501));
|
|
let _ = app.update(Message::TaskTick);
|
|
let _ = app.update(Message::ChatSurfaceTabSelected {
|
|
surface_id: tabs_id.clone(),
|
|
index: 1,
|
|
});
|
|
let _ = app.update(Message::ChatSurfaceDismissed(form_id.clone()));
|
|
|
|
let reopened_conversation =
|
|
chat::get_conversation(app.db.as_ref().unwrap().conn(), &conversation.id).unwrap();
|
|
let reopened = ChatEditorState::new(
|
|
reopened_conversation,
|
|
chat::list_messages(app.db.as_ref().unwrap().conn(), &conversation.id).unwrap(),
|
|
vec![],
|
|
);
|
|
assert_eq!(
|
|
reopened.surface_state.surface_data[&form_id]["topic"],
|
|
"Rust"
|
|
);
|
|
assert_eq!(reopened.surface_state.surface_tabs[&tabs_id], 1);
|
|
assert!(reopened.surface_state.dismissed_surfaces.contains(&form_id));
|
|
assert_eq!(reopened.message_surfaces[&message.id].len(), 1);
|
|
|
|
let _ = app.update(Message::ChatSurfaceAction {
|
|
surface_id: form_id,
|
|
action: "runJavaScript".into(),
|
|
payload: serde_json::json!({"script": "alert(1)"}),
|
|
});
|
|
assert!(
|
|
app.chat_editors[&conversation.id]
|
|
.error
|
|
.as_deref()
|
|
.is_some_and(|error| error.contains("runJavaScript"))
|
|
);
|
|
}
|
|
}
|