feat: implement the bDS2-compatible core Lua API
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use base64::Engine as _;
|
||||
use bds_core::db::DbQueryError as SqlError;
|
||||
@@ -119,7 +119,10 @@ pub enum Message {
|
||||
// macOS lifecycle
|
||||
FileOpenRequested(PathBuf),
|
||||
UrlOpenRequested(String),
|
||||
BlogmarkImported(Result<engine::blogmark::BlogmarkImportResult, String>),
|
||||
BlogmarkImported {
|
||||
task_id: TaskId,
|
||||
result: Result<engine::blogmark::BlogmarkImportResult, String>,
|
||||
},
|
||||
MainWindowLoaded(Option<window::Id>),
|
||||
EmbeddedPreviewReady(Result<(), String>),
|
||||
|
||||
@@ -738,6 +741,7 @@ pub struct BdsApp {
|
||||
|
||||
// Tasks
|
||||
task_manager: Arc<TaskManager>,
|
||||
script_menu_actions: Arc<Mutex<Vec<MenuAction>>>,
|
||||
task_snapshots: Vec<TaskSnapshot>,
|
||||
collapsed_task_groups: HashSet<String>,
|
||||
output_entries: Vec<OutputEntry>,
|
||||
@@ -891,6 +895,7 @@ impl BdsApp {
|
||||
panel_visible: false,
|
||||
panel_tab: PanelTab::Tasks,
|
||||
task_manager: Arc::new(TaskManager::default()),
|
||||
script_menu_actions: Arc::new(Mutex::new(Vec::new())),
|
||||
task_snapshots: Vec::new(),
|
||||
collapsed_task_groups: HashSet::new(),
|
||||
output_entries: Vec::new(),
|
||||
@@ -956,6 +961,7 @@ impl BdsApp {
|
||||
panel_visible: false,
|
||||
panel_tab: PanelTab::Tasks,
|
||||
task_manager: Arc::new(TaskManager::default()),
|
||||
script_menu_actions: Arc::new(Mutex::new(Vec::new())),
|
||||
task_snapshots: Vec::new(),
|
||||
collapsed_task_groups: HashSet::new(),
|
||||
output_entries: Vec::new(),
|
||||
@@ -1498,7 +1504,12 @@ impl BdsApp {
|
||||
if !self.search_index_rebuild_running {
|
||||
self.auto_save_due_post_editors();
|
||||
}
|
||||
Task::none()
|
||||
let actions = std::mem::take(&mut *self.script_menu_actions.lock().unwrap());
|
||||
Task::batch(
|
||||
actions
|
||||
.into_iter()
|
||||
.map(|action| self.dispatch_menu_action(action)),
|
||||
)
|
||||
}
|
||||
Message::CancelTask(task_id) => {
|
||||
self.task_manager.cancel(task_id);
|
||||
@@ -1593,26 +1604,51 @@ impl BdsApp {
|
||||
}
|
||||
let db_path = self.db_path.clone();
|
||||
let project_id = target.id;
|
||||
let task_manager = Arc::clone(&self.task_manager);
|
||||
let label = t(self.ui_locale, "blogmark.importing");
|
||||
let task_id = task_manager.submit(&label);
|
||||
let offline_mode = self.offline_mode;
|
||||
let app_handler = crate::platform::script_host::handler(
|
||||
Arc::clone(&self.script_menu_actions),
|
||||
t(self.ui_locale, "dialog.selectFolder"),
|
||||
);
|
||||
self.refresh_task_snapshots();
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if !task_manager.wait_until_runnable(task_id) {
|
||||
return Err("cancelled".to_string());
|
||||
}
|
||||
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
||||
engine::blogmark::receive_deep_link(
|
||||
let host =
|
||||
bds_core::scripting::CoreHost::new(db_path, &project_id, &data_dir)
|
||||
.with_task(Arc::clone(&task_manager), task_id)
|
||||
.with_offline_mode(offline_mode)
|
||||
.with_app_handler(app_handler);
|
||||
let control = task_manager
|
||||
.cancellation_flag(task_id)
|
||||
.map(bds_core::scripting::ExecutionControl::from_cancelled)
|
||||
.unwrap_or_default();
|
||||
engine::blogmark::receive_deep_link_with_host(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
&url,
|
||||
&control,
|
||||
Arc::new(host),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
||||
},
|
||||
Message::BlogmarkImported,
|
||||
move |result| Message::BlogmarkImported { task_id, result },
|
||||
)
|
||||
}
|
||||
Message::BlogmarkImported(result) => match result {
|
||||
Message::BlogmarkImported { task_id, result } => match result {
|
||||
Ok(result) => {
|
||||
self.task_manager.complete(task_id);
|
||||
self.refresh_task_snapshots();
|
||||
for message in result.toasts {
|
||||
self.notify(ToastLevel::Info, &message);
|
||||
}
|
||||
@@ -1635,6 +1671,10 @@ impl BdsApp {
|
||||
self.refresh_counts()
|
||||
}
|
||||
Err(error) => {
|
||||
if self.task_manager.status(task_id) != Some(TaskStatus::Cancelled) {
|
||||
self.task_manager.fail(task_id, error.clone());
|
||||
}
|
||||
self.refresh_task_snapshots();
|
||||
self.notify(
|
||||
ToastLevel::Error,
|
||||
&tw(self.ui_locale, "blogmark.failed", &[("error", &error)]),
|
||||
|
||||
@@ -708,9 +708,14 @@ impl BdsApp {
|
||||
}
|
||||
}
|
||||
if let Some((source, entrypoint, kind)) = run_request {
|
||||
let offline_mode = self.offline_mode;
|
||||
let app_handler = crate::platform::script_host::handler(
|
||||
Arc::clone(&self.script_menu_actions),
|
||||
t(self.ui_locale, "dialog.selectFolder"),
|
||||
);
|
||||
return self.spawn_engine_task(
|
||||
"engine.runScript",
|
||||
move |_db_path, _project_id, _data_dir, task_manager, task_id| {
|
||||
move |db_path, project_id, data_dir, task_manager, task_id| {
|
||||
let execution_kind = match kind {
|
||||
bds_core::model::ScriptKind::Macro => {
|
||||
bds_core::scripting::ExecutionKind::Macro
|
||||
@@ -722,19 +727,22 @@ impl BdsApp {
|
||||
bds_core::scripting::ExecutionKind::Transform
|
||||
}
|
||||
};
|
||||
let result = bds_core::scripting::execute(
|
||||
let host = bds_core::scripting::CoreHost::new(db_path, project_id, data_dir)
|
||||
.with_task(Arc::clone(&task_manager), task_id)
|
||||
.with_offline_mode(offline_mode)
|
||||
.with_app_handler(Arc::clone(&app_handler));
|
||||
let control = task_manager
|
||||
.cancellation_flag(task_id)
|
||||
.map(bds_core::scripting::ExecutionControl::from_cancelled)
|
||||
.unwrap_or_default();
|
||||
let result = bds_core::scripting::execute_with_host(
|
||||
&source,
|
||||
&entrypoint,
|
||||
&serde_json::json!({}),
|
||||
execution_kind,
|
||||
&bds_core::scripting::ExecutionControl::default(),
|
||||
&control,
|
||||
Arc::new(host),
|
||||
)?;
|
||||
for progress in &result.progress {
|
||||
let percent = progress.total.and_then(|total| {
|
||||
(total > 0.0).then_some((progress.current / total) as f32)
|
||||
});
|
||||
task_manager.report_progress(task_id, percent, progress.message.clone());
|
||||
}
|
||||
let mut lines = result.output;
|
||||
lines.extend(
|
||||
result
|
||||
|
||||
@@ -82,6 +82,40 @@ impl MenuAction {
|
||||
MenuAction::ReportIssue,
|
||||
];
|
||||
|
||||
pub fn from_script_name(name: &str) -> Option<Self> {
|
||||
Some(match name {
|
||||
"new_post" => Self::NewPost,
|
||||
"import_media" => Self::ImportMedia,
|
||||
"save" => Self::Save,
|
||||
"open_in_browser" => Self::OpenInBrowser,
|
||||
"open_data_folder" => Self::OpenDataFolder,
|
||||
"find" => Self::Find,
|
||||
"replace" => Self::Replace,
|
||||
"edit_preferences" => Self::EditPreferences,
|
||||
"view_posts" => Self::ViewPosts,
|
||||
"view_media" => Self::ViewMedia,
|
||||
"toggle_sidebar" => Self::ToggleSidebar,
|
||||
"toggle_panel" => Self::TogglePanel,
|
||||
"publish_selected" => Self::PublishSelected,
|
||||
"preview_post" => Self::PreviewPost,
|
||||
"edit_menu" => Self::EditMenu,
|
||||
"rebuild_database" => Self::RebuildDatabase,
|
||||
"reindex_text" => Self::ReindexText,
|
||||
"metadata_diff" => Self::MetadataDiff,
|
||||
"regenerate_calendar" => Self::RegenerateCalendar,
|
||||
"validate_translations" => Self::ValidateTranslations,
|
||||
"fill_missing_translations" => Self::FillMissingTranslations,
|
||||
"generate_sitemap" | "force_render_site" => Self::GenerateSitemap,
|
||||
"validate_site" => Self::ValidateSite,
|
||||
"upload_site" => Self::UploadSite,
|
||||
"about" => Self::About,
|
||||
"open_documentation" => Self::OpenDocumentation,
|
||||
"view_on_github" => Self::ViewOnGitHub,
|
||||
"report_issue" => Self::ReportIssue,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the i18n key for this action's menu label.
|
||||
pub fn i18n_key(self) -> &'static str {
|
||||
match self {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod dialog;
|
||||
pub mod menu;
|
||||
pub mod script_host;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
|
||||
130
crates/bds-ui/src/platform/script_host.rs
Normal file
130
crates/bds-ui/src/platform/script_host.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use bds_core::scripting::AppHostHandler;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::menu::MenuAction;
|
||||
|
||||
pub fn handler(
|
||||
menu_actions: Arc<Mutex<Vec<MenuAction>>>,
|
||||
select_folder_title: String,
|
||||
) -> Arc<AppHostHandler> {
|
||||
Arc::new(move |method, args| match method {
|
||||
"copy_to_clipboard" => Ok(copy_to_clipboard(string_arg(args, 0)?).into()),
|
||||
"get_title_bar_metrics" => Ok(title_bar_metrics()),
|
||||
"notify_renderer_ready" => Ok(Value::Bool(true)),
|
||||
"open_folder" => Ok(open::that(string_arg(args, 0)?)
|
||||
.map(|()| Value::String(String::new()))
|
||||
.unwrap_or_else(|error| Value::String(error.to_string()))),
|
||||
"select_folder" => Ok(rfd::FileDialog::new()
|
||||
.set_title(optional_string_arg(args, 0).unwrap_or(select_folder_title.as_str()))
|
||||
.pick_folder()
|
||||
.map(|path| Value::String(path.to_string_lossy().into_owned()))
|
||||
.unwrap_or(Value::Null)),
|
||||
"set_preview_post_target" => {
|
||||
*preview_post_target().lock().unwrap() =
|
||||
optional_string_arg(args, 0).map(str::to_owned);
|
||||
Ok(Value::Bool(true))
|
||||
}
|
||||
"show_item_in_folder" => {
|
||||
let _ = show_item_in_folder(Path::new(string_arg(args, 0)?));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"trigger_menu_action" => {
|
||||
if let Some(action) = MenuAction::from_script_name(string_arg(args, 0)?) {
|
||||
menu_actions.lock().unwrap().push(action);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
_ => Err(format!("unknown desktop shell capability: {method}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn string_arg(args: &[Value], index: usize) -> Result<&str, String> {
|
||||
args.get(index)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| format!("argument {} must be a string", index + 1))
|
||||
}
|
||||
|
||||
fn optional_string_arg(args: &[Value], index: usize) -> Option<&str> {
|
||||
args.get(index).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
fn preview_post_target() -> &'static Mutex<Option<String>> {
|
||||
static TARGET: OnceLock<Mutex<Option<String>>> = OnceLock::new();
|
||||
TARGET.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn title_bar_metrics() -> Value {
|
||||
json!({"macos_left_inset": 72})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn title_bar_metrics() -> Value {
|
||||
Value::Null
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(text: &str) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut child = Command::new("pbcopy");
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut child = {
|
||||
let mut command = Command::new("cmd");
|
||||
command.args(["/c", "clip"]);
|
||||
command
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut child = {
|
||||
let mut command = Command::new("xclip");
|
||||
command.args(["-selection", "clipboard"]);
|
||||
command
|
||||
};
|
||||
|
||||
child
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
.and_then(|mut child| {
|
||||
child.stdin.take().unwrap().write_all(text.as_bytes())?;
|
||||
child.wait().map(|status| status.success())
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn show_item_in_folder(path: &Path) -> std::io::Result<()> {
|
||||
Command::new("open")
|
||||
.arg("-R")
|
||||
.arg(path)
|
||||
.status()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn show_item_in_folder(path: &Path) -> std::io::Result<()> {
|
||||
Command::new("explorer")
|
||||
.arg(format!("/select,{}", path.display()))
|
||||
.status()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn show_item_in_folder(path: &Path) -> std::io::Result<()> {
|
||||
open::that(path.parent().unwrap_or(path)).map_err(std::io::Error::other)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn desktop_handler_queues_supported_menu_actions() {
|
||||
let queued = Arc::new(Mutex::new(Vec::new()));
|
||||
handler(Arc::clone(&queued), String::new())("trigger_menu_action", &[json!("new_post")])
|
||||
.unwrap();
|
||||
assert_eq!(queued.lock().unwrap().as_slice(), &[MenuAction::NewPost]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user