chore: cleanups and refactorings for cleaner code
This commit is contained in:
File diff suppressed because it is too large
Load Diff
757
crates/bds-ui/src/app/editor_handlers.rs
Normal file
757
crates/bds-ui/src/app/editor_handlers.rs
Normal file
@@ -0,0 +1,757 @@
|
||||
use super::*;
|
||||
|
||||
impl BdsApp {
|
||||
pub(super) fn handle_post_editor_msg(&mut self, msg: PostEditorMsg) -> Task<Message> {
|
||||
enum DeferredPostAction {
|
||||
None,
|
||||
SyncEmbeddedPreview,
|
||||
Analyze(String),
|
||||
AnalyzeTaxonomy(String),
|
||||
DetectLanguage(String),
|
||||
OpenTranslate(String),
|
||||
TranslateTo {
|
||||
post_id: String,
|
||||
target_language: String,
|
||||
},
|
||||
Save(String),
|
||||
Publish(String),
|
||||
Duplicate(String),
|
||||
Discard(String),
|
||||
ShowDelete {
|
||||
tab_id: String,
|
||||
name: String,
|
||||
},
|
||||
OpenInsertLink(String),
|
||||
OpenInsertMedia {
|
||||
post_id: String,
|
||||
link_only: bool,
|
||||
},
|
||||
OpenGallery(String),
|
||||
OpenLinkedMedia(String),
|
||||
UnlinkLinkedMedia {
|
||||
post_id: String,
|
||||
media_id: String,
|
||||
},
|
||||
InsertSelectedLink {
|
||||
post_id: String,
|
||||
linked_post_id: String,
|
||||
},
|
||||
CreateLinkedPost(String),
|
||||
InsertSelectedMedia {
|
||||
post_id: String,
|
||||
media_id: String,
|
||||
},
|
||||
SetLinkTab(modal::PostInsertLinkTab),
|
||||
SetLinkSearch(String),
|
||||
SetExternalUrl(String),
|
||||
SetExternalText(String),
|
||||
InsertExternalLink,
|
||||
SetMediaSearch(String),
|
||||
SelectGalleryImage(usize),
|
||||
GalleryPrevious,
|
||||
GalleryNext,
|
||||
GalleryCloseLightbox,
|
||||
}
|
||||
|
||||
let mut deferred = DeferredPostAction::None;
|
||||
let mut refresh_linked_media: Option<(String, String)> = None;
|
||||
if let Some(tab_id) = self.active_tab.clone()
|
||||
&& let Some(state) = self.post_editors.get_mut(&tab_id)
|
||||
{
|
||||
match msg {
|
||||
PostEditorMsg::ToggleQuickActions => {
|
||||
state.quick_actions_open = !state.quick_actions_open;
|
||||
}
|
||||
PostEditorMsg::AnalyzeWithAi => {
|
||||
state.quick_actions_open = false;
|
||||
deferred = DeferredPostAction::Analyze(tab_id.clone());
|
||||
}
|
||||
PostEditorMsg::AnalyzeTaxonomy => {
|
||||
state.quick_actions_open = false;
|
||||
deferred = DeferredPostAction::AnalyzeTaxonomy(tab_id.clone());
|
||||
}
|
||||
PostEditorMsg::SwitchEditorMode(mode) => {
|
||||
state.set_editor_mode(&mode);
|
||||
deferred = DeferredPostAction::SyncEmbeddedPreview;
|
||||
}
|
||||
PostEditorMsg::DetectLanguage => {
|
||||
state.quick_actions_open = false;
|
||||
deferred = DeferredPostAction::DetectLanguage(tab_id.clone());
|
||||
}
|
||||
PostEditorMsg::Translate => {
|
||||
state.quick_actions_open = false;
|
||||
deferred = DeferredPostAction::OpenTranslate(tab_id.clone());
|
||||
}
|
||||
PostEditorMsg::TranslateTo(target_language) => {
|
||||
deferred = DeferredPostAction::TranslateTo {
|
||||
post_id: tab_id.clone(),
|
||||
target_language,
|
||||
};
|
||||
}
|
||||
PostEditorMsg::TitleChanged(s) => {
|
||||
state.title = s;
|
||||
state.mark_dirty();
|
||||
}
|
||||
PostEditorMsg::SlugChanged(s) => {
|
||||
state.slug = s;
|
||||
state.mark_dirty();
|
||||
}
|
||||
PostEditorMsg::ExcerptChanged(s) => {
|
||||
state.excerpt = s;
|
||||
state.mark_dirty();
|
||||
}
|
||||
PostEditorMsg::ContentChanged(new_text) => {
|
||||
state.content = new_text;
|
||||
state.mark_dirty();
|
||||
refresh_linked_media = Some((state.post_id.clone(), state.content.clone()));
|
||||
}
|
||||
PostEditorMsg::AuthorChanged(s) => {
|
||||
state.author = s;
|
||||
state.mark_dirty();
|
||||
}
|
||||
PostEditorMsg::LanguageChanged(s) => {
|
||||
state.language = s;
|
||||
state.mark_dirty();
|
||||
}
|
||||
PostEditorMsg::TemplateSlugChanged(s) => {
|
||||
state.template_slug = s;
|
||||
state.mark_dirty();
|
||||
}
|
||||
PostEditorMsg::ToggleDoNotTranslate(b) => {
|
||||
state.do_not_translate = b;
|
||||
state.mark_dirty();
|
||||
}
|
||||
PostEditorMsg::ToggleMetadata => {
|
||||
state.metadata_expanded = !state.metadata_expanded;
|
||||
}
|
||||
PostEditorMsg::ToggleExcerpt => {
|
||||
state.excerpt_expanded = !state.excerpt_expanded;
|
||||
}
|
||||
PostEditorMsg::SwitchLanguage(lang) => {
|
||||
state.switch_language(&lang);
|
||||
if state.editor_mode == "preview" {
|
||||
deferred = DeferredPostAction::SyncEmbeddedPreview;
|
||||
}
|
||||
}
|
||||
PostEditorMsg::TagsInputChanged(s) => {
|
||||
state.tags_input = s;
|
||||
}
|
||||
PostEditorMsg::TagsInputSubmit => {
|
||||
let tag = state.tags_input.trim().to_string();
|
||||
if !tag.is_empty() && !state.tags.contains(&tag) {
|
||||
state.tags.push(tag);
|
||||
state.mark_dirty();
|
||||
}
|
||||
state.tags_input.clear();
|
||||
}
|
||||
PostEditorMsg::RemoveTag(tag) => {
|
||||
state.tags.retain(|t| t != &tag);
|
||||
state.mark_dirty();
|
||||
}
|
||||
PostEditorMsg::CategoriesInputChanged(s) => {
|
||||
state.categories_input = s;
|
||||
}
|
||||
PostEditorMsg::CategoriesInputSubmit => {
|
||||
let cat = state.categories_input.trim().to_string();
|
||||
if !cat.is_empty() && !state.categories.contains(&cat) {
|
||||
state.categories.push(cat);
|
||||
state.mark_dirty();
|
||||
}
|
||||
state.categories_input.clear();
|
||||
}
|
||||
PostEditorMsg::RemoveCategory(cat) => {
|
||||
state.categories.retain(|c| c != &cat);
|
||||
state.mark_dirty();
|
||||
}
|
||||
PostEditorMsg::Save => {
|
||||
deferred = DeferredPostAction::Save(tab_id.clone());
|
||||
}
|
||||
PostEditorMsg::Publish => {
|
||||
deferred = DeferredPostAction::Publish(tab_id.clone());
|
||||
}
|
||||
PostEditorMsg::Duplicate => {
|
||||
deferred = DeferredPostAction::Duplicate(tab_id.clone());
|
||||
}
|
||||
PostEditorMsg::Discard => {
|
||||
deferred = DeferredPostAction::Discard(tab_id.clone());
|
||||
}
|
||||
PostEditorMsg::Delete => {
|
||||
deferred = DeferredPostAction::ShowDelete {
|
||||
tab_id: tab_id.clone(),
|
||||
name: state.title.clone(),
|
||||
};
|
||||
}
|
||||
PostEditorMsg::InsertLink => {
|
||||
deferred = DeferredPostAction::OpenInsertLink(state.post_id.clone());
|
||||
}
|
||||
PostEditorMsg::InsertMedia => {
|
||||
deferred = DeferredPostAction::OpenInsertMedia {
|
||||
post_id: state.post_id.clone(),
|
||||
link_only: false,
|
||||
};
|
||||
}
|
||||
PostEditorMsg::Gallery => {
|
||||
deferred = DeferredPostAction::OpenGallery(state.post_id.clone());
|
||||
}
|
||||
PostEditorMsg::LinkExistingMedia => {
|
||||
deferred = DeferredPostAction::OpenInsertMedia {
|
||||
post_id: state.post_id.clone(),
|
||||
link_only: true,
|
||||
};
|
||||
}
|
||||
PostEditorMsg::OpenLinkedMedia(media_id) => {
|
||||
deferred = DeferredPostAction::OpenLinkedMedia(media_id);
|
||||
}
|
||||
PostEditorMsg::UnlinkLinkedMedia(media_id) => {
|
||||
deferred = DeferredPostAction::UnlinkLinkedMedia {
|
||||
post_id: state.post_id.clone(),
|
||||
media_id,
|
||||
};
|
||||
}
|
||||
PostEditorMsg::PostInsertLinkSelected(linked_post_id) => {
|
||||
deferred = DeferredPostAction::InsertSelectedLink {
|
||||
post_id: state.post_id.clone(),
|
||||
linked_post_id,
|
||||
};
|
||||
}
|
||||
PostEditorMsg::PostInsertLinkCreate => {
|
||||
deferred = DeferredPostAction::CreateLinkedPost(state.post_id.clone());
|
||||
}
|
||||
PostEditorMsg::PostInsertMediaSelected(media_id) => {
|
||||
deferred = DeferredPostAction::InsertSelectedMedia {
|
||||
post_id: state.post_id.clone(),
|
||||
media_id,
|
||||
};
|
||||
}
|
||||
PostEditorMsg::PostGalleryImageSelected(index) => {
|
||||
deferred = DeferredPostAction::SelectGalleryImage(index);
|
||||
}
|
||||
PostEditorMsg::PostInsertLinkTabSwitch(tab) => {
|
||||
deferred = DeferredPostAction::SetLinkTab(tab);
|
||||
}
|
||||
PostEditorMsg::PostInsertLinkSearch(query) => {
|
||||
deferred = DeferredPostAction::SetLinkSearch(query);
|
||||
}
|
||||
PostEditorMsg::PostInsertLinkUrlChanged(url) => {
|
||||
deferred = DeferredPostAction::SetExternalUrl(url);
|
||||
}
|
||||
PostEditorMsg::PostInsertLinkTextChanged(text) => {
|
||||
deferred = DeferredPostAction::SetExternalText(text);
|
||||
}
|
||||
PostEditorMsg::PostInsertLinkExternalInsert => {
|
||||
deferred = DeferredPostAction::InsertExternalLink;
|
||||
}
|
||||
PostEditorMsg::PostInsertMediaSearch(query) => {
|
||||
deferred = DeferredPostAction::SetMediaSearch(query);
|
||||
}
|
||||
PostEditorMsg::PostGalleryPrevious => {
|
||||
deferred = DeferredPostAction::GalleryPrevious;
|
||||
}
|
||||
PostEditorMsg::PostGalleryNext => {
|
||||
deferred = DeferredPostAction::GalleryNext;
|
||||
}
|
||||
PostEditorMsg::PostGalleryCloseLightbox => {
|
||||
deferred = DeferredPostAction::GalleryCloseLightbox;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == state.post_id) {
|
||||
tab.is_dirty = state.is_dirty;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((post_id, content)) = refresh_linked_media {
|
||||
let linked_media = self.load_post_media_items(&post_id, Some(&content));
|
||||
if let Some(state) = self.post_editors.get_mut(&post_id) {
|
||||
state.linked_media = linked_media;
|
||||
}
|
||||
}
|
||||
|
||||
match deferred {
|
||||
DeferredPostAction::None => Task::none(),
|
||||
DeferredPostAction::SyncEmbeddedPreview => self.sync_embedded_preview_for_active_post(),
|
||||
DeferredPostAction::Analyze(tab_id) => self.run_post_ai_analysis(&tab_id),
|
||||
DeferredPostAction::AnalyzeTaxonomy(tab_id) => self.run_post_taxonomy_analysis(&tab_id),
|
||||
DeferredPostAction::DetectLanguage(tab_id) => self.detect_post_language(&tab_id),
|
||||
DeferredPostAction::OpenTranslate(tab_id) => self.open_post_translation_modal(&tab_id),
|
||||
DeferredPostAction::TranslateTo {
|
||||
post_id,
|
||||
target_language,
|
||||
} => self.translate_post_to(&post_id, &target_language),
|
||||
DeferredPostAction::Save(tab_id) => self.save_post_editor(&tab_id),
|
||||
DeferredPostAction::Publish(tab_id) => self.publish_post_editor(&tab_id),
|
||||
DeferredPostAction::Duplicate(tab_id) => self.duplicate_post_editor(&tab_id),
|
||||
DeferredPostAction::Discard(tab_id) => self.discard_post_editor(&tab_id),
|
||||
DeferredPostAction::ShowDelete { tab_id, name } => {
|
||||
Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
||||
entity_name: name,
|
||||
references: Vec::new(),
|
||||
on_confirm: modal::ConfirmAction::DeletePost(tab_id),
|
||||
}))
|
||||
}
|
||||
DeferredPostAction::OpenInsertLink(post_id) => self.insert_link_modal(&post_id),
|
||||
DeferredPostAction::OpenInsertMedia { post_id, link_only } => {
|
||||
self.insert_media_modal(&post_id, link_only)
|
||||
}
|
||||
DeferredPostAction::OpenGallery(post_id) => self.post_gallery(&post_id),
|
||||
DeferredPostAction::OpenLinkedMedia(media_id) => {
|
||||
let title = self
|
||||
.db
|
||||
.as_ref()
|
||||
.and_then(|db| {
|
||||
bds_core::db::queries::media::get_media_by_id(db.conn(), &media_id)
|
||||
.ok()
|
||||
.map(|media| media.title.unwrap_or(media.original_name))
|
||||
})
|
||||
.unwrap_or_else(|| media_id.clone());
|
||||
Task::done(Message::OpenTab(Tab {
|
||||
id: media_id,
|
||||
tab_type: TabType::Media,
|
||||
title,
|
||||
is_transient: false,
|
||||
is_dirty: false,
|
||||
}))
|
||||
}
|
||||
DeferredPostAction::UnlinkLinkedMedia { post_id, media_id } => {
|
||||
if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) {
|
||||
if let Err(err) = engine::post_media::unlink_media_from_post(
|
||||
db.conn(),
|
||||
data_dir,
|
||||
&post_id,
|
||||
&media_id,
|
||||
) {
|
||||
self.notify_operation_failed("editor.unlinkMedia", err);
|
||||
return Task::none();
|
||||
}
|
||||
self.refresh_post_relationships(&post_id);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
DeferredPostAction::InsertSelectedLink {
|
||||
post_id,
|
||||
linked_post_id,
|
||||
} => self.insert_selected_post_link(&post_id, &linked_post_id),
|
||||
DeferredPostAction::CreateLinkedPost(post_id) => {
|
||||
self.insert_created_post_link(&post_id)
|
||||
}
|
||||
DeferredPostAction::InsertSelectedMedia { post_id, media_id } => {
|
||||
self.insert_selected_media(&post_id, &media_id)
|
||||
}
|
||||
DeferredPostAction::SetLinkTab(tab) => {
|
||||
self.refresh_post_insert_link_modal(Some(tab), None, None, None);
|
||||
Task::none()
|
||||
}
|
||||
DeferredPostAction::SetLinkSearch(query) => {
|
||||
self.refresh_post_insert_link_modal(None, Some(query), None, None);
|
||||
Task::none()
|
||||
}
|
||||
DeferredPostAction::SetExternalUrl(url) => {
|
||||
self.refresh_post_insert_link_modal(None, None, Some(url), None);
|
||||
Task::none()
|
||||
}
|
||||
DeferredPostAction::SetExternalText(text) => {
|
||||
self.refresh_post_insert_link_modal(None, None, None, Some(text));
|
||||
Task::none()
|
||||
}
|
||||
DeferredPostAction::InsertExternalLink => {
|
||||
if let Some(modal::ModalState::PostInsertLink {
|
||||
post_id,
|
||||
external_url,
|
||||
external_text,
|
||||
..
|
||||
}) = self.active_modal.clone()
|
||||
{
|
||||
if let Some(markdown) =
|
||||
modal::external_link_markdown(&external_url, &external_text)
|
||||
{
|
||||
self.insert_markdown_into_post(&post_id, &markdown)
|
||||
} else {
|
||||
self.notify(
|
||||
ToastLevel::Error,
|
||||
&t(self.ui_locale, "modal.postInsertLink.urlRequired"),
|
||||
);
|
||||
Task::none()
|
||||
}
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
DeferredPostAction::SetMediaSearch(query) => {
|
||||
self.refresh_insert_media_modal(query);
|
||||
Task::none()
|
||||
}
|
||||
DeferredPostAction::SelectGalleryImage(index) => {
|
||||
self.update_gallery_selection(Some(index));
|
||||
Task::none()
|
||||
}
|
||||
DeferredPostAction::GalleryPrevious => {
|
||||
self.step_gallery_selection(-1);
|
||||
Task::none()
|
||||
}
|
||||
DeferredPostAction::GalleryNext => {
|
||||
self.step_gallery_selection(1);
|
||||
Task::none()
|
||||
}
|
||||
DeferredPostAction::GalleryCloseLightbox => {
|
||||
self.update_gallery_selection(None);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_media_editor_msg(&mut self, msg: MediaEditorMsg) -> Task<Message> {
|
||||
enum DeferredMediaAction {
|
||||
None,
|
||||
Analyze(String),
|
||||
DetectLanguage(String),
|
||||
OpenTranslate(String),
|
||||
TranslateTo {
|
||||
media_id: String,
|
||||
target_language: String,
|
||||
},
|
||||
LinkPost {
|
||||
media_id: String,
|
||||
post_id: String,
|
||||
},
|
||||
OpenLinkedPost(String),
|
||||
UnlinkPost {
|
||||
media_id: String,
|
||||
post_id: String,
|
||||
},
|
||||
Save(String),
|
||||
Replace(String),
|
||||
Delete {
|
||||
tab_id: String,
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
let mut deferred = DeferredMediaAction::None;
|
||||
let mut picker_refresh: Option<(String, String)> = None;
|
||||
if let Some(tab_id) = self.active_tab.clone()
|
||||
&& let Some(state) = self.media_editors.get_mut(&tab_id)
|
||||
{
|
||||
match msg {
|
||||
MediaEditorMsg::AnalyzeWithAi => {
|
||||
deferred = DeferredMediaAction::Analyze(tab_id.clone());
|
||||
}
|
||||
MediaEditorMsg::DetectLanguage => {
|
||||
deferred = DeferredMediaAction::DetectLanguage(tab_id.clone());
|
||||
}
|
||||
MediaEditorMsg::TranslateMetadata => {
|
||||
deferred = DeferredMediaAction::OpenTranslate(tab_id.clone());
|
||||
}
|
||||
MediaEditorMsg::TranslateTo(target_language) => {
|
||||
deferred = DeferredMediaAction::TranslateTo {
|
||||
media_id: tab_id.clone(),
|
||||
target_language,
|
||||
};
|
||||
}
|
||||
MediaEditorMsg::TitleChanged(s) => {
|
||||
state.title = s;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
MediaEditorMsg::AltChanged(s) => {
|
||||
state.alt = s;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
MediaEditorMsg::CaptionChanged(s) => {
|
||||
state.caption = s;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
MediaEditorMsg::AuthorChanged(s) => {
|
||||
state.author = s;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
MediaEditorMsg::LanguageChanged(s) => {
|
||||
state.language = s;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
MediaEditorMsg::TagsChanged(s) => {
|
||||
state.tags_input = s;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
MediaEditorMsg::SwitchLanguage(lang) => {
|
||||
state.switch_language(&lang);
|
||||
}
|
||||
MediaEditorMsg::TogglePostPicker => {
|
||||
let next_open = !state.post_picker_open;
|
||||
state.post_picker_open = next_open;
|
||||
if !next_open {
|
||||
state.post_picker_results.clear();
|
||||
} else {
|
||||
picker_refresh =
|
||||
Some((state.media_id.clone(), state.post_picker_search.clone()));
|
||||
}
|
||||
}
|
||||
MediaEditorMsg::PostPickerSearchChanged(search) => {
|
||||
state.post_picker_search = search;
|
||||
if state.post_picker_open {
|
||||
picker_refresh =
|
||||
Some((state.media_id.clone(), state.post_picker_search.clone()));
|
||||
}
|
||||
}
|
||||
MediaEditorMsg::LinkPost(post_id) => {
|
||||
deferred = DeferredMediaAction::LinkPost {
|
||||
media_id: state.media_id.clone(),
|
||||
post_id,
|
||||
};
|
||||
}
|
||||
MediaEditorMsg::OpenLinkedPost(post_id) => {
|
||||
deferred = DeferredMediaAction::OpenLinkedPost(post_id);
|
||||
}
|
||||
MediaEditorMsg::UnlinkPost(post_id) => {
|
||||
deferred = DeferredMediaAction::UnlinkPost {
|
||||
media_id: state.media_id.clone(),
|
||||
post_id,
|
||||
};
|
||||
}
|
||||
MediaEditorMsg::Save => {
|
||||
deferred = DeferredMediaAction::Save(tab_id.clone());
|
||||
}
|
||||
MediaEditorMsg::ReplaceFile => {
|
||||
deferred = DeferredMediaAction::Replace(tab_id.clone());
|
||||
}
|
||||
MediaEditorMsg::Delete => {
|
||||
deferred = DeferredMediaAction::Delete {
|
||||
tab_id: tab_id.clone(),
|
||||
name: state.title.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
if let Some(tab) = self
|
||||
.tabs
|
||||
.iter_mut()
|
||||
.find(|t| t.id == *state.media_id.as_str())
|
||||
{
|
||||
tab.is_dirty = state.is_dirty;
|
||||
}
|
||||
}
|
||||
if let Some((media_id, search)) = picker_refresh {
|
||||
let results = self.query_media_post_picker_results(&media_id, &search);
|
||||
if let Some(state) = self.media_editors.get_mut(&media_id) {
|
||||
state.post_picker_results = results;
|
||||
}
|
||||
}
|
||||
match deferred {
|
||||
DeferredMediaAction::None => Task::none(),
|
||||
DeferredMediaAction::Analyze(media_id) => self.run_media_ai_analysis(&media_id),
|
||||
DeferredMediaAction::DetectLanguage(media_id) => self.detect_media_language(&media_id),
|
||||
DeferredMediaAction::OpenTranslate(media_id) => {
|
||||
self.open_media_translation_modal(&media_id)
|
||||
}
|
||||
DeferredMediaAction::TranslateTo {
|
||||
media_id,
|
||||
target_language,
|
||||
} => self.translate_media_to(&media_id, &target_language),
|
||||
DeferredMediaAction::LinkPost { media_id, post_id } => {
|
||||
self.link_media_to_post(&media_id, &post_id)
|
||||
}
|
||||
DeferredMediaAction::OpenLinkedPost(post_id) => {
|
||||
let title = self
|
||||
.db
|
||||
.as_ref()
|
||||
.and_then(|db| {
|
||||
bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok()
|
||||
})
|
||||
.map(|post| post.title)
|
||||
.unwrap_or_else(|| post_id.clone());
|
||||
Task::done(Message::OpenTab(Tab {
|
||||
id: post_id,
|
||||
tab_type: TabType::Post,
|
||||
title,
|
||||
is_transient: false,
|
||||
is_dirty: false,
|
||||
}))
|
||||
}
|
||||
DeferredMediaAction::UnlinkPost { media_id, post_id } => {
|
||||
self.unlink_media_from_post(&media_id, &post_id)
|
||||
}
|
||||
DeferredMediaAction::Save(tab_id) => self.save_media_editor(&tab_id),
|
||||
DeferredMediaAction::Replace(media_id) => {
|
||||
crate::platform::dialog::pick_media_replacement(
|
||||
media_id,
|
||||
t(self.ui_locale, "dialog.replaceMedia"),
|
||||
t(self.ui_locale, "dialog.imageFilter"),
|
||||
)
|
||||
}
|
||||
DeferredMediaAction::Delete { tab_id, name } => {
|
||||
Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
||||
entity_name: name,
|
||||
references: Vec::new(),
|
||||
on_confirm: modal::ConfirmAction::DeleteMedia(tab_id),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_template_editor_msg(&mut self, msg: TemplateEditorMsg) -> Task<Message> {
|
||||
if let Some(tab_id) = self.active_tab.clone()
|
||||
&& let Some(state) = self.template_editors.get_mut(&tab_id)
|
||||
{
|
||||
match msg {
|
||||
TemplateEditorMsg::TitleChanged(s) => {
|
||||
state.title = s;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
TemplateEditorMsg::SlugChanged(s) => {
|
||||
state.slug = s;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
TemplateEditorMsg::KindChanged(k) => {
|
||||
state.kind = k.0;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
TemplateEditorMsg::EnabledChanged(b) => {
|
||||
state.enabled = b;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
TemplateEditorMsg::ContentChanged(new_text) => {
|
||||
state.content = new_text;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
TemplateEditorMsg::Save => {
|
||||
return self.save_template_editor(&tab_id);
|
||||
}
|
||||
TemplateEditorMsg::Validate => {
|
||||
if let Some(st) = self.template_editors.get_mut(&tab_id) {
|
||||
match engine::template::validate_template(&st.content) {
|
||||
Ok(()) => {
|
||||
st.validation_error = None;
|
||||
}
|
||||
Err(e) => {
|
||||
st.validation_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TemplateEditorMsg::Delete => {
|
||||
return self.show_template_delete_confirmation(&tab_id);
|
||||
}
|
||||
}
|
||||
if let Some(st) = self.template_editors.get(&tab_id)
|
||||
&& let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id)
|
||||
{
|
||||
tab.is_dirty = st.is_dirty;
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn handle_script_editor_msg(&mut self, msg: ScriptEditorMsg) -> Task<Message> {
|
||||
let mut run_request = None;
|
||||
if let Some(tab_id) = self.active_tab.clone()
|
||||
&& let Some(state) = self.script_editors.get_mut(&tab_id)
|
||||
{
|
||||
match msg {
|
||||
ScriptEditorMsg::TitleChanged(s) => {
|
||||
state.title = s;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
ScriptEditorMsg::SlugChanged(s) => {
|
||||
state.slug = s;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
ScriptEditorMsg::KindChanged(k) => {
|
||||
state.kind = k.0;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
ScriptEditorMsg::EntrypointChanged(s) => {
|
||||
state.entrypoint = s;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
ScriptEditorMsg::EnabledChanged(b) => {
|
||||
state.enabled = b;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
ScriptEditorMsg::ContentChanged(new_text) => {
|
||||
state.discovered_entrypoints = engine::script::discover_entrypoints(&new_text);
|
||||
state.content = new_text;
|
||||
state.is_dirty = true;
|
||||
}
|
||||
ScriptEditorMsg::Save => {
|
||||
return self.save_script_editor(&tab_id);
|
||||
}
|
||||
ScriptEditorMsg::CheckSyntax => {
|
||||
if let Some(st) = self.script_editors.get_mut(&tab_id) {
|
||||
match engine::script::validate_script_syntax(&st.content) {
|
||||
Ok(()) => {
|
||||
st.validation_error = None;
|
||||
}
|
||||
Err(e) => {
|
||||
st.validation_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ScriptEditorMsg::Run => {
|
||||
run_request = Some((
|
||||
state.content.clone(),
|
||||
state.entrypoint.clone(),
|
||||
state.kind.clone(),
|
||||
));
|
||||
}
|
||||
ScriptEditorMsg::Delete => {
|
||||
let name = state.title.clone();
|
||||
return Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
||||
entity_name: name,
|
||||
references: Vec::new(),
|
||||
on_confirm: modal::ConfirmAction::DeleteScript(tab_id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
if let Some(st) = self.script_editors.get(&tab_id)
|
||||
&& let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id)
|
||||
{
|
||||
tab.is_dirty = st.is_dirty;
|
||||
}
|
||||
}
|
||||
if let Some((source, entrypoint, kind)) = run_request {
|
||||
return self.spawn_engine_task(
|
||||
"engine.runScript",
|
||||
move |_db_path, _project_id, _data_dir, task_manager, task_id| {
|
||||
let execution_kind = match kind {
|
||||
bds_core::model::ScriptKind::Macro => {
|
||||
bds_core::scripting::ExecutionKind::Macro
|
||||
}
|
||||
bds_core::model::ScriptKind::Utility => {
|
||||
bds_core::scripting::ExecutionKind::Utility
|
||||
}
|
||||
bds_core::model::ScriptKind::Transform => {
|
||||
bds_core::scripting::ExecutionKind::Transform
|
||||
}
|
||||
};
|
||||
let result = bds_core::scripting::execute(
|
||||
&source,
|
||||
&entrypoint,
|
||||
&serde_json::json!({}),
|
||||
execution_kind,
|
||||
&bds_core::scripting::ExecutionControl::default(),
|
||||
)?;
|
||||
for progress in &result.progress {
|
||||
let percent = progress.total.and_then(|total| {
|
||||
(total > 0.0).then_some((progress.current / total) as f32)
|
||||
});
|
||||
task_manager.report_progress(task_id, percent, progress.message.clone());
|
||||
}
|
||||
let mut lines = result.output;
|
||||
lines.extend(
|
||||
result
|
||||
.toasts
|
||||
.into_iter()
|
||||
.map(|message| format!("toast: {message}")),
|
||||
);
|
||||
if result.value != serde_json::Value::Null {
|
||||
lines.push(result.value.to_string());
|
||||
}
|
||||
Ok(if lines.is_empty() {
|
||||
"completed".to_string()
|
||||
} else {
|
||||
lines.join("\n")
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
412
crates/bds-ui/src/app/engine_handlers.rs
Normal file
412
crates/bds-ui/src/app/engine_handlers.rs
Normal file
@@ -0,0 +1,412 @@
|
||||
use super::*;
|
||||
|
||||
impl BdsApp {
|
||||
pub(super) fn handle_engine_message(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::RebuildDatabase => self.spawn_engine_task(
|
||||
"engine.rebuildStarted",
|
||||
|db_path, project_id, data_dir, tm, tid| {
|
||||
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||
let on_progress: engine::rebuild::ProgressFn = Arc::new(move |pct, msg| {
|
||||
tm.report_progress(tid, Some(pct), Some(msg.to_string()));
|
||||
});
|
||||
let report = engine::rebuild::rebuild_from_filesystem_with_progress(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
Some(on_progress),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let posts = report.posts_created + report.posts_updated;
|
||||
let media = report.media_created + report.media_updated;
|
||||
let templates = report.templates_created + report.templates_updated;
|
||||
let scripts = report.scripts_created + report.scripts_updated;
|
||||
Ok(format!(
|
||||
"posts={posts}, media={media}, templates={templates}, scripts={scripts}"
|
||||
))
|
||||
},
|
||||
),
|
||||
Message::ReindexText => {
|
||||
if !self.search_index_rebuild_running {
|
||||
self.active_modal = Some(modal::ModalState::SearchIndexRepair);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::RegenerateCalendar => {
|
||||
let locale = self.ui_locale;
|
||||
self.spawn_engine_task(
|
||||
"engine.calendarStarted",
|
||||
move |db_path, project_id, data_dir, tm, tid| {
|
||||
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||
tm.report_progress(tid, Some(0.20), Some(t(locale, "engine.loadingPosts")));
|
||||
engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(0.90),
|
||||
Some(t(locale, "engine.writingCalendar")),
|
||||
);
|
||||
Ok("done".to_string())
|
||||
},
|
||||
)
|
||||
}
|
||||
Message::ValidateTranslations => {
|
||||
self.open_singleton_tab(
|
||||
TabType::TranslationValidation,
|
||||
"tabBar.translationValidation",
|
||||
);
|
||||
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
|
||||
return Task::none();
|
||||
};
|
||||
self.translation_validation_state.is_running = true;
|
||||
self.translation_validation_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(|e| e.to_string())?;
|
||||
let meta = engine::meta::read_project_json(&data_dir)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let main_lang = meta.main_language.as_deref().unwrap_or("en");
|
||||
let blog_langs = meta.blog_languages.clone();
|
||||
let on_item: engine::validate_translations::ItemProgressFn =
|
||||
Box::new(move |_current, _total, _name| {});
|
||||
engine::validate_translations::validate_translations_with_progress(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
&blog_langs,
|
||||
main_lang,
|
||||
Some(on_item),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
||||
},
|
||||
Message::TranslationValidationLoaded,
|
||||
)
|
||||
}
|
||||
Message::TranslationValidationLoaded(result) => {
|
||||
self.translation_validation_state.is_running = false;
|
||||
match result {
|
||||
Ok(report) => {
|
||||
self.translation_validation_state.report = Some(report);
|
||||
self.translation_validation_state.error_message = None;
|
||||
}
|
||||
Err(error) => self.translation_validation_state.error_message = Some(error),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ValidateMedia => {
|
||||
let locale = self.ui_locale;
|
||||
self.spawn_engine_task(
|
||||
"engine.validateMediaStarted",
|
||||
move |db_path, project_id, _data_dir, tm, tid| {
|
||||
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||
let on_item: engine::validate_media::ProgressFn =
|
||||
Box::new(move |current, total, name| {
|
||||
let pct = if total > 0 {
|
||||
current as f32 / total as f32
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let msg = tw(
|
||||
locale,
|
||||
"engine.checkingItem",
|
||||
&[
|
||||
("current", ¤t.to_string()),
|
||||
("total", &total.to_string()),
|
||||
("name", name),
|
||||
],
|
||||
);
|
||||
tm.report_progress(tid, Some(pct), Some(msg));
|
||||
});
|
||||
let report = engine::validate_media::validate_media(
|
||||
db.conn(),
|
||||
&_data_dir,
|
||||
&project_id,
|
||||
Some(on_item),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(format!(
|
||||
"checked={}, issues={}",
|
||||
report.total_checked,
|
||||
report.issues.len()
|
||||
))
|
||||
},
|
||||
)
|
||||
}
|
||||
Message::GenerateSite => {
|
||||
let locale = self.ui_locale;
|
||||
self.spawn_engine_task(
|
||||
"engine.generateSiteStarted",
|
||||
move |db_path, project_id, data_dir, tm, tid| {
|
||||
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||
let metadata = engine::meta::read_project_json(&data_dir)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if metadata
|
||||
.public_url
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(
|
||||
"public URL is required before generating the site".to_string()
|
||||
);
|
||||
}
|
||||
let main_language = metadata
|
||||
.main_language
|
||||
.clone()
|
||||
.unwrap_or_else(|| "en".to_string());
|
||||
let all_posts = bds_core::db::queries::post::list_posts_by_project(
|
||||
db.conn(),
|
||||
&project_id,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let published_posts = all_posts
|
||||
.into_iter()
|
||||
.filter(engine::generation::has_published_snapshot)
|
||||
.collect::<Vec<_>>();
|
||||
let total = published_posts.len().max(1) as f32;
|
||||
let mut sources = Vec::new();
|
||||
for (index, post) in published_posts.into_iter().enumerate() {
|
||||
if tm.is_cancelled(tid) {
|
||||
return Err("cancelled".to_string());
|
||||
}
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(((index as f32) / total) * 0.7),
|
||||
Some(tw(locale, "engine.renderingPost", &[("post", &post.slug)])),
|
||||
);
|
||||
if let Some(source) =
|
||||
engine::generation::load_published_post_source(&data_dir, post)
|
||||
.map_err(|error| error.to_string())?
|
||||
{
|
||||
sources.push(source);
|
||||
}
|
||||
}
|
||||
let output_dir = data_dir.join("html");
|
||||
std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?;
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(0.85),
|
||||
Some(t(locale, "engine.writingGeneratedFiles")),
|
||||
);
|
||||
let progress_start = 0.70_f32;
|
||||
let progress_span = 0.28_f32;
|
||||
let task_manager = Arc::clone(&tm);
|
||||
let report = engine::generation::generate_starter_site_with_progress(
|
||||
db.conn(),
|
||||
&output_dir,
|
||||
&project_id,
|
||||
&metadata,
|
||||
&sources,
|
||||
&main_language,
|
||||
move |current, total, path| {
|
||||
let fraction = if total == 0 {
|
||||
1.0
|
||||
} else {
|
||||
current as f32 / total as f32
|
||||
};
|
||||
task_manager.report_progress(
|
||||
tid,
|
||||
Some(progress_start + fraction * progress_span),
|
||||
Some(tw(
|
||||
locale,
|
||||
"engine.generatedPage",
|
||||
&[
|
||||
("url", path),
|
||||
("current", ¤t.to_string()),
|
||||
("total", &total.to_string()),
|
||||
],
|
||||
)),
|
||||
);
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(1.0),
|
||||
Some(t(locale, "engine.generationComplete")),
|
||||
);
|
||||
Ok(format!(
|
||||
"written={}, skipped={}, output={}",
|
||||
report.written_paths.len(),
|
||||
report.skipped_paths.len(),
|
||||
output_dir.display(),
|
||||
))
|
||||
},
|
||||
)
|
||||
}
|
||||
Message::RunMetadataDiff => {
|
||||
self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
|
||||
self.start_metadata_diff()
|
||||
}
|
||||
Message::MetadataDiffLoaded(result) => {
|
||||
self.metadata_diff_state.is_running = false;
|
||||
match result {
|
||||
Ok(report) => {
|
||||
self.metadata_diff_state.report = Some(report);
|
||||
self.metadata_diff_state.error_message = None;
|
||||
}
|
||||
Err(error) => self.metadata_diff_state.error_message = Some(error),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::RepairMetadataDiffItem { index, direction } => {
|
||||
let Some(item) = self
|
||||
.metadata_diff_state
|
||||
.report
|
||||
.as_ref()
|
||||
.and_then(|report| report.diffs.get(index))
|
||||
.cloned()
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
let (Some(project), Some(data_dir)) =
|
||||
(self.active_project.as_ref(), self.data_dir.as_ref())
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
self.metadata_diff_state.is_repairing = true;
|
||||
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::repair_metadata_diff_item(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
direction,
|
||||
&item,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
},
|
||||
Message::MetadataDiffItemRepaired,
|
||||
)
|
||||
}
|
||||
Message::MetadataDiffItemRepaired(result) => {
|
||||
self.metadata_diff_state.is_repairing = false;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.notify(
|
||||
ToastLevel::Success,
|
||||
&t(self.ui_locale, "metadataDiff.repaired"),
|
||||
);
|
||||
self.start_metadata_diff()
|
||||
}
|
||||
Err(error) => {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::RunSiteValidation => self.start_site_validation(),
|
||||
Message::ApplySiteValidation => self.apply_site_validation(),
|
||||
Message::EngineTaskDone {
|
||||
task_id,
|
||||
label,
|
||||
result,
|
||||
} => {
|
||||
let search_rebuild_finished = self.search_index_rebuild_task_id == Some(task_id);
|
||||
let cancelled = self.task_manager.status(task_id) == Some(TaskStatus::Cancelled);
|
||||
match &result {
|
||||
_ if cancelled => {}
|
||||
Ok(detail) => {
|
||||
self.task_manager.complete(task_id);
|
||||
self.notify(ToastLevel::Success, &format!("{label}: {detail}"));
|
||||
}
|
||||
Err(err) => {
|
||||
self.task_manager.fail(task_id, err.clone());
|
||||
let message = tw(
|
||||
self.ui_locale,
|
||||
"common.operationFailed",
|
||||
&[("operation", &label), ("error", err)],
|
||||
);
|
||||
self.notify(ToastLevel::Error, &message);
|
||||
}
|
||||
}
|
||||
if search_rebuild_finished {
|
||||
self.search_index_rebuild_running = false;
|
||||
self.search_index_rebuild_task_id = None;
|
||||
self.active_modal = None;
|
||||
if result.is_ok() {
|
||||
self.search_index_rebuild_required = false;
|
||||
}
|
||||
self.sync_menu_state();
|
||||
}
|
||||
let sidebar_task = self.refresh_counts();
|
||||
self.refresh_task_snapshots();
|
||||
sidebar_task
|
||||
}
|
||||
Message::SiteValidationLoaded(result) => {
|
||||
self.site_validation_state.is_running = false;
|
||||
self.site_validation_state.has_run = true;
|
||||
match result {
|
||||
Ok(report) => {
|
||||
self.site_validation_state.error_message = None;
|
||||
self.site_validation_state.missing_files = report.missing_pages;
|
||||
self.site_validation_state.extra_files = report.extra_pages;
|
||||
self.site_validation_state.stale_files = report.stale_pages;
|
||||
self.notify(
|
||||
ToastLevel::Success,
|
||||
&tw(
|
||||
self.ui_locale,
|
||||
"siteValidation.summary",
|
||||
&[
|
||||
("label", &t(self.ui_locale, "tabBar.siteValidation")),
|
||||
(
|
||||
"missing",
|
||||
&self.site_validation_state.missing_files.len().to_string(),
|
||||
),
|
||||
(
|
||||
"extra",
|
||||
&self.site_validation_state.extra_files.len().to_string(),
|
||||
),
|
||||
(
|
||||
"stale",
|
||||
&self.site_validation_state.stale_files.len().to_string(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
self.site_validation_state.error_message = Some(error.clone());
|
||||
self.site_validation_state.missing_files.clear();
|
||||
self.site_validation_state.extra_files.clear();
|
||||
self.site_validation_state.stale_files.clear();
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::SiteValidationApplied(result) => {
|
||||
self.site_validation_state.is_applying = false;
|
||||
match result {
|
||||
Ok(detail) => {
|
||||
self.site_validation_state.error_message = None;
|
||||
self.notify(ToastLevel::Success, &detail);
|
||||
self.start_site_validation()
|
||||
}
|
||||
Err(error) => {
|
||||
self.site_validation_state.error_message = Some(error.clone());
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => unreachable!("non-engine message routed to engine handler"),
|
||||
}
|
||||
}
|
||||
}
|
||||
35
crates/bds-ui/src/app/preview_handlers.rs
Normal file
35
crates/bds-ui/src/app/preview_handlers.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use super::*;
|
||||
|
||||
impl BdsApp {
|
||||
pub(super) fn handle_preview_message(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::MainWindowLoaded(window_id) => {
|
||||
self.main_window_id = window_id;
|
||||
if self.active_post_uses_embedded_preview() {
|
||||
self.sync_embedded_preview_for_active_post()
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
Message::EmbeddedPreviewReady(result) => {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let visible = self.active_post_uses_embedded_preview();
|
||||
if let Some(preview) = &mut self.embedded_preview {
|
||||
preview.controller.take_staged();
|
||||
if let Some(url) = preview.current_url.as_deref() {
|
||||
preview.controller.navigate(url);
|
||||
}
|
||||
preview.controller.set_visible(visible);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
_ => unreachable!("non-preview message routed to preview handler"),
|
||||
}
|
||||
}
|
||||
}
|
||||
584
crates/bds-ui/src/app/search.rs
Normal file
584
crates/bds-ui/src/app/search.rs
Normal file
@@ -0,0 +1,584 @@
|
||||
use super::*;
|
||||
|
||||
impl BdsApp {
|
||||
/// Number of items to load per sidebar page.
|
||||
/// Matches the TypeScript app's limit of 500 for initial load.
|
||||
pub(super) const SIDEBAR_PAGE_SIZE: i64 = 500;
|
||||
|
||||
/// Refresh only sidebar posts using current filter state (async).
|
||||
pub(super) fn refresh_sidebar_posts(&mut self) -> Task<Message> {
|
||||
let (Some(_), Some(project)) = (&self.db, &self.active_project) else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
let db_path = self.db_path.clone();
|
||||
let project_id = project.id.clone();
|
||||
let content_language = self.content_language.clone();
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => self.page_filter.clone(),
|
||||
_ => self.post_filter.clone(),
|
||||
};
|
||||
let is_pages = self.sidebar_view == SidebarView::Pages;
|
||||
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
Self::query_sidebar_posts_blocking(
|
||||
&db_path,
|
||||
&project_id,
|
||||
&content_language,
|
||||
&filter,
|
||||
is_pages,
|
||||
Self::SIDEBAR_PAGE_SIZE + 1,
|
||||
0,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
},
|
||||
Message::SidebarPostsLoaded,
|
||||
)
|
||||
}
|
||||
|
||||
/// Refresh only sidebar media using current filter state (async).
|
||||
pub(super) fn refresh_sidebar_media(&mut self) -> Task<Message> {
|
||||
let (Some(_), Some(project)) = (&self.db, &self.active_project) else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
let db_path = self.db_path.clone();
|
||||
let project_id = project.id.clone();
|
||||
let data_dir = self.data_dir.clone();
|
||||
let content_language = self.content_language.clone();
|
||||
let filter = self.media_filter.clone();
|
||||
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let items = Self::query_sidebar_media_blocking(
|
||||
&db_path,
|
||||
&project_id,
|
||||
&content_language,
|
||||
&filter,
|
||||
Self::SIDEBAR_PAGE_SIZE + 1,
|
||||
0,
|
||||
);
|
||||
|
||||
// Pre-resolve thumbnail paths off the main thread
|
||||
let thumbs: HashMap<String, Option<std::path::PathBuf>> = items
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let thumb = data_dir.as_ref().and_then(|dir| {
|
||||
if !m.mime_type.starts_with("image/") {
|
||||
return None;
|
||||
}
|
||||
let rel =
|
||||
bds_core::util::paths::thumbnail_path(&m.id, "small", "webp");
|
||||
let full = dir.join(&rel);
|
||||
if full.exists() { Some(full) } else { None }
|
||||
});
|
||||
(m.id.clone(), thumb)
|
||||
})
|
||||
.collect();
|
||||
|
||||
(items, thumbs)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
},
|
||||
|(items, thumbs)| Message::SidebarMediaLoaded { items, thumbs },
|
||||
)
|
||||
}
|
||||
|
||||
/// Load more posts (append to existing sidebar data).
|
||||
pub(super) fn load_more_sidebar_posts(&mut self) -> Task<Message> {
|
||||
let (Some(_), Some(project)) = (&self.db, &self.active_project) else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
let db_path = self.db_path.clone();
|
||||
let project_id = project.id.clone();
|
||||
let content_language = self.content_language.clone();
|
||||
let offset = self.sidebar_posts.len() as i64;
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => self.page_filter.clone(),
|
||||
_ => self.post_filter.clone(),
|
||||
};
|
||||
let is_pages = self.sidebar_view == SidebarView::Pages;
|
||||
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
Self::query_sidebar_posts_blocking(
|
||||
&db_path,
|
||||
&project_id,
|
||||
&content_language,
|
||||
&filter,
|
||||
is_pages,
|
||||
Self::SIDEBAR_PAGE_SIZE + 1,
|
||||
offset,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
},
|
||||
Message::SidebarPostsAppended,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn parse_filter_date(input: &str, end_of_day: bool) -> Option<i64> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let date = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d").ok()?;
|
||||
let time = if end_of_day {
|
||||
date.and_hms_opt(23, 59, 59)?
|
||||
} else {
|
||||
date.and_hms_opt(0, 0, 0)?
|
||||
};
|
||||
Some(time.and_utc().timestamp_millis())
|
||||
}
|
||||
|
||||
pub(super) fn build_post_filter_params(
|
||||
filter: &PostFilter,
|
||||
is_pages: bool,
|
||||
) -> bds_core::db::queries::post::PostFilterParams {
|
||||
bds_core::db::queries::post::PostFilterParams {
|
||||
search_query: filter.search_query.clone(),
|
||||
status: filter.status_filter.clone(),
|
||||
language: filter.language_filter.clone(),
|
||||
year: filter.calendar.selected_year,
|
||||
month: filter.calendar.selected_month,
|
||||
from: Self::parse_filter_date(&filter.from_date, false),
|
||||
to: Self::parse_filter_date(&filter.to_date, true),
|
||||
tags: filter.tag_filter.clone(),
|
||||
categories: filter.category_filter.clone(),
|
||||
exclude_pages: !is_pages,
|
||||
pages_only: is_pages,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn query_sidebar_posts_blocking(
|
||||
db_path: &Path,
|
||||
project_id: &str,
|
||||
content_language: &str,
|
||||
filter: &PostFilter,
|
||||
is_pages: bool,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Vec<Post> {
|
||||
let Ok(db) = Database::open(db_path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let params = Self::build_post_filter_params(filter, is_pages);
|
||||
if filter.search_query.trim().is_empty() {
|
||||
return bds_core::db::queries::post::list_posts_filtered(
|
||||
db.conn(),
|
||||
project_id,
|
||||
¶ms,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
let fts_filters = bds_core::db::fts::PostSearchFilters {
|
||||
status: params.status.as_deref(),
|
||||
tags: (!params.tags.is_empty()).then_some(params.tags.as_slice()),
|
||||
categories: (!params.categories.is_empty()).then_some(params.categories.as_slice()),
|
||||
language: params.language.as_deref(),
|
||||
year: params.year,
|
||||
month: params.month,
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
limit: Some(limit as usize),
|
||||
offset: Some(offset as usize),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ids = bds_core::db::fts::search_posts_filtered(
|
||||
db.conn(),
|
||||
¶ms.search_query,
|
||||
content_language,
|
||||
&fts_filters,
|
||||
)
|
||||
.map(|results| results.post_ids)
|
||||
.unwrap_or_default();
|
||||
|
||||
ids.into_iter()
|
||||
.filter_map(|post_id| {
|
||||
bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok()
|
||||
})
|
||||
.filter(|post| post.project_id == project_id)
|
||||
.filter(|post| {
|
||||
let is_page_post = post
|
||||
.categories
|
||||
.iter()
|
||||
.any(|category| category.eq_ignore_ascii_case("page"));
|
||||
if is_pages {
|
||||
is_page_post
|
||||
} else {
|
||||
!is_page_post
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn query_sidebar_media_blocking(
|
||||
db_path: &Path,
|
||||
project_id: &str,
|
||||
content_language: &str,
|
||||
filter: &MediaFilter,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Vec<Media> {
|
||||
let Ok(db) = Database::open(db_path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let params = bds_core::db::queries::media::MediaFilterParams {
|
||||
search_query: filter.search_query.clone(),
|
||||
year: filter.calendar.selected_year,
|
||||
month: filter.calendar.selected_month,
|
||||
tags: filter.tag_filter.clone(),
|
||||
};
|
||||
if filter.search_query.trim().is_empty() {
|
||||
return bds_core::db::queries::media::list_media_filtered(
|
||||
db.conn(),
|
||||
project_id,
|
||||
¶ms,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
let filters = bds_core::db::fts::MediaSearchFilters {
|
||||
project_id: Some(project_id),
|
||||
tags: (!params.tags.is_empty()).then_some(params.tags.as_slice()),
|
||||
year: params.year,
|
||||
month: params.month,
|
||||
limit: Some(limit as usize),
|
||||
offset: Some(offset as usize),
|
||||
};
|
||||
bds_core::db::fts::search_media_filtered(
|
||||
db.conn(),
|
||||
¶ms.search_query,
|
||||
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()
|
||||
}
|
||||
|
||||
pub(super) fn regenerate_project_thumbnails(
|
||||
db: &Database,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
is_cancelled: impl Fn() -> bool,
|
||||
mut progress: impl FnMut(usize, usize, &str),
|
||||
) -> Result<usize, String> {
|
||||
let media = bds_core::db::queries::media::list_media_by_project(db.conn(), project_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let total = media.len();
|
||||
let thumbnails_dir = data_dir.join("thumbnails");
|
||||
let mut regenerated = 0usize;
|
||||
|
||||
for (index, item) in media.iter().enumerate() {
|
||||
if is_cancelled() {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
progress(index, total, &item.original_name);
|
||||
if !item.mime_type.starts_with("image/") {
|
||||
continue;
|
||||
}
|
||||
let source = data_dir.join(&item.file_path);
|
||||
if !source.exists() {
|
||||
continue;
|
||||
}
|
||||
bds_core::util::thumbnail::generate_all_thumbnails(&source, &thumbnails_dir, &item.id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
regenerated += 1;
|
||||
}
|
||||
|
||||
Ok(regenerated)
|
||||
}
|
||||
|
||||
/// Load more media (append to existing sidebar data).
|
||||
pub(super) fn load_more_sidebar_media(&mut self) -> Task<Message> {
|
||||
let (Some(_), Some(project)) = (&self.db, &self.active_project) else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
let db_path = self.db_path.clone();
|
||||
let project_id = project.id.clone();
|
||||
let offset = self.sidebar_media.len() as i64;
|
||||
let data_dir = self.data_dir.clone();
|
||||
let content_language = self.content_language.clone();
|
||||
let filter = self.media_filter.clone();
|
||||
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let items = Self::query_sidebar_media_blocking(
|
||||
&db_path,
|
||||
&project_id,
|
||||
&content_language,
|
||||
&filter,
|
||||
Self::SIDEBAR_PAGE_SIZE + 1,
|
||||
offset,
|
||||
);
|
||||
|
||||
let thumbs: HashMap<String, Option<std::path::PathBuf>> = items
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let thumb = data_dir.as_ref().and_then(|dir| {
|
||||
if !m.mime_type.starts_with("image/") {
|
||||
return None;
|
||||
}
|
||||
let rel =
|
||||
bds_core::util::paths::thumbnail_path(&m.id, "small", "webp");
|
||||
let full = dir.join(&rel);
|
||||
if full.exists() { Some(full) } else { None }
|
||||
});
|
||||
(m.id.clone(), thumb)
|
||||
})
|
||||
.collect();
|
||||
|
||||
(items, thumbs)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
},
|
||||
|(items, thumbs)| Message::SidebarMediaAppended { items, thumbs },
|
||||
)
|
||||
}
|
||||
|
||||
/// Refresh available tags, categories, and calendar data for filter widgets.
|
||||
pub(super) fn refresh_filter_metadata(&mut self) {
|
||||
if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
|
||||
use bds_core::db::queries::media;
|
||||
use bds_core::db::queries::post;
|
||||
|
||||
// Post filter metadata
|
||||
let all_tags = post::distinct_post_tags(db.conn(), &project.id).unwrap_or_default();
|
||||
let all_cats =
|
||||
post::distinct_post_categories(db.conn(), &project.id).unwrap_or_default();
|
||||
|
||||
// Calendar counts for posts (excluding pages)
|
||||
let post_cal =
|
||||
post::post_calendar_counts(db.conn(), &project.id, false, true).unwrap_or_default();
|
||||
self.post_filter.available_tags = all_tags.clone();
|
||||
self.post_filter.available_categories = all_cats.clone();
|
||||
self.post_filter.available_languages = self.blog_languages.clone();
|
||||
self.post_filter.calendar_years = Self::build_calendar_tree(&post_cal);
|
||||
|
||||
// Calendar counts for pages only
|
||||
let page_cal =
|
||||
post::post_calendar_counts(db.conn(), &project.id, true, false).unwrap_or_default();
|
||||
self.page_filter.available_tags = all_tags;
|
||||
self.page_filter.available_categories = all_cats;
|
||||
self.page_filter.available_languages = self.blog_languages.clone();
|
||||
self.page_filter.calendar_years = Self::build_calendar_tree(&page_cal);
|
||||
|
||||
// Media filter metadata
|
||||
self.media_filter.available_tags =
|
||||
media::distinct_media_tags(db.conn(), &project.id).unwrap_or_default();
|
||||
let media_cal =
|
||||
media::media_calendar_counts(db.conn(), &project.id).unwrap_or_default();
|
||||
self.media_filter.calendar_years = Self::build_calendar_tree(&media_cal);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert (year, month, count) tuples into CalendarYear/CalendarMonth tree.
|
||||
pub(super) fn build_calendar_tree(data: &[(i32, u32, usize)]) -> Vec<CalendarYear> {
|
||||
let mut years: Vec<CalendarYear> = Vec::new();
|
||||
for &(y, m, c) in data {
|
||||
if let Some(cy) = years.iter_mut().find(|cy| cy.year == y) {
|
||||
cy.months.push(CalendarMonth { month: m, count: c });
|
||||
} else {
|
||||
years.push(CalendarYear {
|
||||
year: y,
|
||||
months: vec![CalendarMonth { month: m, count: c }],
|
||||
});
|
||||
}
|
||||
}
|
||||
years
|
||||
}
|
||||
|
||||
pub(super) fn handle_sidebar_message(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::PostSearchChanged(query) => {
|
||||
if self.search_index_rebuild_required && !query.is_empty() {
|
||||
self.active_modal = Some(modal::ModalState::SearchIndexRepair);
|
||||
return Task::none();
|
||||
}
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
filter.search_query = query;
|
||||
self.refresh_sidebar_posts()
|
||||
}
|
||||
Message::TogglePostFilterPanel => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
filter.filter_panel_visible = !filter.filter_panel_visible;
|
||||
Task::none()
|
||||
}
|
||||
Message::SetPostStatusFilter(status) => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
filter.status_filter = status;
|
||||
self.refresh_sidebar_posts()
|
||||
}
|
||||
Message::SetPostLanguageFilter(language) => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
filter.language_filter = language;
|
||||
self.refresh_sidebar_posts()
|
||||
}
|
||||
Message::SetPostCalendarYear(year) => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
filter.calendar.selected_year = year;
|
||||
filter.calendar.selected_month = None;
|
||||
self.refresh_sidebar_posts()
|
||||
}
|
||||
Message::SetPostCalendarMonth(month) => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
filter.calendar.selected_month = month;
|
||||
self.refresh_sidebar_posts()
|
||||
}
|
||||
Message::SetPostFromDate(value) => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
filter.from_date = value;
|
||||
self.refresh_sidebar_posts()
|
||||
}
|
||||
Message::SetPostToDate(value) => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
filter.to_date = value;
|
||||
self.refresh_sidebar_posts()
|
||||
}
|
||||
Message::TogglePostTagFilter(tag) => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
if let Some(pos) = filter.tag_filter.iter().position(|t| *t == tag) {
|
||||
filter.tag_filter.remove(pos);
|
||||
} else {
|
||||
filter.tag_filter.push(tag);
|
||||
}
|
||||
self.refresh_sidebar_posts()
|
||||
}
|
||||
Message::TogglePostCategoryFilter(cat) => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
if let Some(pos) = filter.category_filter.iter().position(|c| *c == cat) {
|
||||
filter.category_filter.remove(pos);
|
||||
} else {
|
||||
filter.category_filter.push(cat);
|
||||
}
|
||||
self.refresh_sidebar_posts()
|
||||
}
|
||||
Message::ClearPostFilters => {
|
||||
let filter = match self.sidebar_view {
|
||||
SidebarView::Pages => &mut self.page_filter,
|
||||
_ => &mut self.post_filter,
|
||||
};
|
||||
filter.clear();
|
||||
self.refresh_sidebar_posts()
|
||||
}
|
||||
Message::MediaSearchChanged(query) => {
|
||||
if self.search_index_rebuild_required && !query.is_empty() {
|
||||
self.active_modal = Some(modal::ModalState::SearchIndexRepair);
|
||||
return Task::none();
|
||||
}
|
||||
self.media_filter.search_query = query;
|
||||
self.refresh_sidebar_media()
|
||||
}
|
||||
Message::ToggleMediaFilterPanel => {
|
||||
self.media_filter.filter_panel_visible = !self.media_filter.filter_panel_visible;
|
||||
Task::none()
|
||||
}
|
||||
Message::SetMediaCalendarYear(year) => {
|
||||
self.media_filter.calendar.selected_year = year;
|
||||
self.media_filter.calendar.selected_month = None;
|
||||
self.refresh_sidebar_media()
|
||||
}
|
||||
Message::SetMediaCalendarMonth(month) => {
|
||||
self.media_filter.calendar.selected_month = month;
|
||||
self.refresh_sidebar_media()
|
||||
}
|
||||
Message::ToggleMediaTagFilter(tag) => {
|
||||
if let Some(pos) = self.media_filter.tag_filter.iter().position(|t| *t == tag) {
|
||||
self.media_filter.tag_filter.remove(pos);
|
||||
} else {
|
||||
self.media_filter.tag_filter.push(tag);
|
||||
}
|
||||
self.refresh_sidebar_media()
|
||||
}
|
||||
Message::ClearMediaFilters => {
|
||||
self.media_filter.clear();
|
||||
self.refresh_sidebar_media()
|
||||
}
|
||||
|
||||
// ── Async sidebar data ──
|
||||
Message::SidebarPostsLoaded(mut posts) => {
|
||||
self.sidebar_posts_has_more = posts.len() > Self::SIDEBAR_PAGE_SIZE as usize;
|
||||
posts.truncate(Self::SIDEBAR_PAGE_SIZE as usize);
|
||||
self.sidebar_posts = posts;
|
||||
Task::none()
|
||||
}
|
||||
Message::SidebarMediaLoaded { mut items, thumbs } => {
|
||||
self.sidebar_media_has_more = items.len() > Self::SIDEBAR_PAGE_SIZE as usize;
|
||||
items.truncate(Self::SIDEBAR_PAGE_SIZE as usize);
|
||||
self.sidebar_media = items;
|
||||
self.sidebar_media_thumbs = thumbs;
|
||||
Task::none()
|
||||
}
|
||||
Message::SidebarPostsAppended(mut posts) => {
|
||||
self.sidebar_posts_has_more = posts.len() > Self::SIDEBAR_PAGE_SIZE as usize;
|
||||
posts.truncate(Self::SIDEBAR_PAGE_SIZE as usize);
|
||||
self.sidebar_posts.extend(posts);
|
||||
Task::none()
|
||||
}
|
||||
Message::SidebarMediaAppended { mut items, thumbs } => {
|
||||
self.sidebar_media_has_more = items.len() > Self::SIDEBAR_PAGE_SIZE as usize;
|
||||
items.truncate(Self::SIDEBAR_PAGE_SIZE as usize);
|
||||
self.sidebar_media.extend(items);
|
||||
self.sidebar_media_thumbs.extend(thumbs);
|
||||
Task::none()
|
||||
}
|
||||
Message::LoadMorePosts => self.load_more_sidebar_posts(),
|
||||
Message::LoadMoreMedia => self.load_more_sidebar_media(),
|
||||
_ => unreachable!("non-sidebar message routed to sidebar handler"),
|
||||
}
|
||||
}
|
||||
}
|
||||
187
crates/bds-ui/src/app/tasks.rs
Normal file
187
crates/bds-ui/src/app/tasks.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use super::*;
|
||||
|
||||
impl BdsApp {
|
||||
pub(super) fn refresh_task_snapshots(&mut self) {
|
||||
self.task_snapshots = self
|
||||
.task_manager
|
||||
.snapshots()
|
||||
.into_iter()
|
||||
.map(|snapshot| {
|
||||
let status_str = match &snapshot.status {
|
||||
TaskStatus::Pending => t(self.ui_locale, "tasks.statusPending"),
|
||||
TaskStatus::Running => t(self.ui_locale, "tasks.statusRunning"),
|
||||
TaskStatus::Completed => t(self.ui_locale, "tasks.statusCompleted"),
|
||||
TaskStatus::Failed(error) => {
|
||||
tw(self.ui_locale, "tasks.statusFailed", &[("error", error)])
|
||||
}
|
||||
TaskStatus::Cancelled => t(self.ui_locale, "tasks.statusCancelled"),
|
||||
};
|
||||
TaskSnapshot {
|
||||
id: snapshot.id,
|
||||
label: snapshot.label,
|
||||
group_id: snapshot.group_id,
|
||||
group_name: snapshot.group_name,
|
||||
status: status_str,
|
||||
progress: snapshot.progress,
|
||||
message: snapshot.message,
|
||||
is_cancellable: matches!(
|
||||
snapshot.status,
|
||||
TaskStatus::Pending | TaskStatus::Running
|
||||
),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
/// Rebuild the shared search index while the modal blocks editor writes.
|
||||
pub(super) fn start_search_index_rebuild(&mut self) -> Task<Message> {
|
||||
if self.db.is_none() || self.search_index_rebuild_running {
|
||||
return Task::none();
|
||||
}
|
||||
if self.task_manager.running_count() > 0 || self.task_manager.pending_count() > 0 {
|
||||
self.active_modal = Some(modal::ModalState::SearchIndexRepair);
|
||||
self.notify(
|
||||
ToastLevel::Warning,
|
||||
&t(self.ui_locale, "searchIndexRepair.waitForTasks"),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
self.flush_active_post_editor();
|
||||
let locale = self.ui_locale;
|
||||
let label = t(locale, "engine.reindexStarted");
|
||||
self.add_output(&label);
|
||||
let task_id = self.task_manager.submit(&label);
|
||||
self.search_index_rebuild_running = true;
|
||||
self.search_index_rebuild_task_id = Some(task_id);
|
||||
self.active_modal = Some(modal::ModalState::SearchIndexRebuilding);
|
||||
self.refresh_task_snapshots();
|
||||
self.sync_menu_state();
|
||||
|
||||
let db_path = self.db_path.clone();
|
||||
let label_for_message = label.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())?;
|
||||
let progress_manager = Arc::clone(&task_manager);
|
||||
let on_item: engine::search::ItemProgressFn =
|
||||
Box::new(move |current, total, name| {
|
||||
let progress = if total > 0 {
|
||||
current as f32 / total as f32
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let message = tw(
|
||||
locale,
|
||||
"engine.indexingItem",
|
||||
&[
|
||||
("current", ¤t.to_string()),
|
||||
("total", &total.to_string()),
|
||||
("name", name),
|
||||
],
|
||||
);
|
||||
progress_manager.report_progress(
|
||||
task_id,
|
||||
Some(progress),
|
||||
Some(message),
|
||||
);
|
||||
});
|
||||
let report = engine::search::rebuild_search_index(db.conn(), Some(on_item))
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(format!(
|
||||
"posts={}, media={}",
|
||||
report.posts_indexed, report.media_indexed
|
||||
))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
||||
},
|
||||
move |result| Message::EngineTaskDone {
|
||||
task_id,
|
||||
label: label_for_message.clone(),
|
||||
result,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Spawn a blocking engine operation on a background thread via TaskManager.
|
||||
pub(super) fn spawn_engine_task<F>(&mut self, label_key: &str, work: F) -> Task<Message>
|
||||
where
|
||||
F: FnOnce(PathBuf, String, PathBuf, Arc<TaskManager>, TaskId) -> Result<String, String>
|
||||
+ Send
|
||||
+ 'static,
|
||||
{
|
||||
self.spawn_engine_task_in_group(label_key, None, work)
|
||||
}
|
||||
|
||||
pub(super) fn spawn_grouped_engine_task<F>(
|
||||
&mut self,
|
||||
label_key: &str,
|
||||
group_name: &str,
|
||||
work: F,
|
||||
) -> Task<Message>
|
||||
where
|
||||
F: FnOnce(PathBuf, String, PathBuf, Arc<TaskManager>, TaskId) -> Result<String, String>
|
||||
+ Send
|
||||
+ 'static,
|
||||
{
|
||||
self.spawn_engine_task_in_group(label_key, Some(group_name), work)
|
||||
}
|
||||
|
||||
pub(super) fn spawn_engine_task_in_group<F>(
|
||||
&mut self,
|
||||
label_key: &str,
|
||||
group_name: Option<&str>,
|
||||
work: F,
|
||||
) -> Task<Message>
|
||||
where
|
||||
F: FnOnce(PathBuf, String, PathBuf, Arc<TaskManager>, TaskId) -> Result<String, String>
|
||||
+ Send
|
||||
+ 'static,
|
||||
{
|
||||
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
let db_path = self.db_path.clone();
|
||||
let project_id = project.id.clone();
|
||||
let data_dir = data_dir.clone();
|
||||
|
||||
let label = t(self.ui_locale, label_key);
|
||||
self.add_output(&label);
|
||||
|
||||
let task_id = group_name.map_or_else(
|
||||
|| self.task_manager.submit(&label),
|
||||
|name| {
|
||||
self.task_manager
|
||||
.submit_grouped(&label, &format!("{project_id}:{name}"), name)
|
||||
},
|
||||
);
|
||||
self.refresh_task_snapshots();
|
||||
|
||||
let label_for_msg = label.clone();
|
||||
let tm = Arc::clone(&self.task_manager);
|
||||
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if !tm.wait_until_runnable(task_id) {
|
||||
return Err("cancelled".to_string());
|
||||
}
|
||||
work(db_path, project_id, data_dir, tm, task_id)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("task panicked: {e}")))
|
||||
},
|
||||
move |result| Message::EngineTaskDone {
|
||||
task_id,
|
||||
label: label_for_msg.clone(),
|
||||
result,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use muda::accelerator::{Accelerator, CMD_OR_CTRL, Code, Modifiers};
|
||||
use muda::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::state::tabs::TabType;
|
||||
use bds_core::i18n::{UiLocale, translate};
|
||||
|
||||
/// Every custom menu item that the application handles.
|
||||
@@ -116,6 +117,70 @@ impl MenuAction {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn action_enabled(
|
||||
action: MenuAction,
|
||||
has_project: bool,
|
||||
active_tab: Option<&TabType>,
|
||||
offline: bool,
|
||||
interactions_enabled: bool,
|
||||
) -> bool {
|
||||
if !interactions_enabled {
|
||||
return !matches!(
|
||||
action,
|
||||
MenuAction::NewPost
|
||||
| MenuAction::ImportMedia
|
||||
| MenuAction::Save
|
||||
| MenuAction::OpenInBrowser
|
||||
| MenuAction::OpenDataFolder
|
||||
| MenuAction::Find
|
||||
| MenuAction::Replace
|
||||
| MenuAction::PublishSelected
|
||||
| MenuAction::PreviewPost
|
||||
| MenuAction::EditMenu
|
||||
| MenuAction::RebuildDatabase
|
||||
| MenuAction::ReindexText
|
||||
| MenuAction::MetadataDiff
|
||||
| MenuAction::RegenerateCalendar
|
||||
| MenuAction::ValidateTranslations
|
||||
| MenuAction::FillMissingTranslations
|
||||
| MenuAction::GenerateSitemap
|
||||
| MenuAction::ValidateSite
|
||||
| MenuAction::UploadSite
|
||||
);
|
||||
}
|
||||
|
||||
let post_tab = active_tab == Some(&TabType::Post);
|
||||
let savable_tab = matches!(
|
||||
active_tab,
|
||||
Some(TabType::Post | TabType::Media | TabType::Templates | TabType::Scripts)
|
||||
);
|
||||
let text_editor = matches!(
|
||||
active_tab,
|
||||
Some(TabType::Post | TabType::Templates | TabType::Scripts)
|
||||
);
|
||||
match action {
|
||||
MenuAction::Save => savable_tab,
|
||||
MenuAction::Find | MenuAction::Replace => text_editor,
|
||||
MenuAction::OpenInBrowser | MenuAction::PublishSelected | MenuAction::PreviewPost => {
|
||||
has_project && post_tab
|
||||
}
|
||||
MenuAction::UploadSite => has_project && !offline,
|
||||
MenuAction::NewPost
|
||||
| MenuAction::ImportMedia
|
||||
| MenuAction::OpenDataFolder
|
||||
| MenuAction::EditMenu
|
||||
| MenuAction::RebuildDatabase
|
||||
| MenuAction::ReindexText
|
||||
| MenuAction::MetadataDiff
|
||||
| MenuAction::RegenerateCalendar
|
||||
| MenuAction::ValidateTranslations
|
||||
| MenuAction::FillMissingTranslations
|
||||
| MenuAction::GenerateSitemap
|
||||
| MenuAction::ValidateSite => has_project,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps between muda `MenuId`s and application `MenuAction`s.
|
||||
///
|
||||
/// Also holds clones of the `MenuItem` handles so that labels and
|
||||
@@ -164,11 +229,6 @@ impl MenuRegistry {
|
||||
item.set_text(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of registered (action, MenuId) pairs.
|
||||
pub fn action_count(&self) -> usize {
|
||||
self.action_map.len()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -422,14 +482,6 @@ mod tests {
|
||||
use super::*;
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
#[test]
|
||||
fn all_variants_are_listed() {
|
||||
// MenuAction::ALL must contain every variant.
|
||||
// If someone adds a variant but forgets ALL, the i18n_key match
|
||||
// will produce a compile error, but this also catches ALL length.
|
||||
assert_eq!(MenuAction::ALL.len(), 28);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i18n_keys_resolve_for_english() {
|
||||
for &action in MenuAction::ALL {
|
||||
@@ -446,7 +498,6 @@ mod tests {
|
||||
reg.register(MenuAction::Save, &mi);
|
||||
|
||||
assert_eq!(reg.lookup(mi.id()), Some(MenuAction::Save));
|
||||
assert_eq!(reg.action_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -477,4 +528,44 @@ mod tests {
|
||||
assert!(seen.insert(key), "duplicate i18n key: {key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_state_follows_application_rules() {
|
||||
assert!(action_enabled(
|
||||
MenuAction::Save,
|
||||
true,
|
||||
Some(&TabType::Media),
|
||||
false,
|
||||
true
|
||||
));
|
||||
assert!(!action_enabled(
|
||||
MenuAction::Find,
|
||||
true,
|
||||
Some(&TabType::Media),
|
||||
false,
|
||||
true
|
||||
));
|
||||
assert!(action_enabled(
|
||||
MenuAction::FillMissingTranslations,
|
||||
true,
|
||||
None,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!action_enabled(
|
||||
MenuAction::UploadSite,
|
||||
true,
|
||||
None,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!action_enabled(
|
||||
MenuAction::ReindexText,
|
||||
true,
|
||||
None,
|
||||
false,
|
||||
false
|
||||
));
|
||||
assert!(action_enabled(MenuAction::About, true, None, false, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,12 @@ pub enum PanelTab {
|
||||
pub struct TaskSnapshot {
|
||||
pub id: u64,
|
||||
pub label: String,
|
||||
pub group_id: Option<String>,
|
||||
pub group_name: Option<String>,
|
||||
pub status: String,
|
||||
pub progress: Option<f32>,
|
||||
pub message: Option<String>,
|
||||
pub is_cancellable: bool,
|
||||
}
|
||||
|
||||
/// A single line of output shown in the panel.
|
||||
|
||||
@@ -64,13 +64,6 @@ mod tests {
|
||||
assert_ne!(a.id, b.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toast_levels() {
|
||||
let t = Toast::new(ToastLevel::Error, "oops".into());
|
||||
assert_eq!(t.level, ToastLevel::Error);
|
||||
assert!(!t.message.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_toast_not_expired() {
|
||||
let t = Toast::new(ToastLevel::Info, "test".into());
|
||||
|
||||
@@ -1,14 +1,45 @@
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{Space, button, column, container, scrollable, text};
|
||||
use iced::widget::{Space, button, column, container, row, scrollable, text};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::components::inputs;
|
||||
use crate::i18n::t;
|
||||
use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot};
|
||||
use crate::state::tabs::{Tab, TabType};
|
||||
use crate::views::post_editor::ResolvedPostLink;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Message> {
|
||||
let progress = snapshot
|
||||
.progress
|
||||
.map(|value| format!(" ({:.0}%)", value * 100.0))
|
||||
.unwrap_or_default();
|
||||
let phase = snapshot
|
||||
.message
|
||||
.as_deref()
|
||||
.map(|message| format!(" — {message}"))
|
||||
.unwrap_or_default();
|
||||
let label = text(format!(
|
||||
"{} — {}{}{}",
|
||||
snapshot.label, snapshot.status, progress, phase
|
||||
))
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.70, 0.70, 0.75));
|
||||
let mut content = row![label].align_y(Alignment::Center).spacing(8);
|
||||
if snapshot.is_cancellable {
|
||||
content = content.push(Space::with_width(Length::Fill)).push(
|
||||
button(text(t(locale, "tasks.cancelTask")).size(11))
|
||||
.on_press(Message::CancelTask(snapshot.id))
|
||||
.padding([3, 8])
|
||||
.style(inputs::secondary_button),
|
||||
);
|
||||
}
|
||||
content.into()
|
||||
}
|
||||
|
||||
/// Panel background style.
|
||||
fn panel_style(_theme: &Theme) -> container::Style {
|
||||
@@ -71,6 +102,7 @@ fn close_btn_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
pub fn view(
|
||||
panel_tab: PanelTab,
|
||||
task_snapshots: &[TaskSnapshot],
|
||||
collapsed_task_groups: &HashSet<String>,
|
||||
output_entries: &[OutputEntry],
|
||||
post_outlinks: &[ResolvedPostLink],
|
||||
post_backlinks: &[ResolvedPostLink],
|
||||
@@ -169,32 +201,54 @@ pub fn view(
|
||||
.padding(8)
|
||||
.into()
|
||||
} else {
|
||||
// Per layout.allium: last 10 tasks, newest first
|
||||
let items: Vec<Element<'static, Message>> = task_snapshots
|
||||
.iter()
|
||||
.rev()
|
||||
.take(10)
|
||||
.map(|snap| {
|
||||
let progress_str = snap
|
||||
.progress
|
||||
.map(|p| format!(" ({:.0}%)", p * 100.0))
|
||||
.unwrap_or_default();
|
||||
let phase_str = snap
|
||||
.message
|
||||
.as_deref()
|
||||
.map(|m| format!(" \u{2014} {m}"))
|
||||
.unwrap_or_default();
|
||||
let status_text = format!(
|
||||
"{} \u{2014} {}{}{}",
|
||||
snap.label, snap.status, progress_str, phase_str,
|
||||
);
|
||||
text(status_text)
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.70, 0.70, 0.75))
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
let visible = task_snapshots.iter().rev().take(10).collect::<Vec<_>>();
|
||||
let mut rendered_groups = HashSet::new();
|
||||
let mut items: Vec<Element<'static, Message>> = Vec::new();
|
||||
for snapshot in &visible {
|
||||
let Some(group_id) = snapshot.group_id.as_ref() else {
|
||||
items.push(task_row(snapshot, locale));
|
||||
continue;
|
||||
};
|
||||
if !rendered_groups.insert(group_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let members = visible
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|member| member.group_id.as_ref() == Some(group_id))
|
||||
.collect::<Vec<_>>();
|
||||
let progress_values = members
|
||||
.iter()
|
||||
.filter_map(|member| member.progress)
|
||||
.collect::<Vec<_>>();
|
||||
let progress = (!progress_values.is_empty()).then(|| {
|
||||
format!(
|
||||
" ({:.0}%)",
|
||||
progress_values.iter().sum::<f32>() / progress_values.len() as f32
|
||||
* 100.0
|
||||
)
|
||||
});
|
||||
let collapsed = collapsed_task_groups.contains(group_id);
|
||||
let group_name = snapshot.group_name.as_deref().unwrap_or(group_id);
|
||||
items.push(
|
||||
button(
|
||||
row![
|
||||
text(if collapsed { "\u{25b8}" } else { "\u{25be}" }).size(11),
|
||||
text(format!("{}{}", group_name, progress.unwrap_or_default()))
|
||||
.size(11)
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.on_press(Message::ToggleTaskGroup(group_id.clone()))
|
||||
.width(Length::Fill)
|
||||
.padding([3, 6])
|
||||
.style(inputs::disclosure_button)
|
||||
.into(),
|
||||
);
|
||||
if !collapsed {
|
||||
items.extend(members.into_iter().map(|member| task_row(member, locale)));
|
||||
}
|
||||
}
|
||||
scrollable(
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(4)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use iced::widget::text::Shaping;
|
||||
@@ -78,6 +78,7 @@ pub fn view<'a>(
|
||||
panel_visible: bool,
|
||||
panel_tab: PanelTab,
|
||||
task_snapshots: &'a [TaskSnapshot],
|
||||
collapsed_task_groups: &'a HashSet<String>,
|
||||
output_entries: &'a [OutputEntry],
|
||||
// Sidebar data
|
||||
sidebar_posts: &'a [Post],
|
||||
@@ -168,6 +169,7 @@ pub fn view<'a>(
|
||||
right_col = right_col.push(panel::view(
|
||||
panel_tab,
|
||||
task_snapshots,
|
||||
collapsed_task_groups,
|
||||
output_entries,
|
||||
post_outlinks,
|
||||
post_backlinks,
|
||||
|
||||
Reference in New Issue
Block a user