985 lines
39 KiB
Rust
985 lines
39 KiB
Rust
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};
|
|
|
|
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::tabs::{self, Tab, TabType};
|
|
use crate::state::toast::{Toast, ToastLevel};
|
|
use crate::views::workspace;
|
|
|
|
// ───────────────────────────────────────────────────────────
|
|
// Message
|
|
// ───────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Message {
|
|
// Menu
|
|
MenuEvent(muda::MenuId),
|
|
|
|
// Navigation
|
|
SetActiveView(SidebarView),
|
|
ToggleSidebar,
|
|
TogglePanel,
|
|
|
|
// Tabs
|
|
OpenTab(Tab),
|
|
CloseTab(String),
|
|
SelectTab(String),
|
|
PinTab(String),
|
|
|
|
// 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,
|
|
|
|
// Blog actions (dispatched to engine)
|
|
RebuildDatabase,
|
|
ReindexText,
|
|
RegenerateCalendar,
|
|
ValidateTranslations,
|
|
GenerateSite,
|
|
RunMetadataDiff,
|
|
EngineTaskDone { task_id: TaskId, label: String, result: Result<String, 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>,
|
|
|
|
// Navigation
|
|
sidebar_view: SidebarView,
|
|
sidebar_visible: 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,
|
|
|
|
// Flags
|
|
offline_mode: bool,
|
|
locale_dropdown_open: bool,
|
|
project_dropdown_open: bool,
|
|
theme_badge: String,
|
|
|
|
// Toasts
|
|
toasts: Vec<Toast>,
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────
|
|
// 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_view: SidebarView::Posts,
|
|
sidebar_visible: true,
|
|
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,
|
|
offline_mode: false,
|
|
locale_dropdown_open: false,
|
|
project_dropdown_open: false,
|
|
theme_badge: String::from("pico"),
|
|
toasts: Vec::new(),
|
|
},
|
|
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);
|
|
self.sidebar_view = new_view;
|
|
self.sidebar_visible = new_visible;
|
|
Task::none()
|
|
}
|
|
Message::ToggleSidebar => {
|
|
self.sidebar_visible = !self.sidebar_visible;
|
|
Task::none()
|
|
}
|
|
Message::TogglePanel => {
|
|
self.panel_visible = !self.panel_visible;
|
|
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());
|
|
}
|
|
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()
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
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);
|
|
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);
|
|
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, "Translation Validation");
|
|
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::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, "Metadata Diff");
|
|
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()
|
|
}
|
|
|
|
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());
|
|
|
|
workspace::view(
|
|
self.sidebar_view,
|
|
self.sidebar_visible,
|
|
&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,
|
|
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,
|
|
)
|
|
}
|
|
|
|
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()
|
|
};
|
|
|
|
Subscription::batch([menu_sub, task_tick, toast_tick])
|
|
}
|
|
|
|
// ── 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 title = t(self.ui_locale, "post.untitled");
|
|
match engine::post::create_post(
|
|
db.conn(),
|
|
data_dir,
|
|
&project.id,
|
|
&title,
|
|
Some(""),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
None,
|
|
None,
|
|
None,
|
|
) {
|
|
Ok(post) => {
|
|
let tab = Tab {
|
|
id: post.id.clone(),
|
|
tab_type: TabType::Post,
|
|
title: post.title.clone(),
|
|
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, "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, "Menu Editor");
|
|
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, "Site Validation");
|
|
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, "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, title: &str) {
|
|
let tab = Tab {
|
|
id: tab_type.singleton_id().to_string(),
|
|
tab_type,
|
|
title: title.to_string(),
|
|
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());
|
|
}
|
|
}
|
|
|
|
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::Queued => "queued".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_posts = bds_core::db::queries::post::list_posts_by_project_limited(
|
|
db.conn(),
|
|
&project.id,
|
|
500,
|
|
0,
|
|
)
|
|
.unwrap_or_default();
|
|
self.sidebar_media = bds_core::db::queries::media::list_media_by_project_limited(
|
|
db.conn(),
|
|
&project.id,
|
|
500,
|
|
0,
|
|
)
|
|
.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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|