Localize engine progress messages

This commit is contained in:
2026-07-22 19:09:10 +02:00
parent 3051331b87
commit 9087096c5d
12 changed files with 328 additions and 60 deletions

View File

@@ -13,6 +13,7 @@ use crate::engine::ai::{
TranslationResult,
};
use crate::engine::{EngineError, EngineResult};
use crate::i18n::{UiLocale, translate, translate_with};
use crate::model::{Media, Post, PostStatus};
use crate::util::frontmatter::read_post_file;
@@ -26,6 +27,29 @@ pub struct FillMissingTranslationsReport {
pub errors: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FillMissingTranslationsProgress {
ScanningPublishedPosts,
TranslatingPost { title: String, language: String },
Complete,
}
impl FillMissingTranslationsProgress {
pub fn localized(&self, locale: UiLocale) -> String {
match self {
Self::ScanningPublishedPosts => {
translate(locale, "engine.progress.scanningPublishedPosts")
}
Self::TranslatingPost { title, language } => translate_with(
locale,
"engine.progress.translatingPost",
&[("title", title), ("language", language)],
),
Self::Complete => translate(locale, "engine.progress.translationBatchComplete"),
}
}
}
pub fn configured_languages(main_language: &str, blog_languages: &[String]) -> Vec<String> {
let mut seen = HashSet::new();
std::iter::once(main_language.to_string())
@@ -64,7 +88,7 @@ pub fn fill_missing_translations(
main_language: &str,
blog_languages: &[String],
offline_mode: bool,
mut on_progress: impl FnMut(f32, &str) -> bool,
mut on_progress: impl FnMut(f32, &FillMissingTranslationsProgress) -> bool,
) -> EngineResult<FillMissingTranslationsReport> {
fill_missing_translations_with(
conn,
@@ -90,7 +114,7 @@ fn fill_missing_translations_with(
blog_languages: &[String],
post_translator: &mut dyn FnMut(&Post, &str) -> EngineResult<TranslationResult>,
media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult<MediaTranslationResult>,
on_progress: &mut dyn FnMut(f32, &str) -> bool,
on_progress: &mut dyn FnMut(f32, &FillMissingTranslationsProgress) -> bool,
) -> EngineResult<FillMissingTranslationsReport> {
let configured = configured_languages(main_language, blog_languages);
if configured.len() <= 1 {
@@ -100,7 +124,10 @@ fn fill_missing_translations_with(
});
}
let posts = qp::list_posts_by_project(conn, project_id)?;
if !on_progress(0.0, "Scanning published posts") {
if !on_progress(
0.0,
&FillMissingTranslationsProgress::ScanningPublishedPosts,
) {
return Err(EngineError::Validation("cancelled".to_string()));
}
let mut work = Vec::new();
@@ -108,7 +135,10 @@ fn fill_missing_translations_with(
.into_iter()
.filter(|post| post.status == PostStatus::Published && !post.do_not_translate)
{
if !on_progress(0.0, "Scanning published posts") {
if !on_progress(
0.0,
&FillMissingTranslationsProgress::ScanningPublishedPosts,
) {
return Err(EngineError::Validation("cancelled".to_string()));
}
for language in missing_languages(conn, &post, &configured)? {
@@ -126,7 +156,10 @@ fn fill_missing_translations_with(
for (index, (post, language)) in work.iter().enumerate() {
if !on_progress(
0.15 + (index as f32 / work.len() as f32) * 0.85,
&format!("{}{language}", post.title),
&FillMissingTranslationsProgress::TranslatingPost {
title: post.title.clone(),
language: language.clone(),
},
) {
return Err(EngineError::Validation("cancelled".to_string()));
}
@@ -151,7 +184,7 @@ fn fill_missing_translations_with(
}
}
}
if !on_progress(1.0, "Translation batch complete") {
if !on_progress(1.0, &FillMissingTranslationsProgress::Complete) {
return Err(EngineError::Validation("cancelled".to_string()));
}
Ok(report)
@@ -399,6 +432,26 @@ mod tests {
use crate::engine::post::{create_post, publish_post, upsert_translation};
use tempfile::TempDir;
#[test]
fn progress_events_use_the_selected_ui_locale() {
assert_eq!(
FillMissingTranslationsProgress::ScanningPublishedPosts.localized(UiLocale::De),
"Veröffentlichte Beiträge werden durchsucht…"
);
assert_eq!(
FillMissingTranslationsProgress::TranslatingPost {
title: "Hallo".into(),
language: "fr".into(),
}
.localized(UiLocale::Fr),
"Traduction de Hallo → fr"
);
assert_eq!(
FillMissingTranslationsProgress::Complete.localized(UiLocale::Es),
"Lote de traducciones completado"
);
}
#[test]
fn batch_translates_only_missing_languages_and_publishes() {
let db = Database::open_in_memory().unwrap();

View File

@@ -10,6 +10,7 @@ use crate::engine::media;
use crate::engine::post;
use crate::engine::script_rebuild;
use crate::engine::template_rebuild;
use crate::i18n::{UiLocale, translate, translate_with};
/// Report from a full rebuild operation.
#[derive(Debug, Default)]
@@ -74,8 +75,69 @@ pub fn rebuild_incremental(
Ok(result)
}
/// Progress callback: (percent 0.0..1.0, phase description).
pub type ProgressFn = Arc<dyn Fn(f32, &str) + Send + Sync>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RebuildProgress {
LoadingProjectMetadata,
ScanningPosts,
PostItem {
current: usize,
total: usize,
name: String,
},
ScanningMedia,
MediaItem {
current: usize,
total: usize,
name: String,
},
RebuildingTemplates,
RebuildingScripts,
ImportingTags,
RefreshingSemanticIndex,
Complete,
}
impl RebuildProgress {
pub fn localized(&self, locale: UiLocale) -> String {
match self {
Self::LoadingProjectMetadata => {
translate(locale, "engine.progress.loadingProjectMetadata")
}
Self::ScanningPosts => translate(locale, "engine.progress.scanningPosts"),
Self::PostItem {
current,
total,
name,
} => localize_item(locale, "engine.progress.postItem", *current, *total, name),
Self::ScanningMedia => translate(locale, "engine.progress.scanningMedia"),
Self::MediaItem {
current,
total,
name,
} => localize_item(locale, "engine.progress.mediaItem", *current, *total, name),
Self::RebuildingTemplates => translate(locale, "engine.progress.rebuildingTemplates"),
Self::RebuildingScripts => translate(locale, "engine.progress.rebuildingScripts"),
Self::ImportingTags => translate(locale, "engine.progress.importingTags"),
Self::RefreshingSemanticIndex => {
translate(locale, "engine.progress.refreshingSemanticIndex")
}
Self::Complete => translate(locale, "engine.progress.rebuildComplete"),
}
}
}
fn localize_item(locale: UiLocale, key: &str, current: usize, total: usize, name: &str) -> String {
let current = current.to_string();
let total = total.to_string();
translate_with(
locale,
key,
&[("current", &current), ("total", &total), ("name", name)],
)
}
/// Progress callback: (percent 0.0..1.0, semantic progress event).
pub type ProgressFn = Arc<dyn Fn(f32, &RebuildProgress) + Send + Sync>;
/// Orchestrate a full rebuild from filesystem into the database.
///
@@ -116,23 +178,23 @@ fn rebuild_from_filesystem_inner(
on_progress: Option<ProgressFn>,
) -> EngineResult<FullRebuildReport> {
let mut report = FullRebuildReport::default();
let progress = |pct: f32, msg: &str| {
let progress = |pct: f32, event: RebuildProgress| {
if let Some(ref f) = on_progress {
f(pct, msg);
f(pct, &event);
}
};
// Phase weights: posts 0.0..0.35, media 0.35..0.70, templates 0.70..0.85, scripts 0.85..1.0
// 1. Load portable project metadata and clear all reconstructible rows.
progress(0.0, "Loading project metadata...");
progress(0.0, RebuildProgress::LoadingProjectMetadata);
fts::ensure_fts_tables(conn)?;
crate::engine::meta::startup_sync(data_dir)?;
crate::engine::meta::sync_metadata_from_filesystem(conn, data_dir, project_id)?;
clear_project_rows(conn, project_id)?;
// 2. Rebuild posts (0.00 .. 0.35)
progress(0.01, "Scanning posts...");
progress(0.01, RebuildProgress::ScanningPosts);
let post_item_cb: Option<post::ItemProgressFn> = on_progress.as_ref().map(|cb| {
let cb = Arc::clone(cb);
let f: post::ItemProgressFn = Box::new(move |current, total, name| {
@@ -142,8 +204,14 @@ fn rebuild_from_filesystem_inner(
1.0
};
let global_pct = 0.01 + phase_pct * 0.34;
let msg = format!("Posts: {current}/{total} \u{2014} {name}");
cb(global_pct, &msg);
cb(
global_pct,
&RebuildProgress::PostItem {
current,
total,
name: name.to_string(),
},
);
});
f
});
@@ -160,7 +228,7 @@ fn rebuild_from_filesystem_inner(
report.errors.extend(post_report.errors);
// 3. Rebuild media (0.35 .. 0.70)
progress(0.35, "Scanning media...");
progress(0.35, RebuildProgress::ScanningMedia);
let media_item_cb: Option<media::ItemProgressFn> = on_progress.as_ref().map(|cb| {
let cb = Arc::clone(cb);
let f: media::ItemProgressFn = Box::new(move |current, total, name| {
@@ -170,8 +238,14 @@ fn rebuild_from_filesystem_inner(
1.0
};
let global_pct = 0.35 + phase_pct * 0.35;
let msg = format!("Media: {current}/{total} \u{2014} {name}");
cb(global_pct, &msg);
cb(
global_pct,
&RebuildProgress::MediaItem {
current,
total,
name: name.to_string(),
},
);
});
f
});
@@ -188,7 +262,7 @@ fn rebuild_from_filesystem_inner(
report.errors.extend(media_report.errors);
// 4. Rebuild templates (0.70 .. 0.85)
progress(0.70, "Rebuilding templates...");
progress(0.70, RebuildProgress::RebuildingTemplates);
let tpl_report =
template_rebuild::rebuild_templates_from_filesystem(conn, data_dir, project_id)?;
report.templates_created = tpl_report.created;
@@ -196,7 +270,7 @@ fn rebuild_from_filesystem_inner(
report.errors.extend(tpl_report.errors);
// 5. Rebuild scripts (0.85 .. 0.95)
progress(0.85, "Rebuilding scripts...");
progress(0.85, RebuildProgress::RebuildingScripts);
let script_report =
script_rebuild::rebuild_scripts_from_filesystem(conn, data_dir, project_id)?;
report.scripts_created = script_report.created;
@@ -204,7 +278,7 @@ fn rebuild_from_filesystem_inner(
report.errors.extend(script_report.errors);
// 6. Restore relationships and tags (0.95 .. 1.0)
progress(0.95, "Importing tags...");
progress(0.95, RebuildProgress::ImportingTags);
super::tag::import_tags_from_file(conn, data_dir, project_id)?;
super::tag::sync_tags_from_posts(conn, project_id)?;
post::rebuild_all_links(conn, data_dir, project_id)?;
@@ -216,11 +290,11 @@ fn rebuild_from_filesystem_inner(
)));
}
progress(0.98, "Refreshing semantic index...");
progress(0.98, RebuildProgress::RefreshingSemanticIndex);
crate::engine::embedding::EmbeddingService::production(conn, data_dir)
.index_unindexed(project_id)?;
progress(1.0, "Rebuild complete");
progress(1.0, RebuildProgress::Complete);
Ok(report)
}
@@ -303,6 +377,27 @@ mod tests {
use std::fs;
use tempfile::TempDir;
#[test]
fn progress_events_use_the_selected_ui_locale() {
assert_eq!(
RebuildProgress::LoadingProjectMetadata.localized(UiLocale::It),
"Caricamento dei metadati del progetto…"
);
assert_eq!(
RebuildProgress::PostItem {
current: 2,
total: 4,
name: "Bonjour".into(),
}
.localized(UiLocale::Fr),
"Articles : 2/4 — Bonjour"
);
assert_eq!(
RebuildProgress::Complete.localized(UiLocale::De),
"Neuaufbau abgeschlossen"
);
}
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();

View File

@@ -234,6 +234,47 @@ static RENDER_MAPS: LazyLock<[HashMap<String, String>; 5]> = LazyLock::new(|| {
mod tests {
use super::*;
#[test]
fn engine_progress_keys_exist_in_every_ui_catalog() {
let keys = [
"engine-progress-scanningPublishedPosts",
"engine-progress-translatingPost",
"engine-progress-translationBatchComplete",
"engine-progress-loadingProjectMetadata",
"engine-progress-scanningPosts",
"engine-progress-postItem",
"engine-progress-scanningMedia",
"engine-progress-mediaItem",
"engine-progress-rebuildingTemplates",
"engine-progress-rebuildingScripts",
"engine-progress-importingTags",
"engine-progress-refreshingSemanticIndex",
"engine-progress-rebuildComplete",
];
let catalogs = [
("en", UI_EN),
("de", include_str!("../../../../locales/ui/de.ftl")),
("fr", include_str!("../../../../locales/ui/fr.ftl")),
("it", include_str!("../../../../locales/ui/it.ftl")),
("es", include_str!("../../../../locales/ui/es.ftl")),
];
for (language, source) in catalogs {
let present = resource(source)
.entries()
.filter_map(|entry| match entry {
fluent_syntax::ast::Entry::Message(message) => {
Some(message.id.name.to_string())
}
_ => None,
})
.collect::<std::collections::HashSet<_>>();
for key in keys {
assert!(present.contains(key), "{language} is missing {key}");
}
}
}
// LanguageNormalization invariant: base-extract and fallback
#[test]
fn normalize_strips_region() {

View File

@@ -849,14 +849,27 @@ mod tests {
#[test]
fn video_macros_with_missing_ids_still_use_the_localized_templates() {
let rendered =
expand_builtin_macros("[[youtube]] [[vimeo]]", &MacroRenderContext::default());
for (language, youtube_title, vimeo_title) in [
("en", "YouTube video", "Vimeo video"),
("de", "YouTube-Video", "Vimeo-Video"),
("fr", "Vidéo YouTube", "Vidéo Vimeo"),
("it", "Video YouTube", "Video Vimeo"),
("es", "Vídeo de YouTube", "Vídeo de Vimeo"),
] {
let mut roots = serde_json::Map::new();
roots.insert("language".into(), serde_json::json!(language));
let context = MacroRenderContext {
roots,
..MacroRenderContext::default()
};
let rendered = expand_builtin_macros("[[youtube]] [[vimeo]]", &context);
assert!(rendered.contains("https://www.youtube.com/embed/?rel=0"));
assert!(rendered.contains("https://player.vimeo.com/video/"));
assert!(rendered.contains("title=\"YouTube video\""));
assert!(rendered.contains("title=\"Vimeo video\""));
assert!(!rendered.contains("Missing"));
assert!(rendered.contains("https://www.youtube.com/embed/?rel=0"));
assert!(rendered.contains("https://player.vimeo.com/video/"));
assert!(rendered.contains(&format!("title=\"{youtube_title}\"")));
assert!(rendered.contains(&format!("title=\"{vimeo_title}\"")));
assert!(!rendered.contains("Missing"));
}
}
#[test]