Open blogmark drafts before semantic indexing finishes.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user