Retry failed automatic translations once.
This commit is contained in:
@@ -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.
|
- `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.
|
- 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.
|
- 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.
|
- Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript.
|
||||||
- SSH-agent-based SCP or rsync publishing.
|
- SSH-agent-based SCP or rsync publishing.
|
||||||
- Integrated Git workflow for each blog project's current repository, with repository initialization, read-only origin discovery, Git LFS image tracking, status and diffs, branch/file history with bDS2-compatible sync-status colors, commits, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode.
|
- Integrated Git workflow for each blog project's current repository, with repository initialization, read-only origin discovery, Git LFS image tracking, status and diffs, branch/file history with bDS2-compatible sync-status colors, commits, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode.
|
||||||
|
|||||||
@@ -229,9 +229,9 @@ pub fn translate_missing_for_post(
|
|||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Translate one language from the current missing-language set. This is the
|
/// Translate one language from the configured target set. This is the reactive
|
||||||
/// reactive editor path: already-created or unconfigured translations are a
|
/// editor path: generated translations remain drafts, and a retry after the
|
||||||
/// silent no-op, and generated translations remain drafts.
|
/// post translation was saved resumes any still-missing media translations.
|
||||||
pub fn translate_missing_language_for_post(
|
pub fn translate_missing_language_for_post(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
@@ -240,10 +240,42 @@ pub fn translate_missing_language_for_post(
|
|||||||
language: &str,
|
language: &str,
|
||||||
offline_mode: bool,
|
offline_mode: bool,
|
||||||
is_cancelled: impl Fn() -> bool,
|
is_cancelled: impl Fn() -> bool,
|
||||||
|
) -> EngineResult<FillMissingTranslationsReport> {
|
||||||
|
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<TranslationResult>,
|
||||||
|
media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult<MediaTranslationResult>,
|
||||||
|
is_cancelled: impl Fn() -> bool,
|
||||||
) -> EngineResult<FillMissingTranslationsReport> {
|
) -> EngineResult<FillMissingTranslationsReport> {
|
||||||
let post = qp::get_post_by_id(conn, post_id)?;
|
let post = qp::get_post_by_id(conn, post_id)?;
|
||||||
let targets = missing_languages(conn, &post, configured_languages)?;
|
let language = normalize_language(language);
|
||||||
if !targets.iter().any(|target| target == 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 {
|
return Ok(FillMissingTranslationsReport {
|
||||||
nothing_to_do: true,
|
nothing_to_do: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -252,21 +284,36 @@ pub fn translate_missing_language_for_post(
|
|||||||
if is_cancelled() {
|
if is_cancelled() {
|
||||||
return Err(EngineError::Validation("cancelled".to_string()));
|
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();
|
let mut report = FillMissingTranslationsReport::default();
|
||||||
merge_reactive_translation_result(
|
let result = if translation_exists {
|
||||||
&mut report,
|
translate_missing_media(conn, data_dir, &post, &language, media_translator)
|
||||||
&post,
|
} else {
|
||||||
language,
|
|
||||||
translate_one_post(
|
translate_one_post(
|
||||||
conn,
|
conn,
|
||||||
data_dir,
|
data_dir,
|
||||||
&post,
|
&post,
|
||||||
language,
|
&language,
|
||||||
false,
|
false,
|
||||||
&mut |post, language| translate_post_ai(conn, offline_mode, post, language),
|
post_translator,
|
||||||
&mut |media, language| translate_media_ai(conn, offline_mode, media, language),
|
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)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +356,25 @@ fn translate_one_post(
|
|||||||
let mut input = post.clone();
|
let mut input = post.clone();
|
||||||
input.content = Some(body);
|
input.content = Some(body);
|
||||||
let translated = post_translator(&input, language)?;
|
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(
|
let translation = crate::engine::post::upsert_automatic_translation(
|
||||||
conn,
|
conn,
|
||||||
data_dir,
|
data_dir,
|
||||||
@@ -322,6 +388,16 @@ fn translate_one_post(
|
|||||||
crate::engine::post::publish_post_translation(conn, data_dir, &translation.id)?;
|
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<MediaTranslationResult>,
|
||||||
|
) -> EngineResult<usize> {
|
||||||
let mut translated_media = 0;
|
let mut translated_media = 0;
|
||||||
for link in post_media::list_post_media_by_post(conn, &post.id)? {
|
for link in post_media::list_post_media_by_post(conn, &post.id)? {
|
||||||
let media = qm::get_media_by_id(conn, &link.media_id)?;
|
let media = qm::get_media_by_id(conn, &link.media_id)?;
|
||||||
@@ -428,8 +504,10 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
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::media::{insert_media, make_test_media};
|
||||||
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, upsert_translation};
|
use crate::engine::post::{create_post, publish_post, upsert_translation};
|
||||||
|
use crate::model::PostMedia;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[test]
|
#[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]
|
#[test]
|
||||||
fn reactive_language_translation_is_a_no_op_when_translation_exists() {
|
fn reactive_language_translation_is_a_no_op_when_translation_exists() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
@@ -557,6 +729,83 @@ mod tests {
|
|||||||
assert_eq!(report.translated_posts, 0);
|
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]
|
#[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();
|
||||||
|
|||||||
@@ -582,9 +582,16 @@ pub(crate) fn upsert_automatic_translation(
|
|||||||
excerpt: Option<&str>,
|
excerpt: Option<&str>,
|
||||||
content: Option<&str>,
|
content: Option<&str>,
|
||||||
) -> EngineResult<PostTranslation> {
|
) -> EngineResult<PostTranslation> {
|
||||||
upsert_translation_with_mode(
|
let translation = upsert_translation_with_mode(
|
||||||
conn, data_dir, post_id, language, title, excerpt, content, false,
|
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(
|
#[expect(
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ enum BlogmarkImportEvent {
|
|||||||
Finished(Result<engine::blogmark::BlogmarkImportResult, String>),
|
Finished(Result<engine::blogmark::BlogmarkImportResult, String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum AutoTranslationTaskEvent {
|
||||||
|
Retrying,
|
||||||
|
Finished(Result<String, String>),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
// Menu
|
// Menu
|
||||||
@@ -186,6 +192,9 @@ pub enum Message {
|
|||||||
CancelTask(TaskSource, TaskId),
|
CancelTask(TaskSource, TaskId),
|
||||||
RemoteTaskCancelled(Result<(), String>),
|
RemoteTaskCancelled(Result<(), String>),
|
||||||
ToggleTaskGroup(String),
|
ToggleTaskGroup(String),
|
||||||
|
AutoTranslationRetrying {
|
||||||
|
task_id: TaskId,
|
||||||
|
},
|
||||||
TagDeleted {
|
TagDeleted {
|
||||||
task_id: TaskId,
|
task_id: TaskId,
|
||||||
tag_id: String,
|
tag_id: String,
|
||||||
@@ -2775,6 +2784,16 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
Task::none()
|
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 ──
|
// ── macOS lifecycle ──
|
||||||
Message::FileOpenRequested(path) => {
|
Message::FileOpenRequested(path) => {
|
||||||
@@ -4490,6 +4509,15 @@ impl BdsApp {
|
|||||||
DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => false,
|
DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => false,
|
||||||
};
|
};
|
||||||
if dirty {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
match entity {
|
match entity {
|
||||||
@@ -6675,40 +6703,100 @@ impl BdsApp {
|
|||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
return Task::none();
|
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();
|
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;
|
|
||||||
Task::batch(targets.into_iter().map(|language| {
|
Task::batch(targets.into_iter().map(|language| {
|
||||||
let post_id = post_id.clone();
|
self.spawn_auto_translation_task(
|
||||||
let configured = configured.clone();
|
post_id.clone(),
|
||||||
self.spawn_grouped_engine_task(
|
configured.clone(),
|
||||||
"engine.autoTranslationStarted",
|
language,
|
||||||
"AI",
|
offline_mode,
|
||||||
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())],
|
|
||||||
))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_auto_translation_task(
|
||||||
|
&mut self,
|
||||||
|
post_id: String,
|
||||||
|
configured_languages: Vec<String>,
|
||||||
|
language: String,
|
||||||
|
offline_mode: bool,
|
||||||
|
) -> Task<Message> {
|
||||||
|
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<Message> {
|
fn ensure_post_editor_tag(&mut self, post_id: &str, name: &str) -> Task<Message> {
|
||||||
let (Some(db), Some(data_dir)) = (self.db.as_ref(), self.data_dir.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();
|
||||||
@@ -9316,6 +9404,36 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn post_translations_for_editor(&self, post: &Post) -> Vec<PostTranslation> {
|
||||||
|
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.
|
/// Load editor state when a tab is opened for an entity.
|
||||||
fn load_editor_for_tab(&mut self, tab: &Tab) {
|
fn load_editor_for_tab(&mut self, tab: &Tab) {
|
||||||
let Some(ref db) = self.db else { return };
|
let Some(ref db) = self.db else { return };
|
||||||
@@ -9340,31 +9458,8 @@ impl BdsApp {
|
|||||||
post.content = Some(body);
|
post.content = Some(body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Load translations for translation flags bar
|
// Load translations for translation flags bar.
|
||||||
let mut translations = bds_core::db::queries::post_translation::list_post_translations_by_post(
|
let translations = self.post_translations_for_editor(&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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
||||||
let linked_media =
|
let linked_media =
|
||||||
self.load_post_media_items(&post.id, post.content.as_deref());
|
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()
|
content.chars().take(max_len).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn auto_translation_task_result(
|
||||||
|
locale: UiLocale,
|
||||||
|
report: engine::auto_translation::FillMissingTranslationsReport,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
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<T>(
|
||||||
|
mut attempt: impl FnMut() -> Result<T, String>,
|
||||||
|
mut should_retry: impl FnMut(&str) -> bool,
|
||||||
|
) -> Result<T, String> {
|
||||||
|
match attempt() {
|
||||||
|
Err(error) if should_retry(&error) => attempt(),
|
||||||
|
result => result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn dropped_image_target(active_tab: Option<&str>, tabs: &[Tab], path: &Path) -> Option<String> {
|
fn dropped_image_target(active_tab: Option<&str>, tabs: &[Tab], path: &Path) -> Option<String> {
|
||||||
if !engine::media::is_supported_image_path(path) {
|
if !engine::media::is_supported_image_path(path) {
|
||||||
return None;
|
return None;
|
||||||
@@ -10673,12 +10796,13 @@ fn remote_error_closes_connection(code: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState,
|
BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState,
|
||||||
PostStatus, SettingsMsg, SiteGenerationKind, active_post_tab_id, dropped_image_target,
|
PostStatus, SettingsMsg, SiteGenerationKind, active_post_tab_id,
|
||||||
flush_embeddings_and_exit, localize_chat_error, month_abbreviation,
|
auto_translation_task_result, dropped_image_target, flush_embeddings_and_exit,
|
||||||
persist_media_editor_state_impl, persist_post_editor_preview_state_impl,
|
localize_chat_error, month_abbreviation, persist_media_editor_state_impl,
|
||||||
persist_post_editor_state_impl, remote_error_closes_connection,
|
persist_post_editor_preview_state_impl, persist_post_editor_state_impl,
|
||||||
save_editor_settings_state_impl, save_script_editor_state_impl,
|
remote_error_closes_connection, retry_once, save_editor_settings_state_impl,
|
||||||
save_template_editor_state_impl, should_start_embedded_preview_creation,
|
save_script_editor_state_impl, save_template_editor_state_impl,
|
||||||
|
should_start_embedded_preview_creation,
|
||||||
};
|
};
|
||||||
use crate::i18n::t;
|
use crate::i18n::t;
|
||||||
use crate::platform::menu::MenuAction;
|
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]
|
#[test]
|
||||||
fn publish_does_not_enqueue_missing_translations() {
|
fn publish_does_not_enqueue_missing_translations() {
|
||||||
let (mut app, post_id, _tmp) = auto_translation_test_app(&[]);
|
let (mut app, post_id, _tmp) = auto_translation_test_app(&[]);
|
||||||
|
|||||||
@@ -227,6 +227,37 @@ impl PostEditorState {
|
|||||||
self.switch_language(&previous.active_language);
|
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) {
|
pub fn insert_markdown_at_cursor(&mut self, markdown: &str) {
|
||||||
let new_content = {
|
let new_content = {
|
||||||
let mut buffer = self.editor_buffer.borrow_mut();
|
let mut buffer = self.editor_buffer.borrow_mut();
|
||||||
@@ -1443,4 +1474,35 @@ mod tests {
|
|||||||
assert!(content_actions_visible("markdown"));
|
assert!(content_actions_visible("markdown"));
|
||||||
assert!(!content_actions_visible("preview"));
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
let Some(state) = settings_state else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -657,6 +657,8 @@ engine-fillMissingTranslationsStarted = Fehlende Übersetzungen werden ergänzt
|
|||||||
engine-fillMissingTranslationsComplete = Übersetzung abgeschlossen: { $posts } Beiträge, { $media } Medien, { $failed } fehlgeschlagen
|
engine-fillMissingTranslationsComplete = Übersetzung abgeschlossen: { $posts } Beiträge, { $media } Medien, { $failed } fehlgeschlagen
|
||||||
engine-autoTranslationStarted = Fehlende Übersetzungen werden erstellt…
|
engine-autoTranslationStarted = Fehlende Übersetzungen werden erstellt…
|
||||||
engine-autoTranslationComplete = { $count } Übersetzungsentwürfe 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-scanningPublishedPosts = Veröffentlichte Beiträge werden durchsucht…
|
||||||
engine-progress-translatingPost = { $title } wird übersetzt → { $language }
|
engine-progress-translatingPost = { $title } wird übersetzt → { $language }
|
||||||
engine-progress-translationBatchComplete = Übersetzungsstapel abgeschlossen
|
engine-progress-translationBatchComplete = Übersetzungsstapel abgeschlossen
|
||||||
|
|||||||
@@ -657,6 +657,8 @@ engine-fillMissingTranslationsStarted = Filling missing translations…
|
|||||||
engine-fillMissingTranslationsComplete = Translation batch complete: { $posts } posts, { $media } media, { $failed } failed
|
engine-fillMissingTranslationsComplete = Translation batch complete: { $posts } posts, { $media } media, { $failed } failed
|
||||||
engine-autoTranslationStarted = Creating missing translations…
|
engine-autoTranslationStarted = Creating missing translations…
|
||||||
engine-autoTranslationComplete = Created { $count } draft 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-scanningPublishedPosts = Scanning published posts…
|
||||||
engine-progress-translatingPost = Translating { $title } → { $language }
|
engine-progress-translatingPost = Translating { $title } → { $language }
|
||||||
engine-progress-translationBatchComplete = Translation batch complete
|
engine-progress-translationBatchComplete = Translation batch complete
|
||||||
|
|||||||
@@ -657,6 +657,8 @@ engine-fillMissingTranslationsStarted = Creando las traducciones que faltan…
|
|||||||
engine-fillMissingTranslationsComplete = Traducción completada: { $posts } entradas, { $media } medios, { $failed } fallos
|
engine-fillMissingTranslationsComplete = Traducción completada: { $posts } entradas, { $media } medios, { $failed } fallos
|
||||||
engine-autoTranslationStarted = Creando las traducciones que faltan…
|
engine-autoTranslationStarted = Creando las traducciones que faltan…
|
||||||
engine-autoTranslationComplete = Se crearon { $count } borradores de traducción
|
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-scanningPublishedPosts = Analizando las entradas publicadas…
|
||||||
engine-progress-translatingPost = Traduciendo { $title } → { $language }
|
engine-progress-translatingPost = Traduciendo { $title } → { $language }
|
||||||
engine-progress-translationBatchComplete = Lote de traducciones completado
|
engine-progress-translationBatchComplete = Lote de traducciones completado
|
||||||
|
|||||||
@@ -657,6 +657,8 @@ engine-fillMissingTranslationsStarted = Création des traductions manquantes…
|
|||||||
engine-fillMissingTranslationsComplete = Traduction terminée : { $posts } articles, { $media } médias, { $failed } échecs
|
engine-fillMissingTranslationsComplete = Traduction terminée : { $posts } articles, { $media } médias, { $failed } échecs
|
||||||
engine-autoTranslationStarted = Création des traductions manquantes…
|
engine-autoTranslationStarted = Création des traductions manquantes…
|
||||||
engine-autoTranslationComplete = { $count } brouillons de traduction créés
|
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-scanningPublishedPosts = Analyse des articles publiés…
|
||||||
engine-progress-translatingPost = Traduction de { $title } → { $language }
|
engine-progress-translatingPost = Traduction de { $title } → { $language }
|
||||||
engine-progress-translationBatchComplete = Lot de traductions terminé
|
engine-progress-translationBatchComplete = Lot de traductions terminé
|
||||||
|
|||||||
@@ -657,6 +657,8 @@ engine-fillMissingTranslationsStarted = Creazione delle traduzioni mancanti…
|
|||||||
engine-fillMissingTranslationsComplete = Traduzione completata: { $posts } articoli, { $media } media, { $failed } errori
|
engine-fillMissingTranslationsComplete = Traduzione completata: { $posts } articoli, { $media } media, { $failed } errori
|
||||||
engine-autoTranslationStarted = Creazione delle traduzioni mancanti…
|
engine-autoTranslationStarted = Creazione delle traduzioni mancanti…
|
||||||
engine-autoTranslationComplete = Create { $count } bozze di traduzione
|
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-scanningPublishedPosts = Scansione dei post pubblicati…
|
||||||
engine-progress-translatingPost = Traduzione di { $title } → { $language }
|
engine-progress-translatingPost = Traduzione di { $title } → { $language }
|
||||||
engine-progress-translationBatchComplete = Lotto di traduzioni completato
|
engine-progress-translationBatchComplete = Lotto di traduzioni completato
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ surface AiEngine {
|
|||||||
-- The active endpoint's response, parsed into per-field suggestions
|
-- 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 ────────────────────────────────────
|
-- ─── AI operation gating ────────────────────────────────────
|
||||||
--
|
--
|
||||||
-- All AI operations route through the active endpoint for the current
|
-- All AI operations route through the active endpoint for the current
|
||||||
@@ -130,6 +137,18 @@ rule AutoTranslationChain {
|
|||||||
-- languages enqueues nothing (empty set).
|
-- 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 {
|
rule MediaMetadataTranslationCascade {
|
||||||
when: PostAutoTranslateCompleted(post_id, language)
|
when: PostAutoTranslateCompleted(post_id, language)
|
||||||
let saved_post = post/Post{id: post_id}
|
let saved_post = post/Post{id: post_id}
|
||||||
|
|||||||
@@ -222,6 +222,12 @@ surface AutoTranslationControlSurface {
|
|||||||
FillMissingTranslationsRequested(project)
|
FillMissingTranslationsRequested(project)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
surface AutoTranslationRuntime {
|
||||||
|
facing _: TranslationOperator
|
||||||
|
|
||||||
|
provides: AutoTranslatePostCompleted(post, language)
|
||||||
|
}
|
||||||
|
|
||||||
invariant AutoTranslationGatedByEndpoint {
|
invariant AutoTranslationGatedByEndpoint {
|
||||||
-- No automatic translation runs unless an endpoint is resolvable for the
|
-- No automatic translation runs unless an endpoint is resolvable for the
|
||||||
-- current mode. Airplane mode needs url+model; online additionally needs an
|
-- current mode. Airplane mode needs url+model; online additionally needs an
|
||||||
@@ -281,7 +287,14 @@ rule AutoTranslatePost {
|
|||||||
|
|
||||||
@guidance
|
@guidance
|
||||||
-- An empty body yields a no_content_to_translate error and no
|
-- 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 {
|
rule AutoTranslateMediaCascade {
|
||||||
|
|||||||
Reference in New Issue
Block a user