Open blogmark drafts before semantic indexing finishes.

This commit is contained in:
2026-08-01 11:29:10 +02:00
parent 2fed5371ca
commit b25691ddac
5 changed files with 198 additions and 41 deletions

View File

@@ -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.

View File

@@ -102,6 +102,20 @@ pub fn receive_deep_link_with_host(
raw: &str,
control: &ExecutionControl,
host: Arc<dyn HostApi>,
) -> EngineResult<BlogmarkImportResult> {
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<dyn HostApi>,
on_created: impl FnOnce(&Post),
) -> EngineResult<BlogmarkImportResult> {
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,

View File

@@ -47,6 +47,38 @@ pub fn create_post(
author: Option<&str>,
language: Option<&str>,
template_slug: Option<&str>,
) -> EngineResult<Post> {
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<String>,
categories: Vec<String>,
author: Option<&str>,
language: Option<&str>,
template_slug: Option<&str>,
on_created: impl FnOnce(&Post),
) -> EngineResult<Post> {
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)

View File

@@ -79,6 +79,12 @@ pub enum OneShotAiAction {
MediaTranslation { target_language: String },
}
#[derive(Debug, Clone)]
enum BlogmarkImportEvent {
DraftCreated(Post),
Finished(Result<engine::blogmark::BlogmarkImportResult, String>),
}
#[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<engine::blogmark::BlogmarkImportResult, String>,
@@ -2856,12 +2866,16 @@ impl BdsApp {
t(self.ui_locale, "dialog.selectFolder"),
);
self.refresh_task_snapshots();
let import_task = Task::perform(
async move {
let (sender, receiver) = futures::channel::mpsc::unbounded();
tokio::spawn(async move {
let Some(worker) = task_manager.admit(task_id).await else {
return Err("cancelled".to_string());
let _ = sender.unbounded_send(BlogmarkImportEvent::Finished(Err(
"cancelled".to_string(),
)));
return;
};
tokio::task::spawn_blocking(move || {
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 =
@@ -2873,23 +2887,50 @@ impl BdsApp {
.cancellation_flag(task_id)
.map(bds_core::scripting::ExecutionControl::from_cancelled)
.unwrap_or_default();
engine::blogmark::receive_deep_link_with_host(
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}")))
},
move |result| Message::BlogmarkImported { task_id, result },
);
.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(),
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.clone(),
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();

View File

@@ -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