diff --git a/README.md b/README.md index 7fcd07d..97f0b09 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ The project is under active development. Core blogging workflows are broadly ava - Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript. - SSH-agent-based SCP or rsync publishing. - Integrated Git workflow for each blog project's current repository, with repository initialization, read-only origin discovery, Git LFS image tracking, status and diffs, branch/file history with bDS2-compatible sync-status colors, commits, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode. -- Site, media, and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; captures open directly in the post editor and defer automatic translation until an explicit manual save, which queues one task per still-missing language. Rendering tasks keep long generated URLs compact in progress messages. Publishing never starts automatic translation. bDS2 keeps its separate `bds2://` bookmarklet protocol. +- Site, media, and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; captures open directly in the post editor as soon as the draft exists, without waiting for on-device semantic indexing, and defer automatic translation until an explicit manual save, which queues one task per still-missing language. Rendering tasks keep long generated URLs compact in progress messages. Publishing never starts automatic translation. bDS2 keeps its separate `bds2://` bookmarklet protocol. RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from CDNs. The preview is served by the Rust application and displayed by the operating-system webview. diff --git a/crates/bds-core/src/engine/blogmark.rs b/crates/bds-core/src/engine/blogmark.rs index 95e6f20..221a63d 100644 --- a/crates/bds-core/src/engine/blogmark.rs +++ b/crates/bds-core/src/engine/blogmark.rs @@ -102,6 +102,20 @@ pub fn receive_deep_link_with_host( raw: &str, control: &ExecutionControl, host: Arc, +) -> EngineResult { + receive_deep_link_with_host_and_created(conn, data_dir, project_id, raw, control, host, |_| {}) +} + +/// Import a blogmark while reporting the created draft before secondary +/// embedding work completes. +pub fn receive_deep_link_with_host_and_created( + conn: &Connection, + data_dir: &Path, + project_id: &str, + raw: &str, + control: &ExecutionControl, + host: Arc, + on_created: impl FnOnce(&Post), ) -> EngineResult { let mut candidate = parse_deep_link(raw)?; if let Some(target) = &candidate.project_id @@ -134,7 +148,7 @@ pub fn receive_deep_link_with_host( } } let metadata = crate::engine::meta::read_project_json(data_dir)?; - let post = crate::engine::post::create_post( + let post = crate::engine::post::create_post_with_created_callback( conn, data_dir, project_id, @@ -145,6 +159,7 @@ pub fn receive_deep_link_with_host( metadata.default_author.as_deref(), metadata.main_language.as_deref(), None, + on_created, )?; Ok(BlogmarkImportResult { post, diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index 6efce31..e0c5c84 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -47,6 +47,38 @@ pub fn create_post( author: Option<&str>, language: Option<&str>, template_slug: Option<&str>, +) -> EngineResult { + create_post_with_created_callback( + conn, + data_dir, + project_id, + title, + content, + tags, + categories, + author, + language, + template_slug, + |_| {}, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "arguments are the user-supplied post fields" +)] +pub(crate) fn create_post_with_created_callback( + conn: &Connection, + data_dir: &Path, + project_id: &str, + title: &str, + content: Option<&str>, + tags: Vec, + categories: Vec, + author: Option<&str>, + language: Option<&str>, + template_slug: Option<&str>, + on_created: impl FnOnce(&Post), ) -> EngineResult { let id = Uuid::new_v4().to_string(); let slug_source = if title.is_empty() { "untitled" } else { title }; @@ -93,6 +125,7 @@ pub fn create_post( fts_index_post(conn, data_dir, &post)?; emit_post(&post, NotificationAction::Created); + on_created(&post); crate::engine::embedding::sync_post_best_effort(conn, data_dir, &post); Ok(post) diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index ef7c480..3b4bb8f 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -79,6 +79,12 @@ pub enum OneShotAiAction { MediaTranslation { target_language: String }, } +#[derive(Debug, Clone)] +enum BlogmarkImportEvent { + DraftCreated(Post), + Finished(Result), +} + #[derive(Debug, Clone)] pub enum Message { // Menu @@ -197,6 +203,10 @@ pub enum Message { // macOS lifecycle FileOpenRequested(PathBuf), UrlOpenRequested(String), + BlogmarkDraftCreated { + task_id: TaskId, + post: Post, + }, BlogmarkImported { task_id: TaskId, result: Result, @@ -2856,40 +2866,71 @@ impl BdsApp { t(self.ui_locale, "dialog.selectFolder"), ); self.refresh_task_snapshots(); - let import_task = Task::perform( - async move { - let Some(worker) = task_manager.admit(task_id).await else { - return Err("cancelled".to_string()); - }; - tokio::task::spawn_blocking(move || { - let _worker = worker; - let db = Database::open(&db_path).map_err(|error| error.to_string())?; - let host = - bds_core::scripting::CoreHost::new(db_path, &project_id, &data_dir) - .with_task(Arc::clone(&task_manager), task_id) - .with_offline_mode(offline_mode) - .with_app_handler(app_handler); - let control = task_manager - .cancellation_flag(task_id) - .map(bds_core::scripting::ExecutionControl::from_cancelled) - .unwrap_or_default(); - engine::blogmark::receive_deep_link_with_host( - db.conn(), - &data_dir, - &project_id, - &url, - &control, - Arc::new(host), - ) - .map_err(|error| error.to_string()) - }) - .await - .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) - }, - move |result| Message::BlogmarkImported { task_id, result }, - ); + let (sender, receiver) = futures::channel::mpsc::unbounded(); + tokio::spawn(async move { + let Some(worker) = task_manager.admit(task_id).await else { + let _ = sender.unbounded_send(BlogmarkImportEvent::Finished(Err( + "cancelled".to_string(), + ))); + return; + }; + let created_sender = sender.clone(); + let result = tokio::task::spawn_blocking(move || { + let _worker = worker; + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + let host = + bds_core::scripting::CoreHost::new(db_path, &project_id, &data_dir) + .with_task(Arc::clone(&task_manager), task_id) + .with_offline_mode(offline_mode) + .with_app_handler(app_handler); + let control = task_manager + .cancellation_flag(task_id) + .map(bds_core::scripting::ExecutionControl::from_cancelled) + .unwrap_or_default(); + engine::blogmark::receive_deep_link_with_host_and_created( + db.conn(), + &data_dir, + &project_id, + &url, + &control, + Arc::new(host), + move |post| { + let _ = created_sender.unbounded_send( + BlogmarkImportEvent::DraftCreated(post.clone()), + ); + }, + ) + .map_err(|error| error.to_string()) + }) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))); + let _ = sender.unbounded_send(BlogmarkImportEvent::Finished(result)); + }); + let import_task = Task::run(receiver, move |event| match event { + BlogmarkImportEvent::DraftCreated(post) => { + Message::BlogmarkDraftCreated { task_id, post } + } + BlogmarkImportEvent::Finished(result) => { + Message::BlogmarkImported { task_id, result } + } + }); Task::batch([project_restore_task, import_task]) } + Message::BlogmarkDraftCreated { task_id, post } => { + if self.task_manager.status(task_id) == Some(TaskStatus::Cancelled) { + return Task::none(); + } + self.sidebar_view = SidebarView::Posts; + self.sidebar_visible = true; + let tab = Tab { + id: post.id, + tab_type: TabType::Post, + title: post.title, + is_transient: false, + is_dirty: false, + }; + Task::batch([self.open_tab(tab), self.refresh_counts()]) + } Message::BlogmarkImported { task_id, result } => match result { Ok(result) => { self.task_manager.complete(task_id); @@ -2902,14 +2943,22 @@ impl BdsApp { } self.sidebar_view = SidebarView::Posts; self.sidebar_visible = true; - let tab = Tab { - id: result.post.id.clone(), - tab_type: TabType::Post, - title: result.post.title.clone(), - is_transient: false, - is_dirty: false, + let post_id = result.post.id.clone(); + let already_open = self + .tabs + .iter() + .any(|tab| tab.id == post_id && tab.tab_type == TabType::Post); + let open_editor = if already_open { + Task::done(Message::LoadSemanticTagSuggestions(post_id)) + } else { + self.open_tab(Tab { + id: result.post.id, + tab_type: TabType::Post, + title: result.post.title, + is_transient: false, + is_dirty: false, + }) }; - let open_editor = self.open_tab(tab); self.notify(ToastLevel::Success, &t(self.ui_locale, "blogmark.imported")); Task::batch([open_editor, self.refresh_counts()]) } @@ -10812,6 +10861,60 @@ mod tests { assert!(!remote_error_closes_connection("engine_error")); } + #[test] + fn created_blogmark_opens_its_editor_before_import_task_finishes() { + let (db, project, temp) = setup(); + let created = post::create_post( + db.conn(), + temp.path(), + &project.id, + "Saved From Browser", + Some("[Saved From Browser](https://example.com/)"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf()); + app.tabs.clear(); + app.active_tab = None; + app.post_editors.clear(); + let task_id = app.task_manager.submit("Importing blogmark"); + + let _ = app.update(Message::BlogmarkDraftCreated { + task_id, + post: created.clone(), + }); + + assert_eq!(app.active_tab.as_deref(), Some(created.id.as_str())); + assert!(app.post_editors.contains_key(&created.id)); + assert_eq!( + app.task_manager.status(task_id), + Some(TaskStatus::Running), + "opening the editor must not wait for semantic indexing to finish" + ); + + let _ = app.update(Message::OpenTab(Tab { + id: "settings".to_string(), + tab_type: TabType::Settings, + title: "Settings".to_string(), + is_transient: false, + is_dirty: false, + })); + let _ = app.update(Message::BlogmarkImported { + task_id, + result: Ok(blogmark::BlogmarkImportResult { + post: created, + toasts: Vec::new(), + transform_errors: Vec::new(), + }), + }); + + assert_eq!(app.active_tab.as_deref(), Some("settings")); + } + #[test] fn imported_blogmark_activates_posts_and_opens_its_editor() { let (db, project, temp) = setup(); diff --git a/specs/script.allium b/specs/script.allium index 3fbc656..2c199b5 100644 --- a/specs/script.allium +++ b/specs/script.allium @@ -371,6 +371,12 @@ rule ExecuteTransform { -- after a deep link (e.g. when the link switched projects) preserves -- the newly opened editor tab as the active tab. + @guarantee BlogmarkEditorActivation + -- Once the transformed draft and its text-search entry exist, the shell + -- opens that draft in the post editor immediately. Post-created side + -- effects that may take longer, including lazy model loading and semantic + -- indexing, continue without delaying or replacing the opened editor. + @guarantee TransformTrigger -- Transform scripts are triggered automatically by blogmark import. -- Each script receives the current post candidate plus a context with