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.
|
- 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.
|
- 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.
|
- 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.
|
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,
|
raw: &str,
|
||||||
control: &ExecutionControl,
|
control: &ExecutionControl,
|
||||||
host: Arc<dyn HostApi>,
|
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> {
|
) -> EngineResult<BlogmarkImportResult> {
|
||||||
let mut candidate = parse_deep_link(raw)?;
|
let mut candidate = parse_deep_link(raw)?;
|
||||||
if let Some(target) = &candidate.project_id
|
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 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,
|
conn,
|
||||||
data_dir,
|
data_dir,
|
||||||
project_id,
|
project_id,
|
||||||
@@ -145,6 +159,7 @@ pub fn receive_deep_link_with_host(
|
|||||||
metadata.default_author.as_deref(),
|
metadata.default_author.as_deref(),
|
||||||
metadata.main_language.as_deref(),
|
metadata.main_language.as_deref(),
|
||||||
None,
|
None,
|
||||||
|
on_created,
|
||||||
)?;
|
)?;
|
||||||
Ok(BlogmarkImportResult {
|
Ok(BlogmarkImportResult {
|
||||||
post,
|
post,
|
||||||
|
|||||||
@@ -47,6 +47,38 @@ pub fn create_post(
|
|||||||
author: Option<&str>,
|
author: Option<&str>,
|
||||||
language: Option<&str>,
|
language: Option<&str>,
|
||||||
template_slug: 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> {
|
) -> EngineResult<Post> {
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let slug_source = if title.is_empty() { "untitled" } else { title };
|
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)?;
|
fts_index_post(conn, data_dir, &post)?;
|
||||||
|
|
||||||
emit_post(&post, NotificationAction::Created);
|
emit_post(&post, NotificationAction::Created);
|
||||||
|
on_created(&post);
|
||||||
crate::engine::embedding::sync_post_best_effort(conn, data_dir, &post);
|
crate::engine::embedding::sync_post_best_effort(conn, data_dir, &post);
|
||||||
|
|
||||||
Ok(post)
|
Ok(post)
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ pub enum OneShotAiAction {
|
|||||||
MediaTranslation { target_language: String },
|
MediaTranslation { target_language: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum BlogmarkImportEvent {
|
||||||
|
DraftCreated(Post),
|
||||||
|
Finished(Result<engine::blogmark::BlogmarkImportResult, String>),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
// Menu
|
// Menu
|
||||||
@@ -197,6 +203,10 @@ pub enum Message {
|
|||||||
// macOS lifecycle
|
// macOS lifecycle
|
||||||
FileOpenRequested(PathBuf),
|
FileOpenRequested(PathBuf),
|
||||||
UrlOpenRequested(String),
|
UrlOpenRequested(String),
|
||||||
|
BlogmarkDraftCreated {
|
||||||
|
task_id: TaskId,
|
||||||
|
post: Post,
|
||||||
|
},
|
||||||
BlogmarkImported {
|
BlogmarkImported {
|
||||||
task_id: TaskId,
|
task_id: TaskId,
|
||||||
result: Result<engine::blogmark::BlogmarkImportResult, String>,
|
result: Result<engine::blogmark::BlogmarkImportResult, String>,
|
||||||
@@ -2856,12 +2866,16 @@ impl BdsApp {
|
|||||||
t(self.ui_locale, "dialog.selectFolder"),
|
t(self.ui_locale, "dialog.selectFolder"),
|
||||||
);
|
);
|
||||||
self.refresh_task_snapshots();
|
self.refresh_task_snapshots();
|
||||||
let import_task = Task::perform(
|
let (sender, receiver) = futures::channel::mpsc::unbounded();
|
||||||
async move {
|
tokio::spawn(async move {
|
||||||
let Some(worker) = task_manager.admit(task_id).await else {
|
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 _worker = worker;
|
||||||
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
||||||
let host =
|
let host =
|
||||||
@@ -2873,23 +2887,50 @@ impl BdsApp {
|
|||||||
.cancellation_flag(task_id)
|
.cancellation_flag(task_id)
|
||||||
.map(bds_core::scripting::ExecutionControl::from_cancelled)
|
.map(bds_core::scripting::ExecutionControl::from_cancelled)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
engine::blogmark::receive_deep_link_with_host(
|
engine::blogmark::receive_deep_link_with_host_and_created(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
&data_dir,
|
&data_dir,
|
||||||
&project_id,
|
&project_id,
|
||||||
&url,
|
&url,
|
||||||
&control,
|
&control,
|
||||||
Arc::new(host),
|
Arc::new(host),
|
||||||
|
move |post| {
|
||||||
|
let _ = created_sender.unbounded_send(
|
||||||
|
BlogmarkImportEvent::DraftCreated(post.clone()),
|
||||||
|
);
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
.unwrap_or_else(|error| Err(format!("task panicked: {error}")));
|
||||||
},
|
let _ = sender.unbounded_send(BlogmarkImportEvent::Finished(result));
|
||||||
move |result| Message::BlogmarkImported { task_id, 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])
|
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 {
|
Message::BlogmarkImported { task_id, result } => match result {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
self.task_manager.complete(task_id);
|
self.task_manager.complete(task_id);
|
||||||
@@ -2902,14 +2943,22 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
self.sidebar_view = SidebarView::Posts;
|
self.sidebar_view = SidebarView::Posts;
|
||||||
self.sidebar_visible = true;
|
self.sidebar_visible = true;
|
||||||
let tab = Tab {
|
let post_id = result.post.id.clone();
|
||||||
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,
|
tab_type: TabType::Post,
|
||||||
title: result.post.title.clone(),
|
title: result.post.title,
|
||||||
is_transient: false,
|
is_transient: false,
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
|
})
|
||||||
};
|
};
|
||||||
let open_editor = self.open_tab(tab);
|
|
||||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "blogmark.imported"));
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "blogmark.imported"));
|
||||||
Task::batch([open_editor, self.refresh_counts()])
|
Task::batch([open_editor, self.refresh_counts()])
|
||||||
}
|
}
|
||||||
@@ -10812,6 +10861,60 @@ mod tests {
|
|||||||
assert!(!remote_error_closes_connection("engine_error"));
|
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]
|
#[test]
|
||||||
fn imported_blogmark_activates_posts_and_opens_its_editor() {
|
fn imported_blogmark_activates_posts_and_opens_its_editor() {
|
||||||
let (db, project, temp) = setup();
|
let (db, project, temp) = setup();
|
||||||
|
|||||||
@@ -371,6 +371,12 @@ rule ExecuteTransform {
|
|||||||
-- after a deep link (e.g. when the link switched projects) preserves
|
-- after a deep link (e.g. when the link switched projects) preserves
|
||||||
-- the newly opened editor tab as the active tab.
|
-- 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
|
@guarantee TransformTrigger
|
||||||
-- Transform scripts are triggered automatically by blogmark import.
|
-- Transform scripts are triggered automatically by blogmark import.
|
||||||
-- Each script receives the current post candidate plus a context with
|
-- Each script receives the current post candidate plus a context with
|
||||||
|
|||||||
Reference in New Issue
Block a user