diff --git a/README.md b/README.md index 9e9c86c..efd0307 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ RuDS is a native Rust blogging desktop application and the successor to bDS2. It manages local projects from authoring through preview, static-site generation, integrity checks, and publishing while preserving the existing bDS filesystem and SQLite formats. -The desktop is a single-window application: closing its window persists UI state and exits RuDS. Background notifications remain unobtrusive and preserve keyboard focus while editing. +The desktop is a single-window application: closing its window persists UI state and exits RuDS. Background notifications remain unobtrusive and preserve keyboard focus while editing; errors stay visible until dismissed. The project is under active development. Core blogging workflows are broadly available; remaining core work and optional extensions are tracked separately. diff --git a/crates/bds-core/src/engine/ai.rs b/crates/bds-core/src/engine/ai.rs index 08f3b2f..74abca2 100644 --- a/crates/bds-core/src/engine/ai.rs +++ b/crates/bds-core/src/engine/ai.rs @@ -121,6 +121,42 @@ pub struct OneShotRequest { pub content: Value, } +pub fn post_translation_request( + title: &str, + excerpt: Option<&str>, + content: &str, + target_language: &str, +) -> OneShotRequest { + OneShotRequest { + operation: OneShotOperation::TranslatePost { + target_language: target_language.to_string(), + }, + content: json!({ + "title": title, + "excerpt": excerpt.unwrap_or_default(), + "content": content, + }), + } +} + +pub fn media_translation_request( + title: Option<&str>, + alt: Option<&str>, + caption: Option<&str>, + target_language: &str, +) -> OneShotRequest { + OneShotRequest { + operation: OneShotOperation::TranslateMedia { + target_language: target_language.to_string(), + }, + content: json!({ + "title": title.unwrap_or_default(), + "alt": alt.unwrap_or_default(), + "caption": caption.unwrap_or_default(), + }), + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TaxonomySuggestion { pub tags: Vec, @@ -1477,6 +1513,33 @@ mod tests { ); } + #[test] + fn translation_requests_normalize_missing_fields() { + let post = post_translation_request("Hello", None, "Body", "de"); + assert_eq!( + post.operation, + OneShotOperation::TranslatePost { + target_language: "de".to_string() + } + ); + assert_eq!( + post.content, + json!({"title": "Hello", "excerpt": "", "content": "Body"}) + ); + + let media = media_translation_request(None, None, None, "fr"); + assert_eq!( + media.operation, + OneShotOperation::TranslateMedia { + target_language: "fr".to_string() + } + ); + assert_eq!( + media.content, + json!({"title": "", "alt": "", "caption": ""}) + ); + } + #[test] fn run_one_shot_supports_image_analysis_via_airplane_endpoint() { let response = run_airplane_one_shot( diff --git a/crates/bds-core/src/engine/auto_translation.rs b/crates/bds-core/src/engine/auto_translation.rs index 08df419..55c0235 100644 --- a/crates/bds-core/src/engine/auto_translation.rs +++ b/crates/bds-core/src/engine/auto_translation.rs @@ -2,16 +2,11 @@ use std::collections::HashSet; use std::fs; use std::path::Path; -use serde_json::json; - use crate::db::DbConnection as Connection; use crate::db::queries::{ media as qm, media_translation as qmt, post as qp, post_media, post_translation, }; -use crate::engine::ai::{ - self, MediaTranslationResult, OneShotOperation, OneShotRequest, OneShotResponse, - TranslationResult, -}; +use crate::engine::ai::{self, MediaTranslationResult, OneShotResponse, TranslationResult}; use crate::engine::{EngineError, EngineResult}; use crate::i18n::{UiLocale, translate, translate_with}; use crate::model::{Media, Post, PostStatus}; @@ -445,16 +440,12 @@ fn translate_post_ai( match ai::run_one_shot( conn, offline_mode, - &OneShotRequest { - operation: OneShotOperation::TranslatePost { - target_language: language.to_string(), - }, - content: json!({ - "title": post.title, - "excerpt": post.excerpt, - "content": post.content, - }), - }, + &ai::post_translation_request( + &post.title, + post.excerpt.as_deref(), + post.content.as_deref().unwrap_or_default(), + language, + ), )? { (OneShotResponse::Translation(result), _usage) => Ok(result), _ => Err(EngineError::Parse( @@ -472,16 +463,12 @@ fn translate_media_ai( match ai::run_one_shot( conn, offline_mode, - &OneShotRequest { - operation: OneShotOperation::TranslateMedia { - target_language: language.to_string(), - }, - content: json!({ - "title": media.title, - "alt": media.alt, - "caption": media.caption, - }), - }, + &ai::media_translation_request( + media.title.as_deref(), + media.alt.as_deref(), + media.caption.as_deref(), + language, + ), )? { (OneShotResponse::MediaTranslation(result), _usage) => Ok(result), _ => Err(EngineError::Parse( diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 3de6e21..f20b2b9 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -10367,16 +10367,12 @@ impl BdsApp { } } - let request = ai::OneShotRequest { - operation: ai::OneShotOperation::TranslatePost { - target_language: target_language.to_string(), - }, - content: json!({ - "title": state.title, - "excerpt": state.excerpt, - "content": state.content, - }), - }; + let request = ai::post_translation_request( + &state.title, + Some(&state.excerpt), + &state.content, + target_language, + ); if let Some(editor) = self.post_editors.get_mut(post_id) { editor.ai_activity = Some(t(self.ui_locale, "editor.translate")); } @@ -10513,16 +10509,12 @@ impl BdsApp { { editor.language = state.canonical_language.clone(); } - let request = ai::OneShotRequest { - operation: ai::OneShotOperation::TranslateMedia { - target_language: target_language.to_string(), - }, - content: json!({ - "title": state.title, - "alt": state.alt, - "caption": state.caption, - }), - }; + let request = ai::media_translation_request( + Some(&state.title), + Some(&state.alt), + Some(&state.caption), + target_language, + ); if let Some(editor) = self.media_editors.get_mut(media_id) { editor.ai_activity = Some(t(self.ui_locale, "editor.translate")); } diff --git a/crates/bds-ui/src/state/toast.rs b/crates/bds-ui/src/state/toast.rs index 318b051..65114c2 100644 --- a/crates/bds-ui/src/state/toast.rs +++ b/crates/bds-ui/src/state/toast.rs @@ -1,8 +1,7 @@ /// Toast notification state. /// -/// Toasts are ephemeral, auto-dismissing messages shown at the top of -/// the workspace. Each toast has a severity level, a message, and a -/// monotonically increasing id used for targeted dismissal. +/// Toasts are messages shown at the top of the workspace. Errors remain until +/// dismissed; other levels expire automatically. use std::sync::atomic::{AtomicU64, Ordering}; static NEXT_TOAST_ID: AtomicU64 = AtomicU64::new(1); @@ -45,6 +44,9 @@ impl Toast { /// Whether this toast has exceeded its display duration. pub fn is_expired(&self) -> bool { + if self.level == ToastLevel::Error { + return false; + } let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -69,4 +71,11 @@ mod tests { let t = Toast::new(ToastLevel::Info, "test".into()); assert!(!t.is_expired()); } + + #[test] + fn error_toast_never_expires_automatically() { + let mut toast = Toast::new(ToastLevel::Error, "important".into()); + toast.created_at = 0; + assert!(!toast.is_expired()); + } }