From c4e5dbb8e6a019ed2a4b399c20d10340ea31040f Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 1 Aug 2026 12:18:54 +0200 Subject: [PATCH] Retry failed automatic translations once. --- README.md | 2 +- .../bds-core/src/engine/auto_translation.rs | 277 ++++++++++++++- crates/bds-core/src/engine/post.rs | 11 +- crates/bds-ui/src/app.rs | 316 ++++++++++++++---- crates/bds-ui/src/views/post_editor.rs | 62 ++++ crates/bds-ui/src/views/workspace.rs | 5 +- locales/ui/de.ftl | 2 + locales/ui/en.ftl | 2 + locales/ui/es.ftl | 2 + locales/ui/fr.ftl | 2 + locales/ui/it.ftl | 2 + specs/action_patterns.allium | 19 ++ specs/translation.allium | 15 +- 13 files changed, 642 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 97f0b09..9e9c86c 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The project is under active development. Core blogging workflows are broadly ava - `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection. - bDS2-compatible Markdown/Liquid rendering with built-in macros rendered from bundled Liquid templates in isolated scopes (customizable with `macros/*` partial-template slugs), project-description homepage headings, category-controlled list title visibility, descriptive category archive titles, canonical multilingual and flat page routes for every configured blog language, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, shared cached multicore full-site rendering, change-aware and forced full renders from the native Blog menu/CLI/TUI, and fast route/mtime-based incremental validation whose targeted repair refreshes affected aggregate pages through cancellable section task groups with bDS2-style per-URL progress. - Navigable generated-route preview in the app or system browser, with draft database overlays and published filesystem content. -- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations are available immediately after restart and run in background tasks with editor-level waiting indicators, using provider-portable JSON-only requests through independent online and local OpenAI-compatible profiles. Each profile has secure credentials, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and restart-persistent status-bar airplane-mode routing. +- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations are available immediately after restart and run in background tasks with editor-level waiting indicators, using provider-portable JSON-only requests through independent online and local OpenAI-compatible profiles. Automatic translation rejects blank AI results, retries one failed request with a visible warning, reports a failed retry, and refreshes open editors without discarding newer edits. Each profile has secure credentials, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and restart-persistent status-bar airplane-mode routing. - 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. diff --git a/crates/bds-core/src/engine/auto_translation.rs b/crates/bds-core/src/engine/auto_translation.rs index 60a9b9a..08df419 100644 --- a/crates/bds-core/src/engine/auto_translation.rs +++ b/crates/bds-core/src/engine/auto_translation.rs @@ -229,9 +229,9 @@ pub fn translate_missing_for_post( 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. +/// Translate one language from the configured target set. This is the reactive +/// editor path: generated translations remain drafts, and a retry after the +/// post translation was saved resumes any still-missing media translations. pub fn translate_missing_language_for_post( conn: &Connection, data_dir: &Path, @@ -240,10 +240,42 @@ pub fn translate_missing_language_for_post( language: &str, offline_mode: bool, is_cancelled: impl Fn() -> bool, +) -> EngineResult { + translate_missing_language_for_post_with( + conn, + data_dir, + post_id, + configured_languages, + language, + &mut |post, language| translate_post_ai(conn, offline_mode, post, language), + &mut |media, language| translate_media_ai(conn, offline_mode, media, language), + is_cancelled, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "testable translation orchestration dependencies" +)] +fn translate_missing_language_for_post_with( + conn: &Connection, + data_dir: &Path, + post_id: &str, + configured_languages: &[String], + language: &str, + post_translator: &mut dyn FnMut(&Post, &str) -> EngineResult, + media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult, + 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) { + let language = normalize_language(language); + let source = normalize_language(post.language.as_deref().unwrap_or("en")); + if post.do_not_translate + || language == source + || !configured_languages + .iter() + .any(|configured| normalize_language(configured) == language) + { return Ok(FillMissingTranslationsReport { nothing_to_do: true, ..Default::default() @@ -252,21 +284,36 @@ pub fn translate_missing_language_for_post( if is_cancelled() { return Err(EngineError::Validation("cancelled".to_string())); } + let translation_exists = + post_translation::get_post_translation_by_post_and_language(conn, &post.id, &language) + .is_ok(); let mut report = FillMissingTranslationsReport::default(); - merge_reactive_translation_result( - &mut report, - &post, - language, + let result = if translation_exists { + translate_missing_media(conn, data_dir, &post, &language, media_translator) + } else { 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), - ), - ); + post_translator, + media_translator, + ) + }; + match result { + Ok(media_count) => { + report.translated_posts += usize::from(!translation_exists); + report.translated_media += media_count; + report.nothing_to_do = translation_exists && media_count == 0; + } + Err(error) => { + report.failed_count += 1; + report + .errors + .push(format!("{} ({language}): {error}", post.title)); + } + } Ok(report) } @@ -309,6 +356,25 @@ fn translate_one_post( let mut input = post.clone(); input.content = Some(body); let translated = post_translator(&input, language)?; + for (field, source, translated_value) in [ + ("title", post.title.as_str(), translated.title.as_str()), + ( + "excerpt", + post.excerpt.as_deref().unwrap_or(""), + translated.excerpt.as_str(), + ), + ( + "content", + input.content.as_deref().unwrap_or(""), + translated.content.as_str(), + ), + ] { + if !source.trim().is_empty() && translated_value.trim().is_empty() { + return Err(EngineError::Validation(format!( + "post translation returned empty {field}" + ))); + } + } let translation = crate::engine::post::upsert_automatic_translation( conn, data_dir, @@ -322,6 +388,16 @@ fn translate_one_post( crate::engine::post::publish_post_translation(conn, data_dir, &translation.id)?; } + translate_missing_media(conn, data_dir, post, language, media_translator) +} + +fn translate_missing_media( + conn: &Connection, + data_dir: &Path, + post: &Post, + language: &str, + media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult, +) -> EngineResult { let mut translated_media = 0; for link in post_media::list_post_media_by_post(conn, &post.id)? { let media = qm::get_media_by_id(conn, &link.media_id)?; @@ -428,8 +504,10 @@ mod tests { use super::*; use crate::db::Database; use crate::db::fts::ensure_fts_tables; + use crate::db::queries::media::{insert_media, make_test_media}; use crate::db::queries::project::{insert_project, make_test_project}; use crate::engine::post::{create_post, publish_post, upsert_translation}; + use crate::model::PostMedia; use tempfile::TempDir; #[test] @@ -511,6 +589,100 @@ mod tests { } } + #[test] + fn blank_ai_translation_is_rejected_without_creating_a_record() { + 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(); + let result = translate_one_post( + db.conn(), + dir.path(), + &post, + "de", + false, + &mut |_post, _language| { + Ok(TranslationResult { + title: String::new(), + excerpt: String::new(), + content: String::new(), + }) + }, + &mut |_media, _language| unreachable!(), + ); + + assert!(result.is_err_and(|error| error.to_string().contains("empty"))); + assert!( + post_translation::get_post_translation_by_post_and_language(db.conn(), &post.id, "de") + .is_err() + ); + } + + #[test] + fn automatic_translation_notifies_open_post_consumers() { + 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(); + let events = crate::engine::domain_events::subscribe(); + + let translated_media = translate_one_post( + db.conn(), + dir.path(), + &post, + "de", + false, + &mut |_post, _language| { + Ok(TranslationResult { + title: "Hallo".into(), + excerpt: "".into(), + content: "Inhalt".into(), + }) + }, + &mut |_media, _language| unreachable!(), + ) + .unwrap(); + + assert_eq!(translated_media, 0); + assert!(events.drain().iter().any(|event| matches!( + event, + crate::model::DomainEvent::EntityChanged { + project_id, + entity: crate::model::DomainEntity::Post, + entity_id, + action: crate::model::NotificationAction::Updated, + } if project_id == "p1" && entity_id == &post.id + ))); + } + #[test] fn reactive_language_translation_is_a_no_op_when_translation_exists() { let db = Database::open_in_memory().unwrap(); @@ -557,6 +729,83 @@ mod tests { assert_eq!(report.translated_posts, 0); } + #[test] + fn reactive_retry_resumes_missing_media_after_post_translation_was_saved() { + 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 mut media = make_test_media("media-1", "p1"); + media.language = Some("en".to_string()); + media.file_path = "media/media-1.jpg".to_string(); + media.sidecar_path = "media/media-1.jpg.meta".to_string(); + insert_media(db.conn(), &media).unwrap(); + post_media::link_media( + db.conn(), + &PostMedia { + id: "link-1".to_string(), + project_id: "p1".to_string(), + post_id: post.id.clone(), + media_id: media.id.clone(), + sort_order: 0, + created_at: 1, + }, + ) + .unwrap(); + + let mut media_attempts = 0; + let report = translate_missing_language_for_post_with( + db.conn(), + dir.path(), + &post.id, + &["en".to_string(), "de".to_string()], + "de", + &mut |_, _| panic!("the existing post translation must not be regenerated"), + &mut |_, _| { + media_attempts += 1; + Ok(MediaTranslationResult { + title: "Foto".to_string(), + alt: "Bild".to_string(), + caption: "Beschreibung".to_string(), + }) + }, + || false, + ) + .unwrap(); + + assert_eq!(media_attempts, 1); + assert_eq!(report.translated_posts, 0); + assert_eq!(report.translated_media, 1); + assert!(!report.nothing_to_do); + assert!( + qmt::get_media_translation_by_media_and_language(db.conn(), &media.id, "de").is_ok() + ); + } + #[test] fn skips_do_not_translate_posts() { let db = Database::open_in_memory().unwrap(); diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index e0c5c84..87a8f70 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -582,9 +582,16 @@ pub(crate) fn upsert_automatic_translation( excerpt: Option<&str>, content: Option<&str>, ) -> EngineResult { - upsert_translation_with_mode( + let translation = upsert_translation_with_mode( conn, data_dir, post_id, language, title, excerpt, content, false, - ) + )?; + domain_events::entity_changed( + &translation.project_id, + DomainEntity::Post, + post_id, + NotificationAction::Updated, + ); + Ok(translation) } #[expect( diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 3b4bb8f..6a1e06f 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -85,6 +85,12 @@ enum BlogmarkImportEvent { Finished(Result), } +#[derive(Debug, Clone)] +enum AutoTranslationTaskEvent { + Retrying, + Finished(Result), +} + #[derive(Debug, Clone)] pub enum Message { // Menu @@ -186,6 +192,9 @@ pub enum Message { CancelTask(TaskSource, TaskId), RemoteTaskCancelled(Result<(), String>), ToggleTaskGroup(String), + AutoTranslationRetrying { + task_id: TaskId, + }, TagDeleted { task_id: TaskId, tag_id: String, @@ -2775,6 +2784,16 @@ impl BdsApp { } Task::none() } + Message::AutoTranslationRetrying { task_id } => { + if self.task_manager.status(task_id) == Some(TaskStatus::Running) { + let message = t(self.ui_locale, "engine.autoTranslationRetrying"); + self.task_manager + .report_progress(task_id, None, Some(message.clone())); + self.refresh_task_snapshots(); + self.notify(ToastLevel::Warning, &message); + } + Task::none() + } // ── macOS lifecycle ── Message::FileOpenRequested(path) => { @@ -4490,6 +4509,15 @@ impl BdsApp { DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => false, }; if dirty { + if entity == DomainEntity::Post + && let Some(db) = &self.db + && let Ok(post) = bds_core::db::queries::post::get_post_by_id(db.conn(), entity_id) + { + let translations = self.post_translations_for_editor(&post); + if let Some(editor) = self.post_editors.get_mut(entity_id) { + editor.merge_clean_translations(&translations); + } + } return; } match entity { @@ -6675,40 +6703,100 @@ impl BdsApp { if targets.is_empty() { return Task::none(); } - if engine::ai::active_endpoint(db.conn(), self.offline_mode).is_err() { + if !workspace::is_ai_enabled(self.settings_state.as_ref(), self.offline_mode) { return Task::none(); } let post_id = post_id.to_string(); let offline_mode = self.offline_mode; - let locale = self.ui_locale; 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())?; - Ok(tw( - locale, - "engine.autoTranslationComplete", - &[("count", &report.translated_posts.to_string())], - )) - }, + self.spawn_auto_translation_task( + post_id.clone(), + configured.clone(), + language, + offline_mode, ) })) } + fn spawn_auto_translation_task( + &mut self, + post_id: String, + configured_languages: Vec, + language: String, + offline_mode: bool, + ) -> Task { + let (Some(project_id), Some(data_dir)) = ( + self.active_project + .as_ref() + .map(|project| project.id.clone()), + self.data_dir.clone(), + ) else { + return Task::none(); + }; + let db_path = self.db_path.clone(); + let locale = self.ui_locale; + let label = t(locale, "engine.autoTranslationStarted"); + self.add_output(&label); + let task_id = self + .task_manager + .submit_grouped(&label, &format!("{project_id}:AI"), "AI"); + self.refresh_task_snapshots(); + let task_manager = Arc::clone(&self.task_manager); + let label_for_message = label.clone(); + let (sender, receiver) = futures::channel::mpsc::unbounded(); + let producer = Task::perform( + async move { + let Some(worker) = task_manager.admit(task_id).await else { + let _ = sender.unbounded_send(AutoTranslationTaskEvent::Finished(Err( + "cancelled".to_string(), + ))); + return; + }; + let retry_sender = sender.clone(); + let result = tokio::task::spawn_blocking(move || { + let _worker = worker; + let attempt = || { + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + let cancellation = Arc::clone(&task_manager); + let report = engine::auto_translation::translate_missing_language_for_post( + db.conn(), + &data_dir, + &post_id, + &configured_languages, + &language, + offline_mode, + move || cancellation.is_cancelled(task_id), + ) + .map_err(|error| error.to_string())?; + auto_translation_task_result(locale, report) + }; + retry_once(attempt, |_error| { + if task_manager.is_cancelled(task_id) { + false + } else { + let _ = retry_sender.unbounded_send(AutoTranslationTaskEvent::Retrying); + true + } + }) + }) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))); + let _ = sender.unbounded_send(AutoTranslationTaskEvent::Finished(result)); + }, + |_| Message::TaskTick, + ); + let consumer = Task::run(receiver, move |event| match event { + AutoTranslationTaskEvent::Retrying => Message::AutoTranslationRetrying { task_id }, + AutoTranslationTaskEvent::Finished(result) => Message::EngineTaskDone { + task_id, + operation: "engine.autoTranslationStarted", + label: label_for_message.clone(), + result, + }, + }); + Task::batch([producer, consumer]) + } + fn ensure_post_editor_tag(&mut self, post_id: &str, name: &str) -> Task { let (Some(db), Some(data_dir)) = (self.db.as_ref(), self.data_dir.as_ref()) else { return Task::none(); @@ -9316,6 +9404,36 @@ impl BdsApp { } } + fn post_translations_for_editor(&self, post: &Post) -> Vec { + let Some(db) = &self.db else { + return Vec::new(); + }; + let mut translations = + bds_core::db::queries::post_translation::list_post_translations_by_post( + db.conn(), + &post.id, + ) + .unwrap_or_default(); + if let Some(data_dir) = &self.data_dir { + for translation in &mut translations { + if translation.content.is_none() { + let path = data_dir.join(bds_core::util::paths::translation_file_path( + post.created_at, + &post.slug, + &translation.language, + )); + if let Ok(raw) = std::fs::read_to_string(path) + && let Ok((_frontmatter, body)) = + bds_core::util::frontmatter::read_translation_file(&raw) + { + translation.content = Some(body); + } + } + } + } + translations + } + /// Load editor state when a tab is opened for an entity. fn load_editor_for_tab(&mut self, tab: &Tab) { let Some(ref db) = self.db else { return }; @@ -9340,31 +9458,8 @@ impl BdsApp { post.content = Some(body); } } - // Load translations for translation flags bar - let mut translations = bds_core::db::queries::post_translation::list_post_translations_by_post( - db.conn(), &post.id, - ).unwrap_or_default(); - // Published translations don't store body in DB — read from file - if let Some(ref data_dir) = self.data_dir { - for tr in &mut translations { - if tr.content.is_none() { - let rel = bds_core::util::paths::translation_file_path( - post.created_at, - &post.slug, - &tr.language, - ); - let path = data_dir.join(&rel); - if let Ok(raw) = std::fs::read_to_string(&path) - && let Ok((_fm, body)) = - bds_core::util::frontmatter::read_translation_file( - &raw, - ) - { - tr.content = Some(body); - } - } - } - } + // Load translations for translation flags bar. + let translations = self.post_translations_for_editor(&post); let (outlinks, backlinks) = self.load_post_links(&post.id); let linked_media = self.load_post_media_items(&post.id, post.content.as_deref()); @@ -10619,6 +10714,34 @@ fn content_sample(content: &str, max_len: usize) -> String { content.chars().take(max_len).collect() } +fn auto_translation_task_result( + locale: UiLocale, + report: engine::auto_translation::FillMissingTranslationsReport, +) -> Result { + if report.failed_count > 0 { + return Err(if report.errors.is_empty() { + t(locale, "engine.autoTranslationFailed") + } else { + report.errors.join("; ") + }); + } + Ok(tw( + locale, + "engine.autoTranslationComplete", + &[("count", &report.translated_posts.to_string())], + )) +} + +fn retry_once( + mut attempt: impl FnMut() -> Result, + mut should_retry: impl FnMut(&str) -> bool, +) -> Result { + match attempt() { + Err(error) if should_retry(&error) => attempt(), + result => result, + } +} + fn dropped_image_target(active_tab: Option<&str>, tabs: &[Tab], path: &Path) -> Option { if !engine::media::is_supported_image_path(path) { return None; @@ -10673,12 +10796,13 @@ fn remote_error_closes_connection(code: &str) -> bool { mod tests { use super::{ BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState, - PostStatus, SettingsMsg, SiteGenerationKind, active_post_tab_id, dropped_image_target, - flush_embeddings_and_exit, localize_chat_error, month_abbreviation, - persist_media_editor_state_impl, persist_post_editor_preview_state_impl, - persist_post_editor_state_impl, remote_error_closes_connection, - save_editor_settings_state_impl, save_script_editor_state_impl, - save_template_editor_state_impl, should_start_embedded_preview_creation, + PostStatus, SettingsMsg, SiteGenerationKind, active_post_tab_id, + auto_translation_task_result, dropped_image_target, flush_embeddings_and_exit, + localize_chat_error, month_abbreviation, persist_media_editor_state_impl, + persist_post_editor_preview_state_impl, persist_post_editor_state_impl, + remote_error_closes_connection, retry_once, save_editor_settings_state_impl, + save_script_editor_state_impl, save_template_editor_state_impl, + should_start_embedded_preview_creation, }; use crate::i18n::t; use crate::platform::menu::MenuAction; @@ -13193,6 +13317,86 @@ mod tests { ); } + #[test] + fn failed_auto_translation_report_becomes_a_task_error() { + let report = engine::auto_translation::FillMissingTranslationsReport { + failed_count: 1, + errors: vec!["Original (de): provider unavailable".to_string()], + ..Default::default() + }; + + assert_eq!( + auto_translation_task_result(UiLocale::En, report), + Err("Original (de): provider unavailable".to_string()) + ); + } + + #[test] + fn auto_translation_retries_one_failure_once() { + let mut attempts = 0; + let mut retry_errors = Vec::new(); + + let result = retry_once( + || { + attempts += 1; + (attempts == 2) + .then_some("translated") + .ok_or_else(|| "first request failed".to_string()) + }, + |error| { + retry_errors.push(error.to_string()); + true + }, + ); + + assert_eq!(result, Ok("translated")); + assert_eq!(attempts, 2); + assert_eq!(retry_errors, vec!["first request failed"]); + } + + #[test] + fn auto_translation_retry_is_visible_as_a_warning_toast() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + let task_id = app.task_manager.submit("translation"); + + let _ = app.update(Message::AutoTranslationRetrying { task_id }); + + assert_eq!( + app.toasts.last().map(|toast| toast.message.as_str()), + Some("AI failed, retrying…") + ); + assert_eq!( + app.toasts.last().map(|toast| toast.level), + Some(ToastLevel::Warning) + ); + } + + #[test] + fn failed_auto_translation_retry_is_visible_as_an_error_toast() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + let label = t(UiLocale::En, "engine.autoTranslationStarted"); + let task_id = app.task_manager.submit(&label); + + let _ = app.update(Message::EngineTaskDone { + task_id, + operation: "engine.autoTranslationStarted", + label, + result: Err("Original (de): provider unavailable".to_string()), + }); + + assert_eq!( + app.toasts.last().map(|toast| toast.level), + Some(ToastLevel::Error) + ); + assert!( + app.toasts + .last() + .is_some_and(|toast| toast.message.contains("provider unavailable")) + ); + } + #[test] fn publish_does_not_enqueue_missing_translations() { let (mut app, post_id, _tmp) = auto_translation_test_app(&[]); diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 9957484..d32e325 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -227,6 +227,37 @@ impl PostEditorState { self.switch_language(&previous.active_language); } + pub fn merge_clean_translations(&mut self, translations: &[PostTranslation]) { + for translation in translations { + let language = &translation.language; + let dirty = if self.active_language == *language { + self.is_dirty + } else { + self.translation_drafts + .get(language) + .is_some_and(|draft| draft.is_dirty) + }; + if dirty { + continue; + } + let draft = TranslationDraft { + title: translation.title.clone(), + excerpt: translation.excerpt.clone().unwrap_or_default(), + content: translation.content.clone().unwrap_or_default(), + status: translation.status.clone(), + is_dirty: false, + }; + if self.active_language == *language { + self.title.clone_from(&draft.title); + self.excerpt.clone_from(&draft.excerpt); + self.content.clone_from(&draft.content); + self.editor_buffer = RefCell::new(EditorBuffer::new(&draft.content)); + self.is_dirty = false; + } + self.translation_drafts.insert(language.clone(), draft); + } + } + pub fn insert_markdown_at_cursor(&mut self, markdown: &str) { let new_content = { let mut buffer = self.editor_buffer.borrow_mut(); @@ -1443,4 +1474,35 @@ mod tests { assert!(content_actions_visible("markdown")); assert!(!content_actions_visible("preview")); } + + #[test] + fn translation_refresh_preserves_new_canonical_edits() { + let mut state = sample_state(); + state.content = "Unsaved follow-up".to_string(); + state.mark_dirty(); + let translation = PostTranslation { + id: "translation-1".into(), + project_id: "project-1".into(), + translation_for: "post-1".into(), + language: "de".into(), + title: "Beispiel".into(), + excerpt: None, + content: Some("Hallo Welt".into()), + status: PostStatus::Draft, + file_path: String::new(), + checksum: None, + created_at: 2, + updated_at: 2, + published_at: None, + }; + + state.merge_clean_translations(&[translation]); + + assert_eq!(state.content, "Unsaved follow-up"); + assert!(state.is_dirty); + assert_eq!( + state.translation_drafts["de"].content, + "Hallo Welt".to_string() + ); + } } diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index 9366696..63b4c9c 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -679,7 +679,10 @@ fn route_kind<'a>( } } -fn is_ai_enabled(settings_state: Option<&SettingsViewState>, offline_mode: bool) -> bool { +pub(crate) fn is_ai_enabled( + settings_state: Option<&SettingsViewState>, + offline_mode: bool, +) -> bool { let Some(state) = settings_state else { return false; }; diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 5438ad9..cca2085 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -657,6 +657,8 @@ engine-fillMissingTranslationsStarted = Fehlende Übersetzungen werden ergänzt engine-fillMissingTranslationsComplete = Übersetzung abgeschlossen: { $posts } Beiträge, { $media } Medien, { $failed } fehlgeschlagen engine-autoTranslationStarted = Fehlende Übersetzungen werden erstellt… engine-autoTranslationComplete = { $count } Übersetzungsentwürfe erstellt +engine-autoTranslationFailed = Automatische Übersetzung fehlgeschlagen +engine-autoTranslationRetrying = KI fehlgeschlagen, neuer Versuch… engine-progress-scanningPublishedPosts = Veröffentlichte Beiträge werden durchsucht… engine-progress-translatingPost = { $title } wird übersetzt → { $language } engine-progress-translationBatchComplete = Übersetzungsstapel abgeschlossen diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index 5a13df1..5058412 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -657,6 +657,8 @@ engine-fillMissingTranslationsStarted = Filling missing translations… engine-fillMissingTranslationsComplete = Translation batch complete: { $posts } posts, { $media } media, { $failed } failed engine-autoTranslationStarted = Creating missing translations… engine-autoTranslationComplete = Created { $count } draft translations +engine-autoTranslationFailed = Automatic translation failed +engine-autoTranslationRetrying = AI failed, retrying… engine-progress-scanningPublishedPosts = Scanning published posts… engine-progress-translatingPost = Translating { $title } → { $language } engine-progress-translationBatchComplete = Translation batch complete diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index 1885df6..d6593fc 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -657,6 +657,8 @@ engine-fillMissingTranslationsStarted = Creando las traducciones que faltan… engine-fillMissingTranslationsComplete = Traducción completada: { $posts } entradas, { $media } medios, { $failed } fallos engine-autoTranslationStarted = Creando las traducciones que faltan… engine-autoTranslationComplete = Se crearon { $count } borradores de traducción +engine-autoTranslationFailed = Error en la traducción automática +engine-autoTranslationRetrying = La IA ha fallado, reintentando… engine-progress-scanningPublishedPosts = Analizando las entradas publicadas… engine-progress-translatingPost = Traduciendo { $title } → { $language } engine-progress-translationBatchComplete = Lote de traducciones completado diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index ed8d646..569d50e 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -657,6 +657,8 @@ engine-fillMissingTranslationsStarted = Création des traductions manquantes… engine-fillMissingTranslationsComplete = Traduction terminée : { $posts } articles, { $media } médias, { $failed } échecs engine-autoTranslationStarted = Création des traductions manquantes… engine-autoTranslationComplete = { $count } brouillons de traduction créés +engine-autoTranslationFailed = Échec de la traduction automatique +engine-autoTranslationRetrying = Échec de l’IA, nouvelle tentative… engine-progress-scanningPublishedPosts = Analyse des articles publiés… engine-progress-translatingPost = Traduction de { $title } → { $language } engine-progress-translationBatchComplete = Lot de traductions terminé diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index 592d317..9c5eb94 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -657,6 +657,8 @@ engine-fillMissingTranslationsStarted = Creazione delle traduzioni mancanti… engine-fillMissingTranslationsComplete = Traduzione completata: { $posts } articoli, { $media } media, { $failed } errori engine-autoTranslationStarted = Creazione delle traduzioni mancanti… engine-autoTranslationComplete = Create { $count } bozze di traduzione +engine-autoTranslationFailed = Traduzione automatica non riuscita +engine-autoTranslationRetrying = Errore dell’IA, nuovo tentativo… engine-progress-scanningPublishedPosts = Scansione dei post pubblicati… engine-progress-translatingPost = Traduzione di { $title } → { $language } engine-progress-translationBatchComplete = Lotto di traduzioni completato diff --git a/specs/action_patterns.allium b/specs/action_patterns.allium index 0202eb2..3f44746 100644 --- a/specs/action_patterns.allium +++ b/specs/action_patterns.allium @@ -27,6 +27,13 @@ surface AiEngine { -- The active endpoint's response, parsed into per-field suggestions } +surface AutoTranslationRuntime { + facing _: AiRuntime + + provides: PostAutoTranslateAttemptFailed(post_id, language, reason) + provides: PostAutoTranslateRetryFailed(post_id, language, reason) +} + -- ─── AI operation gating ──────────────────────────────────── -- -- All AI operations route through the active endpoint for the current @@ -130,6 +137,18 @@ rule AutoTranslationChain { -- languages enqueues nothing (empty set). } +rule RetryFailedAutoTranslationOnce { + when: PostAutoTranslateAttemptFailed(post_id, language, reason) + ensures: ToastShown(message_key: "engine.autoTranslationRetrying") + ensures: PostAutoTranslateRetried(post_id, language, maximum_attempts: 2) +} + +rule ReportFailedAutoTranslationRetry { + when: PostAutoTranslateRetryFailed(post_id, language, reason) + ensures: BackgroundTaskFailed(post_id, language, reason) + ensures: ToastShown(level: "error", reason: reason) +} + rule MediaMetadataTranslationCascade { when: PostAutoTranslateCompleted(post_id, language) let saved_post = post/Post{id: post_id} diff --git a/specs/translation.allium b/specs/translation.allium index f58481f..624bc62 100644 --- a/specs/translation.allium +++ b/specs/translation.allium @@ -222,6 +222,12 @@ surface AutoTranslationControlSurface { FillMissingTranslationsRequested(project) } +surface AutoTranslationRuntime { + facing _: TranslationOperator + + provides: AutoTranslatePostCompleted(post, language) +} + invariant AutoTranslationGatedByEndpoint { -- No automatic translation runs unless an endpoint is resolvable for the -- current mode. Airplane mode needs url+model; online additionally needs an @@ -281,7 +287,14 @@ rule AutoTranslatePost { @guidance -- An empty body yields a no_content_to_translate error and no - -- translation is created. + -- translation is created. A response that leaves any non-empty source + -- field empty is also an error and is never persisted. +} + +rule RefreshCompletedAutomaticTranslation { + when: AutoTranslatePostCompleted(post, language) + ensures: OpenPostEditorTranslationsRefreshed(post, language) + ensures: UnsavedPostEditorContentPreserved(post) } rule AutoTranslateMediaCascade {