2315 lines
103 KiB
Rust
2315 lines
103 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use iced::{Element, Subscription, Task};
|
|
|
|
use bds_core::db::Database;
|
|
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
|
|
use bds_core::engine;
|
|
use bds_core::i18n::{detect_os_locale, UiLocale};
|
|
use bds_core::model::{Media, Post, Project, Script, Template};
|
|
|
|
use crate::i18n::{t, tw};
|
|
use crate::platform::menu::{self, MenuAction, MenuRegistry};
|
|
use crate::state::navigation::{
|
|
handle_activity_click, OutputEntry, PanelTab, SidebarView, TaskSnapshot,
|
|
};
|
|
use crate::state::sidebar_filter::{
|
|
CalendarMonth, CalendarYear, MediaFilter, PostFilter,
|
|
};
|
|
use crate::state::tabs::{self, Tab, TabType};
|
|
use crate::state::toast::{Toast, ToastLevel};
|
|
use crate::views::{
|
|
modal, workspace,
|
|
post_editor::{PostEditorState, PostEditorMsg, ResolvedPostLink},
|
|
media_editor::{MediaEditorState, MediaEditorMsg},
|
|
template_editor::{TemplateEditorState, TemplateEditorMsg},
|
|
script_editor::{ScriptEditorState, ScriptEditorMsg},
|
|
tags_view::{self, TagsViewState, TagsMsg},
|
|
settings_view::{SettingsViewState, SettingsMsg},
|
|
dashboard::DashboardState,
|
|
translation_editor::{TranslationEditorState, TranslationEditorMsg},
|
|
};
|
|
|
|
// ───────────────────────────────────────────────────────────
|
|
// Message
|
|
// ───────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Message {
|
|
// Menu
|
|
MenuEvent(muda::MenuId),
|
|
|
|
// Navigation
|
|
SetActiveView(SidebarView),
|
|
ToggleSidebar,
|
|
TogglePanel,
|
|
|
|
// Sidebar resize
|
|
SidebarResizeStart,
|
|
SidebarResizeMove(f32),
|
|
SidebarResizeEnd,
|
|
|
|
// Tabs
|
|
OpenTab(Tab),
|
|
CloseTab(String),
|
|
SelectTab(String),
|
|
PinTab(String),
|
|
ClearTabs,
|
|
|
|
// Project
|
|
ProjectsLoaded(Vec<Project>),
|
|
SwitchProject(String),
|
|
ProjectSwitched(Result<String, String>),
|
|
RequestCreateProject,
|
|
CreateProject { name: String, data_path: Option<PathBuf> },
|
|
ProjectCreated(Result<String, String>),
|
|
DeleteProject(String),
|
|
ProjectDeleted(Result<String, String>),
|
|
|
|
// Dialogs
|
|
FolderPicked(Option<PathBuf>),
|
|
MediaFilesPicked(Option<Vec<PathBuf>>),
|
|
|
|
// Tasks
|
|
TaskTick,
|
|
|
|
// macOS lifecycle
|
|
FileOpenRequested(PathBuf),
|
|
UrlOpenRequested(String),
|
|
|
|
// Panel
|
|
SetPanelTab(PanelTab),
|
|
|
|
// Settings
|
|
SetOfflineMode(bool),
|
|
SetUiLocale(UiLocale),
|
|
ToggleLocaleDropdown,
|
|
ToggleProjectDropdown,
|
|
|
|
// Toast
|
|
ShowToast(ToastLevel, String),
|
|
DismissToast(u64),
|
|
ExpireToasts,
|
|
|
|
// Sidebar filters
|
|
PostSearchChanged(String),
|
|
TogglePostFilterPanel,
|
|
SetPostCalendarYear(Option<i32>),
|
|
SetPostCalendarMonth(Option<u32>),
|
|
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),
|
|
|
|
// Blog actions (dispatched to engine)
|
|
RebuildDatabase,
|
|
ReindexText,
|
|
RegenerateCalendar,
|
|
ValidateTranslations,
|
|
ValidateMedia,
|
|
GenerateSite,
|
|
RunMetadataDiff,
|
|
EngineTaskDone { task_id: TaskId, label: String, result: Result<String, String> },
|
|
|
|
// Editor views
|
|
PostEditor(PostEditorMsg),
|
|
MediaEditor(MediaEditorMsg),
|
|
TemplateEditor(TemplateEditorMsg),
|
|
ScriptEditor(ScriptEditorMsg),
|
|
Tags(TagsMsg),
|
|
Settings(SettingsMsg),
|
|
TranslationEditor(TranslationEditorMsg),
|
|
|
|
// Editor data loading
|
|
PostLoaded(Result<Post, String>),
|
|
MediaLoaded(Result<Media, String>),
|
|
TemplateLoaded(Result<Template, String>),
|
|
ScriptLoaded(Result<Script, String>),
|
|
|
|
Noop,
|
|
InitMenuBar,
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────
|
|
// 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 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>,
|
|
task_snapshots: Vec<TaskSnapshot>,
|
|
output_entries: Vec<OutputEntry>,
|
|
|
|
// Platform
|
|
_menu_bar: muda::Menu,
|
|
menu_registry: MenuRegistry,
|
|
|
|
// 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>,
|
|
|
|
// 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>,
|
|
translation_editors: HashMap<String, TranslationEditorState>,
|
|
tags_view_state: Option<TagsViewState>,
|
|
settings_state: Option<SettingsViewState>,
|
|
dashboard_state: Option<DashboardState>,
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────
|
|
// App Implementation
|
|
// ───────────────────────────────────────────────────────────
|
|
|
|
impl BdsApp {
|
|
pub fn new() -> (Self, Task<Message>) {
|
|
let locale = detect_os_locale();
|
|
let (menu_bar, registry) = menu::build_menu_bar(locale);
|
|
|
|
// Open or create the database
|
|
let db_path = dirs::data_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join("bds")
|
|
.join("bds.db");
|
|
|
|
// Ensure parent directory exists
|
|
if let Some(parent) = db_path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
|
|
let mut db = Database::open(&db_path).ok();
|
|
if let Some(ref mut db) = db {
|
|
let _ = db.migrate();
|
|
}
|
|
|
|
// 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 = dirs::data_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join("bds")
|
|
.join("projects")
|
|
.join("my-blog");
|
|
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)]);
|
|
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);
|
|
|
|
(
|
|
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(),
|
|
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()),
|
|
task_snapshots: Vec::new(),
|
|
output_entries: Vec::new(),
|
|
_menu_bar: menu_bar,
|
|
menu_registry: registry,
|
|
ui_locale: locale,
|
|
content_language: "en".to_string(),
|
|
blog_languages: Vec::new(),
|
|
offline_mode: false,
|
|
locale_dropdown_open: false,
|
|
project_dropdown_open: false,
|
|
theme_badge: String::from("pico"),
|
|
toasts: Vec::new(),
|
|
active_modal: None,
|
|
post_editors: HashMap::new(),
|
|
media_editors: HashMap::new(),
|
|
template_editors: HashMap::new(),
|
|
script_editors: HashMap::new(),
|
|
translation_editors: HashMap::new(),
|
|
tags_view_state: None,
|
|
settings_state: None,
|
|
dashboard_state: None,
|
|
},
|
|
init_task,
|
|
)
|
|
}
|
|
|
|
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();
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ToggleSidebar => {
|
|
self.sidebar_visible = !self.sidebar_visible;
|
|
Task::none()
|
|
}
|
|
Message::TogglePanel => {
|
|
self.panel_visible = !self.panel_visible;
|
|
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) => {
|
|
let idx = tabs::open_tab(&mut self.tabs, tab);
|
|
if let Some(t) = self.tabs.get(idx) {
|
|
self.active_tab = Some(t.id.clone());
|
|
let tab_clone = t.clone();
|
|
self.load_editor_for_tab(&tab_clone);
|
|
}
|
|
self.enforce_panel_tab_fallback();
|
|
self.sync_menu_state();
|
|
Task::none()
|
|
}
|
|
Message::CloseTab(id) => {
|
|
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.enforce_panel_tab_fallback();
|
|
self.sync_menu_state();
|
|
Task::none()
|
|
}
|
|
Message::SelectTab(id) => {
|
|
if self.tabs.iter().any(|t| t.id == id) {
|
|
self.active_tab = Some(id);
|
|
}
|
|
self.enforce_panel_tab_fallback();
|
|
Task::none()
|
|
}
|
|
Message::PinTab(id) => {
|
|
tabs::pin_tab(&mut self.tabs, &id);
|
|
Task::none()
|
|
}
|
|
Message::ClearTabs => {
|
|
self.tabs.clear();
|
|
self.active_tab = None;
|
|
Task::none()
|
|
}
|
|
|
|
// ── 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) {
|
|
self.add_output(&format!("Metadata sync failed: {e}"));
|
|
}
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
self.refresh_counts();
|
|
self.sync_menu_state();
|
|
Task::none()
|
|
}
|
|
Message::SwitchProject(project_id) => {
|
|
self.project_dropdown_open = false;
|
|
if let Some(ref db) = self.db {
|
|
match engine::project::set_active_project(db.conn(), &project_id) {
|
|
Ok(()) => {
|
|
self.active_project = self.projects.iter().find(|p| p.id == project_id).cloned();
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
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::none()
|
|
}
|
|
Message::ProjectSwitched(result) => {
|
|
match result {
|
|
Ok(name) => self.notify(ToastLevel::Success, &tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)])),
|
|
Err(msg) => self.notify(ToastLevel::Error, &msg),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::RequestCreateProject => {
|
|
crate::platform::dialog::pick_folder(t(self.ui_locale, "dialog.selectFolder"))
|
|
}
|
|
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.active_project = Some(project.clone());
|
|
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"));
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ProjectCreated(result) => {
|
|
match result {
|
|
Ok(name) => self.notify(ToastLevel::Success, &tw(self.ui_locale, "projectSelector.toast.created", &[("name", &name)])),
|
|
Err(msg) => self.notify(ToastLevel::Error, &msg),
|
|
}
|
|
Task::none()
|
|
}
|
|
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()
|
|
}
|
|
Message::ProjectDeleted(result) => {
|
|
if let Err(msg) = result {
|
|
self.notify(ToastLevel::Error, &msg);
|
|
}
|
|
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::MediaFilesPicked(_paths) => {
|
|
// Media import will be expanded in later milestones
|
|
Task::none()
|
|
}
|
|
|
|
// ── Tasks ──
|
|
Message::TaskTick => {
|
|
self.refresh_task_snapshots();
|
|
Task::none()
|
|
}
|
|
|
|
// ── macOS lifecycle ──
|
|
Message::FileOpenRequested(_path) => {
|
|
// File open handling deferred to later milestones
|
|
Task::none()
|
|
}
|
|
Message::UrlOpenRequested(_url) => {
|
|
// URL open handling deferred to later milestones
|
|
Task::none()
|
|
}
|
|
|
|
// ── Panel ──
|
|
Message::SetPanelTab(tab) => {
|
|
self.panel_tab = tab;
|
|
Task::none()
|
|
}
|
|
|
|
// ── Settings ──
|
|
Message::SetOfflineMode(mode) => {
|
|
self.offline_mode = mode;
|
|
self.sync_menu_state();
|
|
Task::none()
|
|
}
|
|
Message::SetUiLocale(locale) => {
|
|
self.ui_locale = locale;
|
|
self.locale_dropdown_open = false;
|
|
menu::update_menu_labels(&self.menu_registry, locale);
|
|
// Re-translate singleton tab titles per tabs.allium
|
|
for tab in &mut self.tabs {
|
|
if let Some(key) = tab.tab_type.i18n_key() {
|
|
tab.title = t(locale, key);
|
|
}
|
|
}
|
|
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::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 => {
|
|
self.spawn_engine_task(
|
|
"engine.reindexStarted",
|
|
|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.0), Some("Reading project config...".into()));
|
|
let main_lang = engine::meta::read_project_json(&data_dir)
|
|
.ok()
|
|
.and_then(|m| m.main_language)
|
|
.unwrap_or_else(|| "en".to_string());
|
|
let tm2 = Arc::clone(&tm);
|
|
let on_item: engine::search::ItemProgressFn = Box::new(move |current, total, name| {
|
|
let pct = if total > 0 { current as f32 / total as f32 } else { 1.0 };
|
|
let msg = format!("Indexing: {current}/{total} \u{2014} {name}");
|
|
tm2.report_progress(tid, Some(pct), Some(msg));
|
|
});
|
|
let report = engine::search::reindex_all_with_progress(
|
|
db.conn(), &project_id, &main_lang, Some(on_item),
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(format!("posts={}, media={}", report.posts_indexed, report.media_indexed))
|
|
},
|
|
)
|
|
}
|
|
Message::RegenerateCalendar => {
|
|
self.spawn_engine_task(
|
|
"engine.calendarStarted",
|
|
|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("Loading posts...".into()));
|
|
engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id)
|
|
.map_err(|e| e.to_string())?;
|
|
tm.report_progress(tid, Some(0.90), Some("Writing calendar JSON...".into()));
|
|
Ok("done".to_string())
|
|
},
|
|
)
|
|
}
|
|
Message::ValidateTranslations => {
|
|
self.open_singleton_tab(TabType::TranslationValidation, "tabBar.translationValidation");
|
|
self.spawn_engine_task(
|
|
"engine.validateTranslationsStarted",
|
|
|db_path, project_id, data_dir, tm, tid| {
|
|
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 tm2 = Arc::clone(&tm);
|
|
let on_item: engine::validate_translations::ItemProgressFn = Box::new(move |current, total, name| {
|
|
let pct = if total > 0 { current as f32 / total as f32 } else { 1.0 };
|
|
let msg = format!("Checking: {current}/{total} \u{2014} {name}");
|
|
tm2.report_progress(tid, Some(pct), Some(msg));
|
|
});
|
|
let report = 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())?;
|
|
Ok(format!("db_issues={}, fs_issues={}", report.db_issues.len(), report.fs_issues.len()))
|
|
},
|
|
)
|
|
}
|
|
Message::ValidateMedia => {
|
|
self.spawn_engine_task(
|
|
"engine.validateMediaStarted",
|
|
|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 = format!("Checking: {current}/{total} \u{2014} {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 => {
|
|
self.spawn_engine_task(
|
|
"engine.generateSiteStarted",
|
|
|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("Generating calendar...".into()));
|
|
engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id)
|
|
.map_err(|e| e.to_string())?;
|
|
tm.report_progress(tid, Some(0.90), Some("Calendar written".into()));
|
|
Ok("done".to_string())
|
|
},
|
|
)
|
|
}
|
|
Message::RunMetadataDiff => {
|
|
self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
|
|
Task::none()
|
|
}
|
|
Message::EngineTaskDone { task_id, label, result } => {
|
|
match &result {
|
|
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());
|
|
self.notify(ToastLevel::Error, &format!("{label} failed: {err}"));
|
|
}
|
|
}
|
|
self.refresh_counts();
|
|
self.refresh_task_snapshots();
|
|
Task::none()
|
|
}
|
|
|
|
// ── Toast ──
|
|
Message::ShowToast(level, msg) => {
|
|
self.toasts.push(Toast::new(level, msg));
|
|
Task::none()
|
|
}
|
|
Message::DismissToast(id) => {
|
|
self.toasts.retain(|t| t.id != id);
|
|
Task::none()
|
|
}
|
|
Message::ExpireToasts => {
|
|
self.toasts.retain(|t| !t.is_expired());
|
|
Task::none()
|
|
}
|
|
|
|
// ── Sidebar filters ──
|
|
Message::PostSearchChanged(query) => {
|
|
let filter = match self.sidebar_view {
|
|
SidebarView::Pages => &mut self.page_filter,
|
|
_ => &mut self.post_filter,
|
|
};
|
|
filter.search_query = query;
|
|
self.refresh_sidebar_posts();
|
|
Task::none()
|
|
}
|
|
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::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();
|
|
Task::none()
|
|
}
|
|
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();
|
|
Task::none()
|
|
}
|
|
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();
|
|
Task::none()
|
|
}
|
|
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();
|
|
Task::none()
|
|
}
|
|
Message::ClearPostFilters => {
|
|
let filter = match self.sidebar_view {
|
|
SidebarView::Pages => &mut self.page_filter,
|
|
_ => &mut self.post_filter,
|
|
};
|
|
filter.clear();
|
|
self.refresh_sidebar_posts();
|
|
Task::none()
|
|
}
|
|
Message::MediaSearchChanged(query) => {
|
|
self.media_filter.search_query = query;
|
|
self.refresh_sidebar_media();
|
|
Task::none()
|
|
}
|
|
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();
|
|
Task::none()
|
|
}
|
|
Message::SetMediaCalendarMonth(month) => {
|
|
self.media_filter.calendar.selected_month = month;
|
|
self.refresh_sidebar_media();
|
|
Task::none()
|
|
}
|
|
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();
|
|
Task::none()
|
|
}
|
|
Message::ClearMediaFilters => {
|
|
self.media_filter.clear();
|
|
self.refresh_sidebar_media();
|
|
Task::none()
|
|
}
|
|
|
|
// ── 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::DeleteProject(id) => {
|
|
Task::done(Message::DeleteProject(id))
|
|
}
|
|
modal::ConfirmAction::DeletePost(_id) => {
|
|
// Post deletion will be implemented in M3 editors
|
|
Task::none()
|
|
}
|
|
modal::ConfirmAction::DeleteMedia(_id) => {
|
|
Task::none()
|
|
}
|
|
modal::ConfirmAction::DeleteScript(_id) => {
|
|
Task::none()
|
|
}
|
|
modal::ConfirmAction::DeleteTemplate(_id) => {
|
|
Task::none()
|
|
}
|
|
modal::ConfirmAction::MergeTags { .. } => {
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Editor view messages ──
|
|
Message::PostEditor(msg) => {
|
|
if let Some(tab_id) = self.active_tab.clone() {
|
|
if let Some(state) = self.post_editors.get_mut(&tab_id) {
|
|
match msg {
|
|
PostEditorMsg::TitleChanged(s) => { state.title = s; state.is_dirty = true; }
|
|
PostEditorMsg::SlugChanged(s) => { state.slug = s; state.is_dirty = true; }
|
|
PostEditorMsg::ExcerptChanged(s) => { state.excerpt = s; state.is_dirty = true; }
|
|
PostEditorMsg::ContentChanged(new_text) => {
|
|
state.content = new_text;
|
|
state.is_dirty = true;
|
|
}
|
|
PostEditorMsg::AuthorChanged(s) => { state.author = s; state.is_dirty = true; }
|
|
PostEditorMsg::TemplateSlugChanged(s) => { state.template_slug = s; state.is_dirty = true; }
|
|
PostEditorMsg::ToggleDoNotTranslate(b) => { state.do_not_translate = b; state.is_dirty = true; }
|
|
PostEditorMsg::ToggleMetadata => { state.metadata_expanded = !state.metadata_expanded; }
|
|
PostEditorMsg::ToggleExcerpt => { state.excerpt_expanded = !state.excerpt_expanded; }
|
|
PostEditorMsg::SwitchLanguage(lang) => { state.switch_language(&lang); }
|
|
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.is_dirty = true;
|
|
}
|
|
state.tags_input.clear();
|
|
}
|
|
PostEditorMsg::RemoveTag(tag) => {
|
|
state.tags.retain(|t| t != &tag);
|
|
state.is_dirty = true;
|
|
}
|
|
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.is_dirty = true;
|
|
}
|
|
state.categories_input.clear();
|
|
}
|
|
PostEditorMsg::RemoveCategory(cat) => {
|
|
state.categories.retain(|c| c != &cat);
|
|
state.is_dirty = true;
|
|
}
|
|
PostEditorMsg::Save => {
|
|
return self.save_post_editor(&tab_id);
|
|
}
|
|
PostEditorMsg::Publish => {
|
|
return self.publish_post_editor(&tab_id);
|
|
}
|
|
PostEditorMsg::Delete => {
|
|
let name = state.title.clone();
|
|
return Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
|
entity_name: name,
|
|
references: Vec::new(),
|
|
on_confirm: modal::ConfirmAction::DeletePost(tab_id),
|
|
}));
|
|
}
|
|
}
|
|
// Mark tab dirty
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *state.post_id.as_str()) {
|
|
tab.is_dirty = state.is_dirty;
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::MediaEditor(msg) => {
|
|
if let Some(tab_id) = self.active_tab.clone() {
|
|
if let Some(state) = self.media_editors.get_mut(&tab_id) {
|
|
match msg {
|
|
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::SwitchLanguage(lang) => { state.switch_language(&lang); }
|
|
MediaEditorMsg::Save => {
|
|
return self.save_media_editor(&tab_id);
|
|
}
|
|
MediaEditorMsg::Delete => {
|
|
let name = state.title.clone();
|
|
return Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
|
entity_name: name,
|
|
references: Vec::new(),
|
|
on_confirm: modal::ConfirmAction::DeleteMedia(tab_id),
|
|
}));
|
|
}
|
|
}
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *state.media_id.as_str()) {
|
|
tab.is_dirty = state.is_dirty;
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::TemplateEditor(msg) => {
|
|
if let Some(tab_id) = self.active_tab.clone() {
|
|
if 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 => {
|
|
let name = state.title.clone();
|
|
return Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
|
entity_name: name,
|
|
references: Vec::new(),
|
|
on_confirm: modal::ConfirmAction::DeleteTemplate(tab_id),
|
|
}));
|
|
}
|
|
}
|
|
if let Some(st) = self.template_editors.get(&tab_id) {
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
|
tab.is_dirty = st.is_dirty;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ScriptEditor(msg) => {
|
|
if let Some(tab_id) = self.active_tab.clone() {
|
|
if 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 => {
|
|
self.notify(ToastLevel::Info, &t(self.ui_locale, "editor.scriptRunNotYet"));
|
|
}
|
|
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) {
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
|
tab.is_dirty = st.is_dirty;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::Tags(msg) => {
|
|
self.handle_tags_msg(msg)
|
|
}
|
|
Message::Settings(msg) => {
|
|
self.handle_settings_msg(msg)
|
|
}
|
|
Message::TranslationEditor(msg) => {
|
|
if let Some(tab_id) = self.active_tab.clone() {
|
|
if let Some(state) = self.translation_editors.get_mut(&tab_id) {
|
|
match msg {
|
|
TranslationEditorMsg::TitleChanged(s) => { state.title = s; state.is_dirty = true; }
|
|
TranslationEditorMsg::ExcerptChanged(s) => { state.excerpt = s; state.is_dirty = true; }
|
|
TranslationEditorMsg::ContentChanged(s) => { state.content = s; state.is_dirty = true; }
|
|
TranslationEditorMsg::Save | TranslationEditorMsg::Publish | TranslationEditorMsg::Delete => {
|
|
// Translation save/publish/delete will be wired to engine in future
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
// ── Editor data loading ──
|
|
Message::PostLoaded(result) => {
|
|
match result {
|
|
Ok(post) => {
|
|
let mut translations = self.db.as_ref()
|
|
.and_then(|db| bds_core::db::queries::post_translation::list_post_translations_by_post(
|
|
db.conn(), &post.id,
|
|
).ok())
|
|
.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) {
|
|
if 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 state = PostEditorState::from_post(&post, &self.blog_languages, &translations, outlinks, backlinks);
|
|
self.post_editors.insert(post.id.clone(), state);
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &e),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::MediaLoaded(result) => {
|
|
match result {
|
|
Ok(media) => {
|
|
let translations = self.db.as_ref()
|
|
.and_then(|db| bds_core::db::queries::media_translation::list_media_translations_by_media(
|
|
db.conn(), &media.id,
|
|
).ok())
|
|
.unwrap_or_default();
|
|
let state = MediaEditorState::from_media(&media, &self.blog_languages, &translations);
|
|
self.media_editors.insert(media.id.clone(), state);
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &e),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::TemplateLoaded(result) => {
|
|
match result {
|
|
Ok(template) => {
|
|
let state = TemplateEditorState::from_template(&template);
|
|
self.template_editors.insert(template.id.clone(), state);
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &e),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ScriptLoaded(result) => {
|
|
match result {
|
|
Ok(script) => {
|
|
let state = ScriptEditorState::from_script(&script);
|
|
self.script_editors.insert(script.id.clone(), state);
|
|
}
|
|
Err(e) => self.notify(ToastLevel::Error, &e),
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
Message::Noop => Task::none(),
|
|
Message::InitMenuBar => {
|
|
#[cfg(target_os = "macos")]
|
|
menu::init_menu_for_nsapp(&self._menu_bar);
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn view(&self) -> Element<'_, Message> {
|
|
let active_name = self.active_project.as_ref().map(|p| p.name.as_str());
|
|
let active_post_filter = match self.sidebar_view {
|
|
SidebarView::Pages => &self.page_filter,
|
|
_ => &self.post_filter,
|
|
};
|
|
|
|
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.output_entries,
|
|
&self.sidebar_posts,
|
|
&self.sidebar_media,
|
|
&self.sidebar_scripts,
|
|
&self.sidebar_templates,
|
|
active_post_filter,
|
|
&self.media_filter,
|
|
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.as_ref(),
|
|
self.data_dir.as_deref(),
|
|
&self.post_editors,
|
|
&self.media_editors,
|
|
&self.template_editors,
|
|
&self.script_editors,
|
|
self.tags_view_state.as_ref(),
|
|
self.settings_state.as_ref(),
|
|
self.dashboard_state.as_ref(),
|
|
)
|
|
}
|
|
|
|
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 toast_tick = if !self.toasts.is_empty() {
|
|
iced::time::every(std::time::Duration::from_millis(250))
|
|
.map(|_| Message::ExpireToasts)
|
|
} else {
|
|
Subscription::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()
|
|
};
|
|
|
|
Subscription::batch([menu_sub, task_tick, toast_tick, drag_sub])
|
|
}
|
|
|
|
// ── Private helpers ──
|
|
|
|
fn dispatch_menu_action(&mut self, action: MenuAction) -> Task<Message> {
|
|
match action {
|
|
// File
|
|
MenuAction::NewPost => {
|
|
if let (Some(db), Some(project), Some(data_dir)) =
|
|
(&self.db, &self.active_project, &self.data_dir)
|
|
{
|
|
let display_title = t(self.ui_locale, "post.untitled");
|
|
match engine::post::create_post(
|
|
db.conn(),
|
|
data_dir,
|
|
&project.id,
|
|
"",
|
|
Some(""),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
None,
|
|
None,
|
|
None,
|
|
) {
|
|
Ok(post) => {
|
|
let tab = Tab {
|
|
id: post.id.clone(),
|
|
tab_type: TabType::Post,
|
|
title: display_title.to_string(),
|
|
is_transient: true,
|
|
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());
|
|
}
|
|
}
|
|
Err(e) => {
|
|
self.add_output(&format!("Failed to create post: {e}"));
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
MenuAction::ImportMedia => crate::platform::dialog::pick_media_files(
|
|
t(self.ui_locale, "dialog.importMedia"),
|
|
t(self.ui_locale, "dialog.imageFilter"),
|
|
),
|
|
MenuAction::Save => Task::none(), // Disabled in M2
|
|
MenuAction::OpenInBrowser => Task::none(),
|
|
MenuAction::OpenDataFolder => {
|
|
if let Some(ref dir) = self.data_dir {
|
|
let _ = open::that(dir);
|
|
}
|
|
Task::none()
|
|
}
|
|
// Edit
|
|
MenuAction::Find => Task::none(),
|
|
MenuAction::Replace => 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 => Task::none(), // Disabled in M2
|
|
MenuAction::PreviewPost => Task::none(), // Disabled in M2
|
|
MenuAction::EditMenu => {
|
|
self.open_singleton_tab(TabType::MenuEditor, "tabBar.menuEditor");
|
|
Task::none()
|
|
}
|
|
MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase),
|
|
MenuAction::ReindexText => Task::done(Message::ReindexText),
|
|
MenuAction::MetadataDiff => Task::done(Message::RunMetadataDiff),
|
|
MenuAction::RegenerateCalendar => Task::done(Message::RegenerateCalendar),
|
|
MenuAction::ValidateTranslations => Task::done(Message::ValidateTranslations),
|
|
MenuAction::FillMissingTranslations => {
|
|
if self.offline_mode {
|
|
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.fillMissingTranslationsOffline"));
|
|
} else {
|
|
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.fillMissingTranslationsNoAi"));
|
|
}
|
|
Task::none()
|
|
}
|
|
MenuAction::GenerateSitemap => Task::done(Message::GenerateSite),
|
|
MenuAction::ValidateSite => {
|
|
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
|
|
Task::none()
|
|
}
|
|
MenuAction::UploadSite => {
|
|
if self.offline_mode {
|
|
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.uploadOffline"));
|
|
} 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().map_or(false, |h| !h.is_empty())
|
|
&& p.ssh_user.as_ref().map_or(false, |u| !u.is_empty())
|
|
})
|
|
.unwrap_or(false);
|
|
if !has_creds {
|
|
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.uploadMissingCredentials"));
|
|
} else {
|
|
self.notify(ToastLevel::Info, &t(self.ui_locale, "engine.uploadStarted"));
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
// 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");
|
|
Task::none()
|
|
}
|
|
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 refresh_task_snapshots(&mut self) {
|
|
self.task_snapshots = self
|
|
.task_manager
|
|
.snapshots()
|
|
.into_iter()
|
|
.map(|(id, label, status, progress, message)| {
|
|
let status_str = match &status {
|
|
TaskStatus::Pending => "pending".to_string(),
|
|
TaskStatus::Running => "running".to_string(),
|
|
TaskStatus::Completed => "completed".to_string(),
|
|
TaskStatus::Failed(e) => format!("failed: {e}"),
|
|
TaskStatus::Cancelled => "cancelled".to_string(),
|
|
};
|
|
TaskSnapshot {
|
|
id,
|
|
label,
|
|
status: status_str,
|
|
progress,
|
|
message,
|
|
}
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// Spawn a blocking engine operation on a background thread via TaskManager.
|
|
///
|
|
/// Returns `Task::none()` if no active project/db/data_dir.
|
|
/// Otherwise registers the task, logs the start message, and returns an
|
|
/// async `Task` that opens a fresh DB connection on a worker thread.
|
|
///
|
|
/// The closure receives `(db_path, project_id, data_dir, task_manager, task_id)`.
|
|
/// Use `task_manager.report_progress(task_id, percent, message)` for live updates.
|
|
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,
|
|
{
|
|
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 = self.task_manager.submit(&label);
|
|
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 || 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,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn refresh_counts(&mut self) {
|
|
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();
|
|
|
|
// Read pico theme from project metadata for status bar badge
|
|
if let Some(ref data_dir) = self.data_dir {
|
|
if let Ok(meta) = engine::meta::read_project_json(data_dir) {
|
|
if let Some(theme) = meta.pico_theme {
|
|
self.theme_badge = theme;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Refresh sidebar data with current filters (separate borrows to avoid borrow conflict)
|
|
self.refresh_sidebar_posts();
|
|
self.refresh_sidebar_media();
|
|
self.refresh_filter_metadata();
|
|
}
|
|
|
|
/// Refresh only sidebar posts using current filter state.
|
|
fn refresh_sidebar_posts(&mut self) {
|
|
if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
|
|
use bds_core::db::queries::post::{PostFilterParams, list_posts_filtered};
|
|
|
|
let filter = match self.sidebar_view {
|
|
SidebarView::Pages => &self.page_filter,
|
|
_ => &self.post_filter,
|
|
};
|
|
let is_pages = self.sidebar_view == SidebarView::Pages;
|
|
|
|
let params = PostFilterParams {
|
|
search_query: filter.search_query.clone(),
|
|
year: filter.calendar.selected_year,
|
|
month: filter.calendar.selected_month,
|
|
tags: filter.tag_filter.clone(),
|
|
categories: filter.category_filter.clone(),
|
|
exclude_pages: !is_pages,
|
|
pages_only: is_pages,
|
|
};
|
|
|
|
self.sidebar_posts = list_posts_filtered(
|
|
db.conn(), &project.id, ¶ms, 500, 0,
|
|
).unwrap_or_default();
|
|
}
|
|
}
|
|
|
|
/// Refresh only sidebar media using current filter state.
|
|
fn refresh_sidebar_media(&mut self) {
|
|
if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
|
|
use bds_core::db::queries::media::{MediaFilterParams, list_media_filtered};
|
|
|
|
let params = MediaFilterParams {
|
|
search_query: self.media_filter.search_query.clone(),
|
|
year: self.media_filter.calendar.selected_year,
|
|
month: self.media_filter.calendar.selected_month,
|
|
tags: self.media_filter.tag_filter.clone(),
|
|
};
|
|
|
|
self.sidebar_media = list_media_filtered(
|
|
db.conn(), &project.id, ¶ms, 500, 0,
|
|
).unwrap_or_default();
|
|
}
|
|
}
|
|
|
|
/// Refresh available tags, categories, and calendar data for filter widgets.
|
|
fn refresh_filter_metadata(&mut self) {
|
|
if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
|
|
use bds_core::db::queries::post;
|
|
use bds_core::db::queries::media;
|
|
|
|
// 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.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.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.
|
|
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
|
|
}
|
|
|
|
/// 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,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// 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 has_project = self.active_project.is_some();
|
|
let has_tab = self.active_tab.is_some();
|
|
|
|
// File group: need active project for most, need open tab for Save
|
|
self.menu_registry.set_enabled(MenuAction::NewPost, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::ImportMedia, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::Save, has_tab);
|
|
self.menu_registry.set_enabled(MenuAction::OpenInBrowser, has_tab);
|
|
self.menu_registry.set_enabled(MenuAction::OpenDataFolder, has_project);
|
|
|
|
// Edit: Find/Replace need an open tab
|
|
self.menu_registry.set_enabled(MenuAction::Find, has_tab);
|
|
self.menu_registry.set_enabled(MenuAction::Replace, has_tab);
|
|
|
|
// Blog group: need active project
|
|
self.menu_registry.set_enabled(MenuAction::PublishSelected, has_project && has_tab);
|
|
self.menu_registry.set_enabled(MenuAction::PreviewPost, has_project && has_tab);
|
|
self.menu_registry.set_enabled(MenuAction::EditMenu, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::RebuildDatabase, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::ReindexText, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::MetadataDiff, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::RegenerateCalendar, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::ValidateTranslations, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::FillMissingTranslations, has_project && !self.offline_mode);
|
|
self.menu_registry.set_enabled(MenuAction::GenerateSitemap, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::ValidateSite, has_project);
|
|
self.menu_registry.set_enabled(MenuAction::UploadSite, has_project && !self.offline_mode);
|
|
}
|
|
|
|
// ── Editor save/publish helpers ──
|
|
|
|
fn save_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
|
let Some(state) = self.post_editors.get(post_id) else { 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 excerpt_val = if state.excerpt.is_empty() { None } else { Some(state.excerpt.as_str()) };
|
|
let author_val = if state.author.is_empty() { None } else { Some(state.author.as_str()) };
|
|
let tmpl_val = if state.template_slug.is_empty() { None } else { Some(state.template_slug.as_str()) };
|
|
|
|
match engine::post::update_post(
|
|
db.conn(),
|
|
data_dir,
|
|
&state.post_id,
|
|
Some(&state.title),
|
|
Some(&state.slug),
|
|
Some(excerpt_val),
|
|
Some(&state.content),
|
|
None, // tags
|
|
None, // categories
|
|
Some(author_val),
|
|
None, // language
|
|
Some(tmpl_val),
|
|
Some(state.do_not_translate),
|
|
) {
|
|
Ok(post) => {
|
|
let s = self.post_editors.get_mut(post_id).unwrap();
|
|
s.is_dirty = false;
|
|
s.updated_at = post.updated_at;
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == post.id) {
|
|
tab.is_dirty = false;
|
|
if !post.title.is_empty() {
|
|
tab.title = post.title.clone();
|
|
}
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => {
|
|
self.notify(ToastLevel::Error, &format!("Save failed: {e}"));
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn publish_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
|
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;
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.published"));
|
|
}
|
|
Err(e) => {
|
|
self.notify(ToastLevel::Error, &format!("Publish failed: {e}"));
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn save_media_editor(&mut self, media_id: &str) -> Task<Message> {
|
|
let Some(state) = self.media_editors.get(media_id) else { return Task::none() };
|
|
let Some(ref db) = self.db else { return Task::none() };
|
|
|
|
// Build a Media struct from editor state for the update call
|
|
let media = bds_core::model::Media {
|
|
id: state.media_id.clone(),
|
|
project_id: self.active_project.as_ref().map(|p| p.id.clone()).unwrap_or_default(),
|
|
filename: state.filename.clone(),
|
|
original_name: state.original_name.clone(),
|
|
mime_type: state.mime_type.clone(),
|
|
size: state.size,
|
|
width: state.width,
|
|
height: state.height,
|
|
title: Some(state.title.clone()),
|
|
alt: Some(state.alt.clone()),
|
|
caption: Some(state.caption.clone()),
|
|
author: Some(state.author.clone()),
|
|
language: if state.language.is_empty() { None } else { Some(state.language.clone()) },
|
|
file_path: state.file_path.clone(),
|
|
sidecar_path: String::new(),
|
|
checksum: None,
|
|
tags: state.tags.clone(),
|
|
created_at: state.created_at,
|
|
updated_at: state.updated_at,
|
|
};
|
|
|
|
match bds_core::db::queries::media::update_media(db.conn(), &media) {
|
|
Ok(()) => {
|
|
let s = self.media_editors.get_mut(media_id).unwrap();
|
|
s.is_dirty = false;
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == media.id) {
|
|
tab.is_dirty = false;
|
|
}
|
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
|
}
|
|
Err(e) => {
|
|
self.notify(ToastLevel::Error, &format!("Save failed: {e}"));
|
|
}
|
|
}
|
|
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 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),
|
|
) {
|
|
Ok(tmpl) => {
|
|
let s = self.template_editors.get_mut(template_id).unwrap();
|
|
s.is_dirty = false;
|
|
s.updated_at = tmpl.updated_at;
|
|
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) => {
|
|
self.notify(ToastLevel::Error, &format!("Save failed: {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 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),
|
|
) {
|
|
Ok(script) => {
|
|
let s = self.script_editors.get_mut(script_id).unwrap();
|
|
s.is_dirty = false;
|
|
s.updated_at = script.updated_at;
|
|
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) => {
|
|
self.notify(ToastLevel::Error, &format!("Save failed: {e}"));
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn handle_tags_msg(&mut self, msg: TagsMsg) -> Task<Message> {
|
|
// Ensure tags view state exists
|
|
if self.tags_view_state.is_none() {
|
|
let (tags, counts) = if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
|
|
let tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id)
|
|
.unwrap_or_default();
|
|
let mut counts = std::collections::HashMap::new();
|
|
for post in &posts {
|
|
for tag_name in &post.tags {
|
|
*counts.entry(tag_name.to_lowercase()).or_insert(0usize) += 1;
|
|
}
|
|
}
|
|
(tags, counts)
|
|
} else {
|
|
(Vec::new(), std::collections::HashMap::new())
|
|
};
|
|
self.tags_view_state = Some(TagsViewState::new(tags, counts));
|
|
}
|
|
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::SelectTag(id) => {
|
|
if let Some(tag) = state.tags.iter().find(|t| t.id == id) {
|
|
state.editing_tag = Some(tags_view::EditingTag {
|
|
id: tag.id.clone(),
|
|
name: tag.name.clone(),
|
|
color: tag.color.clone().unwrap_or_default(),
|
|
template_slug: tag.post_template_slug.clone().unwrap_or_default(),
|
|
});
|
|
}
|
|
}
|
|
TagsMsg::CreateTag(_name) => {
|
|
// Tag creation will dispatch to engine
|
|
}
|
|
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(s) => { if let Some(ref mut e) = state.editing_tag { e.template_slug = s; } }
|
|
TagsMsg::SaveTag => { /* will wire to engine */ }
|
|
TagsMsg::DeleteTag(id) => {
|
|
let name = state.tags.iter().find(|t| t.id == id).map(|t| t.name.clone()).unwrap_or_default();
|
|
return Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete {
|
|
entity_name: name,
|
|
references: Vec::new(),
|
|
on_confirm: modal::ConfirmAction::DeletePost(id), // TODO: add DeleteTag variant
|
|
}));
|
|
}
|
|
TagsMsg::SetMergeSource(s) => { state.merge_source = Some(s); }
|
|
TagsMsg::SetMergeTarget(s) => { state.merge_target = Some(s); }
|
|
TagsMsg::MergeTags => {
|
|
if let (Some(source), Some(target)) = (&state.merge_source, &state.merge_target) {
|
|
return Task::done(Message::ShowModal(modal::ModalState::Confirm {
|
|
title: t(self.ui_locale, "tags.mergeTags"),
|
|
message: t(self.ui_locale, "tags.mergeConfirm"),
|
|
on_confirm: modal::ConfirmAction::MergeTags {
|
|
source: source.clone(),
|
|
target: target.clone(),
|
|
},
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
fn handle_settings_msg(&mut self, msg: SettingsMsg) -> Task<Message> {
|
|
// Ensure settings state exists
|
|
if self.settings_state.is_none() {
|
|
let mut state = SettingsViewState::default();
|
|
if let Some(ref 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(ref 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.default_author = meta.default_author.unwrap_or_default();
|
|
state.max_posts_per_page = meta.max_posts_per_page.to_string();
|
|
}
|
|
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 = format!("{:?}", pub_prefs.ssh_mode).to_lowercase();
|
|
}
|
|
}
|
|
state.offline_mode = self.offline_mode;
|
|
self.settings_state = Some(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::DefaultAuthorChanged(s) => { state.default_author = s; }
|
|
SettingsMsg::MaxPostsPerPageChanged(s) => { state.max_posts_per_page = s; }
|
|
SettingsMsg::SaveProject => { /* Project save will be wired to engine */ }
|
|
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 => { /* Editor prefs save will be wired */ }
|
|
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 => { /* Publishing save will be wired */ }
|
|
SettingsMsg::ClearPublishing => {
|
|
state.ssh_host.clear();
|
|
state.ssh_username.clear();
|
|
state.ssh_remote_path.clear();
|
|
}
|
|
SettingsMsg::OfflineModeChanged(b) => {
|
|
state.offline_mode = b;
|
|
return Task::done(Message::SetOfflineMode(b));
|
|
}
|
|
SettingsMsg::SystemPromptAction(action) => {
|
|
state.system_prompt.perform(action);
|
|
}
|
|
SettingsMsg::SaveSystemPrompt => { /* System prompt save will be wired */ }
|
|
SettingsMsg::ResetSystemPrompt => {
|
|
state.system_prompt = iced::widget::text_editor::Content::new();
|
|
}
|
|
SettingsMsg::RebuildPosts => { return Task::done(Message::RebuildDatabase); }
|
|
SettingsMsg::RebuildMedia => { return Task::done(Message::RebuildDatabase); }
|
|
SettingsMsg::RebuildScripts => { return Task::done(Message::RebuildDatabase); }
|
|
SettingsMsg::RebuildTemplates => { return Task::done(Message::RebuildDatabase); }
|
|
SettingsMsg::RebuildLinks => {
|
|
if let (Some(db), Some(data_dir), Some(project)) =
|
|
(&self.db, &self.data_dir, &self.active_project)
|
|
{
|
|
match bds_core::engine::post::rebuild_all_links(
|
|
db.conn(), data_dir, &project.id,
|
|
) {
|
|
Ok(count) => {
|
|
self.notify(
|
|
ToastLevel::Info,
|
|
&format!("Rebuilt {} links", count),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
self.notify(
|
|
ToastLevel::Error,
|
|
&format!("Failed to rebuild links: {e}"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
SettingsMsg::RegenerateThumbnails => {
|
|
self.notify(ToastLevel::Info, &t(self.ui_locale, "settings.regeneratingThumbnails"));
|
|
}
|
|
SettingsMsg::OpenDataFolder => {
|
|
if let Some(ref dir) = self.data_dir {
|
|
let _ = open::that(dir);
|
|
}
|
|
}
|
|
SettingsMsg::FocusSection(section) => {
|
|
// Expand the target section, collapse all others
|
|
use crate::views::settings_view::SettingsSection;
|
|
let all_others: Vec<SettingsSection> = SettingsSection::all()
|
|
.iter()
|
|
.filter(|s| **s != section)
|
|
.cloned()
|
|
.collect();
|
|
state.collapsed = all_others;
|
|
// Clear search filter to ensure section is visible
|
|
state.search_query.clear();
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
|
|
/// 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() {
|
|
if 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) {
|
|
if 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) {
|
|
if 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);
|
|
self.post_editors.insert(
|
|
post.id.clone(),
|
|
PostEditorState::from_post(&post, &self.blog_languages, &translations, outlinks, backlinks),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
self.notify(ToastLevel::Error, &format!("Failed to load post: {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();
|
|
self.media_editors.insert(media.id.clone(), MediaEditorState::from_media(&media, &self.blog_languages, &translations));
|
|
}
|
|
Err(e) => {
|
|
self.notify(ToastLevel::Error, &format!("Failed to load 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() {
|
|
if 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) {
|
|
if 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(ToastLevel::Error, &format!("Failed to load template: {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() {
|
|
if 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) {
|
|
if 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(ToastLevel::Error, &format!("Failed to load script: {e}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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();
|
|
// 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));
|
|
}
|
|
}
|
|
TabType::Settings => {
|
|
if self.settings_state.is_none() {
|
|
let mut state = SettingsViewState::default();
|
|
if let Some(ref 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(ref 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.default_author = meta.default_author.unwrap_or_default();
|
|
state.max_posts_per_page = meta.max_posts_per_page.to_string();
|
|
}
|
|
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 = format!("{:?}", pub_prefs.ssh_mode).to_lowercase();
|
|
}
|
|
}
|
|
state.offline_mode = self.offline_mode;
|
|
self.settings_state = Some(state);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|