From d1829f0e9b3732d936d662a42b1fd4b97c1b4b82 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 20 Jul 2026 22:20:57 +0200 Subject: [PATCH] Schedule only missing post translations --- README.md | 2 +- crates/bds-core/src/engine/ai.rs | 90 +++++-- .../bds-core/src/engine/auto_translation.rs | 132 +++++++++-- crates/bds-ui/src/app.rs | 221 +++++++++++++++--- crates/bds-ui/src/app/editor_handlers.rs | 2 +- crates/bds-ui/src/components/popover.rs | 28 +-- specs/action_patterns.allium | 15 +- 7 files changed, 399 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index efbc7c0..5594e2f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,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 with repository initialization, Git LFS image tracking, status and diffs, branch/file history, commits, remotes, 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 save or publish. 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 and defer automatic translation until an explicit save or publish, which queues one task per still-missing language. 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/ai.rs b/crates/bds-core/src/engine/ai.rs index 96c5b49..d6cac62 100644 --- a/crates/bds-core/src/engine/ai.rs +++ b/crates/bds-core/src/engine/ai.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::sync::{LazyLock, Mutex}; use std::time::Duration; use crate::db::DbConnection as Connection; @@ -13,6 +14,8 @@ use crate::util::now_unix_ms; const KEYRING_SERVICE: &str = "RuDS"; const KEYRING_SETTING_PREFIX: &str = "ai.endpoint"; +static TEST_API_KEYS: LazyLock>> = + LazyLock::new(|| Mutex::new(BTreeMap::new())); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -246,15 +249,11 @@ fn save_endpoint_api_key_at( api_key: &str, updated_at: i64, ) -> EngineResult<()> { - let entry = endpoint_keyring_entry(kind)?; let configured = !api_key.trim().is_empty(); if configured { - entry.set_password(api_key.trim()).map_err(keyring_error)?; + store_endpoint_api_key(kind, api_key.trim())?; } else { - match entry.delete_credential() { - Ok(()) | Err(keyring::Error::NoEntry) => {} - Err(error) => return Err(keyring_error(error)), - } + delete_endpoint_api_key(kind)?; } set_setting( conn, @@ -397,8 +396,12 @@ pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult EngineResult EngineResult> { - let entry = endpoint_keyring_entry(kind)?; - match entry.get_password() { - Ok(password) if password.trim().is_empty() => Ok(None), - Ok(password) => Ok(Some(password)), - Err(keyring::Error::NoEntry) => Ok(None), - Err(error) => Err(keyring_error(error)), - } + read_endpoint_api_key(kind) } pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult> { @@ -934,6 +931,64 @@ fn endpoint_keyring_entry(kind: AiEndpointKind) -> EngineResult { .map_err(keyring_error) } +fn store_endpoint_api_key(kind: AiEndpointKind, api_key: &str) -> EngineResult<()> { + if cargo_test_process() { + TEST_API_KEYS + .lock() + .map_err(|error| EngineError::Validation(error.to_string()))? + .insert(kind.as_str().to_string(), api_key.to_string()); + return Ok(()); + } + endpoint_keyring_entry(kind)? + .set_password(api_key) + .map_err(keyring_error) +} + +fn read_endpoint_api_key(kind: AiEndpointKind) -> EngineResult> { + if cargo_test_process() { + return TEST_API_KEYS + .lock() + .map_err(|error| EngineError::Validation(error.to_string())) + .map(|keys| { + keys.get(kind.as_str()) + .cloned() + .filter(|key| !key.trim().is_empty()) + }); + } + match endpoint_keyring_entry(kind)?.get_password() { + Ok(password) if password.trim().is_empty() => Ok(None), + Ok(password) => Ok(Some(password)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(keyring_error(error)), + } +} + +fn delete_endpoint_api_key(kind: AiEndpointKind) -> EngineResult<()> { + if cargo_test_process() { + TEST_API_KEYS + .lock() + .map_err(|error| EngineError::Validation(error.to_string()))? + .remove(kind.as_str()); + return Ok(()); + } + match endpoint_keyring_entry(kind)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(keyring_error(error)), + } +} + +/// Cargo places every unit and integration test executable in a `deps` +/// directory. Keep those processes on a process-local credential store so a +/// test can never open an operating-system password prompt. +fn cargo_test_process() -> bool { + cfg!(test) + || std::env::current_exe().is_ok_and(|path| { + path.parent() + .and_then(|parent| parent.file_name()) + .is_some_and(|name| name == "deps") + }) +} + fn keyring_error(error: keyring::Error) -> EngineError { EngineError::Validation(error.to_string()) } @@ -1024,8 +1079,7 @@ mod tests { } fn clear_keyring(kind: AiEndpointKind) { - let entry = endpoint_keyring_entry(kind).unwrap(); - entry.delete_credential().ok(); + delete_endpoint_api_key(kind).unwrap(); } #[test] @@ -1039,7 +1093,6 @@ mod tests { } #[test] - #[ignore = "touches system keychain; run explicitly when validating keychain integration"] fn saves_online_endpoint_with_keychain_secret() { clear_keyring(AiEndpointKind::Online); let db = setup(); @@ -1245,7 +1298,6 @@ mod tests { } #[test] - #[ignore = "touches system keychain; run explicitly when validating keychain integration"] fn run_one_shot_uses_active_endpoint_and_parses_response() { clear_keyring(AiEndpointKind::Online); let server = spawn_test_server( diff --git a/crates/bds-core/src/engine/auto_translation.rs b/crates/bds-core/src/engine/auto_translation.rs index b59984a..8062027 100644 --- a/crates/bds-core/src/engine/auto_translation.rs +++ b/crates/bds-core/src/engine/auto_translation.rs @@ -178,29 +178,83 @@ pub fn translate_missing_for_post( if is_cancelled() { return Err(EngineError::Validation("cancelled".to_string())); } - let result = translate_one_post( + merge_reactive_translation_result( + &mut report, + &post, + &language, + translate_one_post( + conn, + data_dir, + &post, + &language, + false, + &mut |post, language| translate_post_ai(conn, offline_mode, post, language), + &mut |media, language| translate_media_ai(conn, offline_mode, media, language), + ), + ); + } + Ok(report) +} + +/// Translate one language from the current missing-language set. This is the +/// reactive editor path: already-created or unconfigured translations are a +/// silent no-op, and generated translations remain drafts. +pub fn translate_missing_language_for_post( + conn: &Connection, + data_dir: &Path, + post_id: &str, + configured_languages: &[String], + language: &str, + offline_mode: bool, + is_cancelled: impl Fn() -> bool, +) -> EngineResult { + let post = qp::get_post_by_id(conn, post_id)?; + let targets = missing_languages(conn, &post, configured_languages)?; + if !targets.iter().any(|target| target == language) { + return Ok(FillMissingTranslationsReport { + nothing_to_do: true, + ..Default::default() + }); + } + if is_cancelled() { + return Err(EngineError::Validation("cancelled".to_string())); + } + let mut report = FillMissingTranslationsReport::default(); + merge_reactive_translation_result( + &mut report, + &post, + language, + translate_one_post( conn, data_dir, &post, - &language, + language, false, &mut |post, language| translate_post_ai(conn, offline_mode, post, language), &mut |media, language| translate_media_ai(conn, offline_mode, media, language), - ); - match result { - Ok(media_count) => { - report.translated_posts += 1; - report.translated_media += media_count; - } - Err(error) => { - report.failed_count += 1; - report - .errors - .push(format!("{} ({language}): {error}", post.title)); - } + ), + ); + Ok(report) +} + +fn merge_reactive_translation_result( + report: &mut FillMissingTranslationsReport, + post: &Post, + language: &str, + result: EngineResult, +) { + match result { + Ok(media_count) => { + report.translated_posts += 1; + report.translated_media += media_count; + } + Err(error) => { + report.failed_count += 1; + report + .errors + .push(format!("{} ({language}): {error}", post.title)); } } - Ok(report) } fn translate_one_post( @@ -342,7 +396,7 @@ mod tests { use crate::db::Database; use crate::db::fts::ensure_fts_tables; use crate::db::queries::project::{insert_project, make_test_project}; - use crate::engine::post::{create_post, publish_post}; + use crate::engine::post::{create_post, publish_post, upsert_translation}; use tempfile::TempDir; #[test] @@ -401,6 +455,52 @@ mod tests { } } + #[test] + fn reactive_language_translation_is_a_no_op_when_translation_exists() { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + ensure_fts_tables(db.conn()).unwrap(); + insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap(); + let dir = TempDir::new().unwrap(); + let post = create_post( + db.conn(), + dir.path(), + "p1", + "Hello", + Some("Body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + upsert_translation( + db.conn(), + dir.path(), + &post.id, + "de", + "Hallo", + None, + Some("Inhalt"), + ) + .unwrap(); + + let report = translate_missing_language_for_post( + db.conn(), + dir.path(), + &post.id, + &["en".to_string(), "de".to_string()], + "de", + true, + || false, + ) + .unwrap(); + + assert!(report.nothing_to_do); + assert_eq!(report.translated_posts, 0); + } + #[test] fn skips_do_not_translate_posts() { let db = Database::open_in_memory().unwrap(); diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 4b8bfba..9d6bda3 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -5638,11 +5638,21 @@ impl BdsApp { // ── Editor save/publish helpers ── - fn save_post_editor(&mut self, post_id: &str) -> Task { + fn save_post_editor( + &mut self, + post_id: &str, + schedule_auto_translation: bool, + ) -> Task { + let saves_canonical_post = self + .post_editors + .get(post_id) + .is_some_and(|editor| editor.active_language == editor.canonical_language); match self.persist_post_editor_state(post_id) { Ok(()) => { self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); - return self.schedule_post_auto_translation(post_id); + if schedule_auto_translation && saves_canonical_post { + return self.schedule_post_auto_translation(post_id); + } } Err(e) => self.notify_operation_failed("common.save", e), } @@ -5954,39 +5964,58 @@ impl BdsApp { } fn schedule_post_auto_translation(&mut self, post_id: &str) -> Task { - let Some(db) = self.db.as_ref() else { + let (Some(db), Some(data_dir)) = (self.db.as_ref(), self.data_dir.as_ref()) else { return Task::none(); }; + let Ok(meta) = engine::meta::read_project_json(data_dir) else { + return Task::none(); + }; + let Ok(post) = bds_core::db::queries::post::get_post_by_id(db.conn(), post_id) else { + return Task::none(); + }; + let main_language = meta.main_language.unwrap_or_else(|| "en".to_string()); + let configured = + engine::auto_translation::configured_languages(&main_language, &meta.blog_languages); + let Ok(targets) = + engine::auto_translation::missing_languages(db.conn(), &post, &configured) + else { + return Task::none(); + }; + if targets.is_empty() { + return Task::none(); + } if engine::ai::active_endpoint(db.conn(), self.offline_mode).is_err() { return Task::none(); } let post_id = post_id.to_string(); let offline_mode = self.offline_mode; let locale = self.ui_locale; - self.spawn_grouped_engine_task( - "engine.autoTranslationStarted", - "AI", - move |db_path, _project_id, data_dir, task_manager, task_id| { - let db = Database::open(&db_path).map_err(|error| error.to_string())?; - let meta = engine::meta::read_project_json(&data_dir) + Task::batch(targets.into_iter().map(|language| { + let post_id = post_id.clone(); + let configured = configured.clone(); + self.spawn_grouped_engine_task( + "engine.autoTranslationStarted", + "AI", + move |db_path, _project_id, data_dir, task_manager, task_id| { + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + let report = engine::auto_translation::translate_missing_language_for_post( + db.conn(), + &data_dir, + &post_id, + &configured, + &language, + offline_mode, + move || task_manager.is_cancelled(task_id), + ) .map_err(|error| error.to_string())?; - let report = engine::auto_translation::translate_missing_for_post( - db.conn(), - &data_dir, - &post_id, - meta.main_language.as_deref().unwrap_or("en"), - &meta.blog_languages, - offline_mode, - move || task_manager.is_cancelled(task_id), - ) - .map_err(|error| error.to_string())?; - Ok(tw( - locale, - "engine.autoTranslationComplete", - &[("count", &report.translated_posts.to_string())], - )) - }, - ) + Ok(tw( + locale, + "engine.autoTranslationComplete", + &[("count", &report.translated_posts.to_string())], + )) + }, + ) + })) } fn insert_link_modal(&mut self, post_id: &str) -> Task { @@ -6177,7 +6206,7 @@ impl BdsApp { tab.is_dirty = true; } self.active_modal = None; - self.save_post_editor(post_id) + self.save_post_editor(post_id, false) } fn insert_selected_post_link(&mut self, post_id: &str, linked_post_id: &str) -> Task { @@ -9617,8 +9646,10 @@ mod tests { ) .unwrap(); let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf()); - let mut settings = SettingsViewState::default(); - settings.default_mode = "preview".to_string(); + let settings = SettingsViewState { + default_mode: "preview".to_string(), + ..Default::default() + }; app.settings_state = Some(settings); app.offline_mode = true; app.sidebar_view = SidebarView::Settings; @@ -11164,6 +11195,138 @@ mod tests { ); } + fn auto_translation_test_app(translated_languages: &[&str]) -> (BdsApp, String, TempDir) { + let (db, project, tmp) = setup(); + let mut metadata = bds_core::engine::meta::read_project_json(tmp.path()).unwrap(); + metadata.main_language = Some("en".to_string()); + metadata.blog_languages = vec!["en".to_string(), "de".to_string(), "fr".to_string()]; + bds_core::engine::meta::write_project_json(tmp.path(), &metadata).unwrap(); + ai::save_endpoint( + db.conn(), + &ai::AiEndpointConfig { + kind: ai::AiEndpointKind::Airplane, + url: "http://127.0.0.1:9".to_string(), + model: "test".to_string(), + api_key: None, + }, + ) + .unwrap(); + let created = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Original", + Some("Body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + for language in translated_languages { + post::upsert_translation( + db.conn(), + tmp.path(), + &created.id, + language, + "Translated", + None, + Some("Body"), + ) + .unwrap(); + } + let editor_post = + bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap(); + let translations = bds_core::db::queries::post_translation::list_post_translations_by_post( + db.conn(), + &created.id, + ) + .unwrap(); + let editor = PostEditorState::from_post( + &editor_post, + "markdown", + &["en".to_string(), "de".to_string(), "fr".to_string()], + &translations, + Vec::new(), + Vec::new(), + Vec::new(), + ); + let mut app = make_app(db, project, &tmp); + app.offline_mode = true; + app.post_editors.insert(created.id.clone(), editor); + app.tabs.push(crate::state::tabs::Tab { + id: created.id.clone(), + tab_type: crate::state::tabs::TabType::Post, + title: created.title, + is_transient: false, + is_dirty: false, + }); + (app, created.id, tmp) + } + + #[test] + fn first_manual_save_enqueues_one_task_per_missing_translation() { + let (mut app, post_id, _tmp) = auto_translation_test_app(&[]); + + let _ = app.save_post_editor(&post_id, true); + + assert_eq!( + app.task_manager + .snapshots() + .iter() + .filter(|task| task.group_name.as_deref() == Some("AI")) + .count(), + 2 + ); + } + + #[test] + fn later_manual_save_enqueues_no_translation_task_when_languages_exist() { + let (mut app, post_id, _tmp) = auto_translation_test_app(&["de", "fr"]); + + let _ = app.save_post_editor(&post_id, true); + + assert!( + app.task_manager + .snapshots() + .iter() + .all(|task| task.group_name.as_deref() != Some("AI")) + ); + } + + #[test] + fn saving_a_translation_draft_does_not_enqueue_other_languages() { + let (mut app, post_id, _tmp) = auto_translation_test_app(&["de"]); + app.post_editors + .get_mut(&post_id) + .unwrap() + .switch_language("de"); + + let _ = app.save_post_editor(&post_id, true); + + assert!( + app.task_manager + .snapshots() + .iter() + .all(|task| task.group_name.as_deref() != Some("AI")) + ); + } + + #[test] + fn inserting_editor_markdown_persists_without_enqueuing_translations() { + let (mut app, post_id, _tmp) = auto_translation_test_app(&[]); + + let _ = app.insert_markdown_into_post(&post_id, "[Link](/target)"); + + assert!( + app.task_manager + .snapshots() + .iter() + .all(|task| task.group_name.as_deref() != Some("AI")) + ); + } + #[test] fn switching_tabs_flushes_active_post_editor() { let (db, project, tmp) = setup(); diff --git a/crates/bds-ui/src/app/editor_handlers.rs b/crates/bds-ui/src/app/editor_handlers.rs index 0d8b6fd..044d4cc 100644 --- a/crates/bds-ui/src/app/editor_handlers.rs +++ b/crates/bds-ui/src/app/editor_handlers.rs @@ -293,7 +293,7 @@ impl BdsApp { post_id, target_language, } => self.translate_post_to(&post_id, &target_language), - DeferredPostAction::Save(tab_id) => self.save_post_editor(&tab_id), + DeferredPostAction::Save(tab_id) => self.save_post_editor(&tab_id, true), DeferredPostAction::Publish(tab_id) => self.publish_post_editor(&tab_id), DeferredPostAction::Discard(tab_id) => self.discard_post_editor(&tab_id), DeferredPostAction::ShowDelete { tab_id, name } => { diff --git a/crates/bds-ui/src/components/popover.rs b/crates/bds-ui/src/components/popover.rs index 1a257b8..eb50a8c 100644 --- a/crates/bds-ui/src/components/popover.rs +++ b/crates/bds-ui/src/components/popover.rs @@ -274,27 +274,19 @@ where return status; } - let dismiss = matches!( + let escape = matches!( + event, + Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(key::Named::Escape), + .. + }) + ); + let outside_press = matches!( event, Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(iced::touch::Event::FingerPressed { .. }) - | Event::Keyboard(keyboard::Event::KeyPressed { - key: Key::Named(key::Named::Escape), - .. - }) - ); - if dismiss && !cursor.is_over(layout.bounds()) { - shell.publish(self.on_dismiss.clone()); - event::Status::Captured - } else if dismiss - && matches!( - event, - Event::Keyboard(keyboard::Event::KeyPressed { - key: Key::Named(key::Named::Escape), - .. - }) - ) - { + ) && !cursor.is_over(layout.bounds()); + if escape || outside_press { shell.publish(self.on_dismiss.clone()); event::Status::Captured } else { diff --git a/specs/action_patterns.allium b/specs/action_patterns.allium index e32726e..7a69c35 100644 --- a/specs/action_patterns.allium +++ b/specs/action_patterns.allium @@ -59,14 +59,15 @@ rule AISuggestionFlow { rule AutoTranslationChain { when: PostSaved(post_id) -- Gate: AIOperationGating + post.doNotTranslate must be false - -- Triggered after any post save (auto-save, manual Ctrl+S, or unmount) + -- Triggered only by explicit manual save or publish, never by auto-save, + -- unmount/tab-switch persistence, post creation, import, or scripting. -- For each configured blogLanguage missing a translation for this post: - -- 1. Enqueue background task: translate metadata (title, excerpt) - -- via title model - -- 2. Enqueue background task: translate content (full markdown) - -- via title model - -- 3. Create/update translation record in DB - -- Tasks: sequential per language, parallel across languages + -- 1. Enqueue one background task for that language + -- 2. Translate metadata (title, excerpt) via title model + -- 3. Translate content (full markdown) via title model + -- 4. Create/update translation record in DB + -- A later manual save with no missing languages enqueues no task. + -- Work is sequential within a language and parallel across languages. -- Progress visible in Tasks panel }