Schedule only missing post translations
This commit is contained in:
@@ -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.
|
- 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 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.
|
- 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::{LazyLock, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::db::DbConnection as Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
@@ -13,6 +14,8 @@ use crate::util::now_unix_ms;
|
|||||||
|
|
||||||
const KEYRING_SERVICE: &str = "RuDS";
|
const KEYRING_SERVICE: &str = "RuDS";
|
||||||
const KEYRING_SETTING_PREFIX: &str = "ai.endpoint";
|
const KEYRING_SETTING_PREFIX: &str = "ai.endpoint";
|
||||||
|
static TEST_API_KEYS: LazyLock<Mutex<BTreeMap<String, String>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(BTreeMap::new()));
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -246,15 +249,11 @@ fn save_endpoint_api_key_at(
|
|||||||
api_key: &str,
|
api_key: &str,
|
||||||
updated_at: i64,
|
updated_at: i64,
|
||||||
) -> EngineResult<()> {
|
) -> EngineResult<()> {
|
||||||
let entry = endpoint_keyring_entry(kind)?;
|
|
||||||
let configured = !api_key.trim().is_empty();
|
let configured = !api_key.trim().is_empty();
|
||||||
if configured {
|
if configured {
|
||||||
entry.set_password(api_key.trim()).map_err(keyring_error)?;
|
store_endpoint_api_key(kind, api_key.trim())?;
|
||||||
} else {
|
} else {
|
||||||
match entry.delete_credential() {
|
delete_endpoint_api_key(kind)?;
|
||||||
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
|
||||||
Err(error) => return Err(keyring_error(error)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
set_setting(
|
set_setting(
|
||||||
conn,
|
conn,
|
||||||
@@ -397,8 +396,12 @@ pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<Ai
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let api_key = if stored.api_key_configured {
|
let api_key = if stored.api_key_configured {
|
||||||
let entry = endpoint_keyring_entry(kind)?;
|
let password = read_endpoint_api_key(kind)?.ok_or_else(|| {
|
||||||
let password = entry.get_password().map_err(keyring_error)?;
|
EngineError::Validation(format!(
|
||||||
|
"AI unavailable - configure {} endpoint in Settings",
|
||||||
|
kind.as_str()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
if password.trim().is_empty() {
|
if password.trim().is_empty() {
|
||||||
return Err(EngineError::Validation(format!(
|
return Err(EngineError::Validation(format!(
|
||||||
"AI unavailable - configure {} endpoint in Settings",
|
"AI unavailable - configure {} endpoint in Settings",
|
||||||
@@ -419,13 +422,7 @@ pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<Ai
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_endpoint_api_key(kind: AiEndpointKind) -> EngineResult<Option<String>> {
|
pub fn load_endpoint_api_key(kind: AiEndpointKind) -> EngineResult<Option<String>> {
|
||||||
let entry = endpoint_keyring_entry(kind)?;
|
read_endpoint_api_key(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)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<AiModelInfo>> {
|
pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<AiModelInfo>> {
|
||||||
@@ -934,6 +931,64 @@ fn endpoint_keyring_entry(kind: AiEndpointKind) -> EngineResult<Entry> {
|
|||||||
.map_err(keyring_error)
|
.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<Option<String>> {
|
||||||
|
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 {
|
fn keyring_error(error: keyring::Error) -> EngineError {
|
||||||
EngineError::Validation(error.to_string())
|
EngineError::Validation(error.to_string())
|
||||||
}
|
}
|
||||||
@@ -1024,8 +1079,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn clear_keyring(kind: AiEndpointKind) {
|
fn clear_keyring(kind: AiEndpointKind) {
|
||||||
let entry = endpoint_keyring_entry(kind).unwrap();
|
delete_endpoint_api_key(kind).unwrap();
|
||||||
entry.delete_credential().ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1039,7 +1093,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
|
|
||||||
fn saves_online_endpoint_with_keychain_secret() {
|
fn saves_online_endpoint_with_keychain_secret() {
|
||||||
clear_keyring(AiEndpointKind::Online);
|
clear_keyring(AiEndpointKind::Online);
|
||||||
let db = setup();
|
let db = setup();
|
||||||
@@ -1245,7 +1298,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
|
|
||||||
fn run_one_shot_uses_active_endpoint_and_parses_response() {
|
fn run_one_shot_uses_active_endpoint_and_parses_response() {
|
||||||
clear_keyring(AiEndpointKind::Online);
|
clear_keyring(AiEndpointKind::Online);
|
||||||
let server = spawn_test_server(
|
let server = spawn_test_server(
|
||||||
|
|||||||
@@ -178,7 +178,11 @@ pub fn translate_missing_for_post(
|
|||||||
if is_cancelled() {
|
if is_cancelled() {
|
||||||
return Err(EngineError::Validation("cancelled".to_string()));
|
return Err(EngineError::Validation("cancelled".to_string()));
|
||||||
}
|
}
|
||||||
let result = translate_one_post(
|
merge_reactive_translation_result(
|
||||||
|
&mut report,
|
||||||
|
&post,
|
||||||
|
&language,
|
||||||
|
translate_one_post(
|
||||||
conn,
|
conn,
|
||||||
data_dir,
|
data_dir,
|
||||||
&post,
|
&post,
|
||||||
@@ -186,7 +190,59 @@ pub fn translate_missing_for_post(
|
|||||||
false,
|
false,
|
||||||
&mut |post, language| translate_post_ai(conn, offline_mode, post, language),
|
&mut |post, language| translate_post_ai(conn, offline_mode, post, language),
|
||||||
&mut |media, language| translate_media_ai(conn, offline_mode, media, 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<FillMissingTranslationsReport> {
|
||||||
|
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,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_reactive_translation_result(
|
||||||
|
report: &mut FillMissingTranslationsReport,
|
||||||
|
post: &Post,
|
||||||
|
language: &str,
|
||||||
|
result: EngineResult<usize>,
|
||||||
|
) {
|
||||||
match result {
|
match result {
|
||||||
Ok(media_count) => {
|
Ok(media_count) => {
|
||||||
report.translated_posts += 1;
|
report.translated_posts += 1;
|
||||||
@@ -199,8 +255,6 @@ pub fn translate_missing_for_post(
|
|||||||
.push(format!("{} ({language}): {error}", post.title));
|
.push(format!("{} ({language}): {error}", post.title));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(report)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn translate_one_post(
|
fn translate_one_post(
|
||||||
@@ -342,7 +396,7 @@ mod tests {
|
|||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::db::fts::ensure_fts_tables;
|
use crate::db::fts::ensure_fts_tables;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
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;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[test]
|
#[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]
|
#[test]
|
||||||
fn skips_do_not_translate_posts() {
|
fn skips_do_not_translate_posts() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
|||||||
@@ -5638,12 +5638,22 @@ impl BdsApp {
|
|||||||
|
|
||||||
// ── Editor save/publish helpers ──
|
// ── Editor save/publish helpers ──
|
||||||
|
|
||||||
fn save_post_editor(&mut self, post_id: &str) -> Task<Message> {
|
fn save_post_editor(
|
||||||
|
&mut self,
|
||||||
|
post_id: &str,
|
||||||
|
schedule_auto_translation: bool,
|
||||||
|
) -> Task<Message> {
|
||||||
|
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) {
|
match self.persist_post_editor_state(post_id) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||||
|
if schedule_auto_translation && saves_canonical_post {
|
||||||
return self.schedule_post_auto_translation(post_id);
|
return self.schedule_post_auto_translation(post_id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Err(e) => self.notify_operation_failed("common.save", e),
|
Err(e) => self.notify_operation_failed("common.save", e),
|
||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
@@ -5954,28 +5964,46 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn schedule_post_auto_translation(&mut self, post_id: &str) -> Task<Message> {
|
fn schedule_post_auto_translation(&mut self, post_id: &str) -> Task<Message> {
|
||||||
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();
|
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() {
|
if engine::ai::active_endpoint(db.conn(), self.offline_mode).is_err() {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
let post_id = post_id.to_string();
|
let post_id = post_id.to_string();
|
||||||
let offline_mode = self.offline_mode;
|
let offline_mode = self.offline_mode;
|
||||||
let locale = self.ui_locale;
|
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(
|
self.spawn_grouped_engine_task(
|
||||||
"engine.autoTranslationStarted",
|
"engine.autoTranslationStarted",
|
||||||
"AI",
|
"AI",
|
||||||
move |db_path, _project_id, data_dir, task_manager, task_id| {
|
move |db_path, _project_id, data_dir, task_manager, task_id| {
|
||||||
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 meta = engine::meta::read_project_json(&data_dir)
|
let report = engine::auto_translation::translate_missing_language_for_post(
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
let report = engine::auto_translation::translate_missing_for_post(
|
|
||||||
db.conn(),
|
db.conn(),
|
||||||
&data_dir,
|
&data_dir,
|
||||||
&post_id,
|
&post_id,
|
||||||
meta.main_language.as_deref().unwrap_or("en"),
|
&configured,
|
||||||
&meta.blog_languages,
|
&language,
|
||||||
offline_mode,
|
offline_mode,
|
||||||
move || task_manager.is_cancelled(task_id),
|
move || task_manager.is_cancelled(task_id),
|
||||||
)
|
)
|
||||||
@@ -5987,6 +6015,7 @@ impl BdsApp {
|
|||||||
))
|
))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_link_modal(&mut self, post_id: &str) -> Task<Message> {
|
fn insert_link_modal(&mut self, post_id: &str) -> Task<Message> {
|
||||||
@@ -6177,7 +6206,7 @@ impl BdsApp {
|
|||||||
tab.is_dirty = true;
|
tab.is_dirty = true;
|
||||||
}
|
}
|
||||||
self.active_modal = None;
|
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<Message> {
|
fn insert_selected_post_link(&mut self, post_id: &str, linked_post_id: &str) -> Task<Message> {
|
||||||
@@ -9617,8 +9646,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
|
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
|
||||||
let mut settings = SettingsViewState::default();
|
let settings = SettingsViewState {
|
||||||
settings.default_mode = "preview".to_string();
|
default_mode: "preview".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
app.settings_state = Some(settings);
|
app.settings_state = Some(settings);
|
||||||
app.offline_mode = true;
|
app.offline_mode = true;
|
||||||
app.sidebar_view = SidebarView::Settings;
|
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]
|
#[test]
|
||||||
fn switching_tabs_flushes_active_post_editor() {
|
fn switching_tabs_flushes_active_post_editor() {
|
||||||
let (db, project, tmp) = setup();
|
let (db, project, tmp) = setup();
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ impl BdsApp {
|
|||||||
post_id,
|
post_id,
|
||||||
target_language,
|
target_language,
|
||||||
} => self.translate_post_to(&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::Publish(tab_id) => self.publish_post_editor(&tab_id),
|
||||||
DeferredPostAction::Discard(tab_id) => self.discard_post_editor(&tab_id),
|
DeferredPostAction::Discard(tab_id) => self.discard_post_editor(&tab_id),
|
||||||
DeferredPostAction::ShowDelete { tab_id, name } => {
|
DeferredPostAction::ShowDelete { tab_id, name } => {
|
||||||
|
|||||||
@@ -274,27 +274,19 @@ where
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dismiss = matches!(
|
let escape = 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,
|
||||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||||
key: Key::Named(key::Named::Escape),
|
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 { .. })
|
||||||
|
) && !cursor.is_over(layout.bounds());
|
||||||
|
if escape || outside_press {
|
||||||
shell.publish(self.on_dismiss.clone());
|
shell.publish(self.on_dismiss.clone());
|
||||||
event::Status::Captured
|
event::Status::Captured
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -59,14 +59,15 @@ rule AISuggestionFlow {
|
|||||||
rule AutoTranslationChain {
|
rule AutoTranslationChain {
|
||||||
when: PostSaved(post_id)
|
when: PostSaved(post_id)
|
||||||
-- Gate: AIOperationGating + post.doNotTranslate must be false
|
-- 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:
|
-- For each configured blogLanguage missing a translation for this post:
|
||||||
-- 1. Enqueue background task: translate metadata (title, excerpt)
|
-- 1. Enqueue one background task for that language
|
||||||
-- via title model
|
-- 2. Translate metadata (title, excerpt) via title model
|
||||||
-- 2. Enqueue background task: translate content (full markdown)
|
-- 3. Translate content (full markdown) via title model
|
||||||
-- via title model
|
-- 4. Create/update translation record in DB
|
||||||
-- 3. Create/update translation record in DB
|
-- A later manual save with no missing languages enqueues no task.
|
||||||
-- Tasks: sequential per language, parallel across languages
|
-- Work is sequential within a language and parallel across languages.
|
||||||
-- Progress visible in Tasks panel
|
-- Progress visible in Tasks panel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user