From a727c9073d204c1f83b8cbf729dc5516dc5eb129 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 18 Jul 2026 15:33:38 +0200 Subject: [PATCH] Enforce i18n completeness. --- Cargo.lock | 2 + crates/bds-ui/Cargo.toml | 2 + crates/bds-ui/src/app.rs | 538 ++++++++++++++--------- crates/bds-ui/tests/i18n_completeness.rs | 373 ++++++++++++++++ locales/ui/de.ftl | 20 + locales/ui/en.ftl | 20 + locales/ui/es.ftl | 20 + locales/ui/fr.ftl | 20 + locales/ui/it.ftl | 20 + 9 files changed, 798 insertions(+), 217 deletions(-) create mode 100644 crates/bds-ui/tests/i18n_completeness.rs diff --git a/Cargo.lock b/Cargo.lock index 61ed71d..19c8e4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -844,6 +844,7 @@ dependencies = [ "bds-editor", "chrono", "dirs 5.0.1", + "fluent-syntax", "iced", "muda", "objc2 0.6.4", @@ -853,6 +854,7 @@ dependencies = [ "rfd", "rusqlite", "serde_json", + "syn 2.0.117", "tempfile", "tokio", "uuid", diff --git a/crates/bds-ui/Cargo.toml b/crates/bds-ui/Cargo.toml index bd6fcb1..5dbac0a 100644 --- a/crates/bds-ui/Cargo.toml +++ b/crates/bds-ui/Cargo.toml @@ -27,4 +27,6 @@ objc2-foundation = "0.3" objc2-app-kit = "0.3" [dev-dependencies] +fluent-syntax = "0.12" +syn = { version = "2", features = ["full", "visit"] } tempfile = "3" diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 56bb289..753738e 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -1050,7 +1050,9 @@ impl BdsApp { // Per metadata.allium StartupSync: sync metadata from filesystem if let Some(data_dir) = self.data_dir.clone() { if let Err(e) = engine::meta::startup_sync(&data_dir) { - self.add_output(&format!("Metadata sync failed: {e}")); + let message = + self.operation_failed_text("common.metadataSync", e.to_string()); + self.add_output(&message); } // Extract content language from project metadata if let Ok(meta) = engine::meta::read_project_json(&data_dir) { @@ -1331,58 +1333,81 @@ impl BdsApp { )) }, ), - Message::ReindexText => self.spawn_engine_task( - "engine.reindexStarted", - |db_path, project_id, data_dir, tm, tid| { - let db = Database::open(&db_path).map_err(|e| e.to_string())?; - tm.report_progress(tid, Some(0.0), Some("Reading project config...".into())); - let main_lang = engine::meta::read_project_json(&data_dir) - .ok() - .and_then(|m| m.main_language) - .unwrap_or_else(|| "en".to_string()); - let tm2 = Arc::clone(&tm); - let on_item: engine::search::ItemProgressFn = - Box::new(move |current, total, name| { - let pct = if total > 0 { - current as f32 / total as f32 - } else { - 1.0 - }; - let msg = format!("Indexing: {current}/{total} \u{2014} {name}"); - tm2.report_progress(tid, Some(pct), Some(msg)); - }); - let report = engine::search::reindex_all_with_progress( - db.conn(), - &project_id, - &main_lang, - Some(on_item), - ) - .map_err(|e| e.to_string())?; - Ok(format!( - "posts={}, media={}", - report.posts_indexed, report.media_indexed - )) - }, - ), - Message::RegenerateCalendar => self.spawn_engine_task( - "engine.calendarStarted", - |db_path, project_id, data_dir, tm, tid| { - let db = Database::open(&db_path).map_err(|e| e.to_string())?; - tm.report_progress(tid, Some(0.20), Some("Loading posts...".into())); - engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id) + Message::ReindexText => { + let locale = self.ui_locale; + self.spawn_engine_task( + "engine.reindexStarted", + move |db_path, project_id, data_dir, tm, tid| { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + tm.report_progress( + tid, + Some(0.0), + Some(t(locale, "engine.readingProjectConfig")), + ); + let main_lang = engine::meta::read_project_json(&data_dir) + .ok() + .and_then(|m| m.main_language) + .unwrap_or_else(|| "en".to_string()); + let tm2 = Arc::clone(&tm); + let on_item: engine::search::ItemProgressFn = + Box::new(move |current, total, name| { + let pct = if total > 0 { + current as f32 / total as f32 + } else { + 1.0 + }; + let msg = tw( + locale, + "engine.indexingItem", + &[ + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ("name", name), + ], + ); + tm2.report_progress(tid, Some(pct), Some(msg)); + }); + let report = engine::search::reindex_all_with_progress( + db.conn(), + &project_id, + &main_lang, + Some(on_item), + ) .map_err(|e| e.to_string())?; - tm.report_progress(tid, Some(0.90), Some("Writing calendar JSON...".into())); - Ok("done".to_string()) - }, - ), + Ok(format!( + "posts={}, media={}", + report.posts_indexed, report.media_indexed + )) + }, + ) + } + Message::RegenerateCalendar => { + let locale = self.ui_locale; + self.spawn_engine_task( + "engine.calendarStarted", + move |db_path, project_id, data_dir, tm, tid| { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + tm.report_progress(tid, Some(0.20), Some(t(locale, "engine.loadingPosts"))); + engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id) + .map_err(|e| e.to_string())?; + tm.report_progress( + tid, + Some(0.90), + Some(t(locale, "engine.writingCalendar")), + ); + Ok("done".to_string()) + }, + ) + } Message::ValidateTranslations => { + let locale = self.ui_locale; self.open_singleton_tab( TabType::TranslationValidation, "tabBar.translationValidation", ); self.spawn_engine_task( "engine.validateTranslationsStarted", - |db_path, project_id, data_dir, tm, tid| { + move |db_path, project_id, data_dir, tm, tid| { let db = Database::open(&db_path).map_err(|e| e.to_string())?; let meta = engine::meta::read_project_json(&data_dir) .map_err(|e| e.to_string())?; @@ -1396,7 +1421,15 @@ impl BdsApp { } else { 1.0 }; - let msg = format!("Checking: {current}/{total} \u{2014} {name}"); + let msg = tw( + locale, + "engine.checkingItem", + &[ + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ("name", name), + ], + ); tm2.report_progress(tid, Some(pct), Some(msg)); }); let report = @@ -1417,96 +1450,122 @@ impl BdsApp { }, ) } - Message::ValidateMedia => self.spawn_engine_task( - "engine.validateMediaStarted", - |db_path, project_id, _data_dir, tm, tid| { - let db = Database::open(&db_path).map_err(|e| e.to_string())?; - let on_item: engine::validate_media::ProgressFn = - Box::new(move |current, total, name| { - let pct = if total > 0 { - current as f32 / total as f32 - } else { - 1.0 - }; - let msg = format!("Checking: {current}/{total} \u{2014} {name}"); - tm.report_progress(tid, Some(pct), Some(msg)); - }); - let report = engine::validate_media::validate_media( - db.conn(), - &_data_dir, - &project_id, - Some(on_item), - ) - .map_err(|e| e.to_string())?; - Ok(format!( - "checked={}, issues={}", - report.total_checked, - report.issues.len() - )) - }, - ), - Message::GenerateSite => self.spawn_engine_task( - "engine.generateSiteStarted", - |db_path, project_id, data_dir, tm, tid| { - let db = Database::open(&db_path).map_err(|e| e.to_string())?; - let metadata = - engine::meta::read_project_json(&data_dir).map_err(|e| e.to_string())?; - if metadata - .public_url - .as_deref() - .unwrap_or("") - .trim() - .is_empty() - { - return Err("public URL is required before generating the site".to_string()); - } - let main_language = metadata - .main_language - .clone() - .unwrap_or_else(|| "en".to_string()); - let all_posts = - bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id) + Message::ValidateMedia => { + let locale = self.ui_locale; + self.spawn_engine_task( + "engine.validateMediaStarted", + move |db_path, project_id, _data_dir, tm, tid| { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + let on_item: engine::validate_media::ProgressFn = + Box::new(move |current, total, name| { + let pct = if total > 0 { + current as f32 / total as f32 + } else { + 1.0 + }; + let msg = tw( + locale, + "engine.checkingItem", + &[ + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ("name", name), + ], + ); + tm.report_progress(tid, Some(pct), Some(msg)); + }); + let report = engine::validate_media::validate_media( + db.conn(), + &_data_dir, + &project_id, + Some(on_item), + ) + .map_err(|e| e.to_string())?; + Ok(format!( + "checked={}, issues={}", + report.total_checked, + report.issues.len() + )) + }, + ) + } + Message::GenerateSite => { + let locale = self.ui_locale; + self.spawn_engine_task( + "engine.generateSiteStarted", + move |db_path, project_id, data_dir, tm, tid| { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + let metadata = engine::meta::read_project_json(&data_dir) .map_err(|e| e.to_string())?; - let published_posts = all_posts - .into_iter() - .filter(engine::generation::has_published_snapshot) - .collect::>(); - let total = published_posts.len().max(1) as f32; - let mut sources = Vec::new(); - for (index, post) in published_posts.into_iter().enumerate() { + if metadata + .public_url + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { + return Err( + "public URL is required before generating the site".to_string() + ); + } + let main_language = metadata + .main_language + .clone() + .unwrap_or_else(|| "en".to_string()); + let all_posts = bds_core::db::queries::post::list_posts_by_project( + db.conn(), + &project_id, + ) + .map_err(|e| e.to_string())?; + let published_posts = all_posts + .into_iter() + .filter(engine::generation::has_published_snapshot) + .collect::>(); + let total = published_posts.len().max(1) as f32; + let mut sources = Vec::new(); + for (index, post) in published_posts.into_iter().enumerate() { + tm.report_progress( + tid, + Some(((index as f32) / total) * 0.7), + Some(tw(locale, "engine.renderingPost", &[("post", &post.slug)])), + ); + if let Some(source) = + engine::generation::load_published_post_source(&data_dir, post) + .map_err(|error| error.to_string())? + { + sources.push(source); + } + } + let output_dir = data_dir.join("html"); + std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?; tm.report_progress( tid, - Some(((index as f32) / total) * 0.7), - Some(format!("Rendering {}", post.slug)), + Some(0.85), + Some(t(locale, "engine.writingGeneratedFiles")), ); - if let Some(source) = - engine::generation::load_published_post_source(&data_dir, post) - .map_err(|error| error.to_string())? - { - sources.push(source); - } - } - let output_dir = data_dir.join("html"); - std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?; - tm.report_progress(tid, Some(0.85), Some("Writing generated files".into())); - let report = engine::generation::generate_starter_site( - db.conn(), - &output_dir, - &project_id, - &metadata, - &sources, - &main_language, - ) - .map_err(|e| e.to_string())?; - tm.report_progress(tid, Some(1.0), Some("Site generation complete".into())); - Ok(format!( - "written={}, skipped={}, output={}", - report.written_paths.len(), - report.skipped_paths.len(), - output_dir.display(), - )) - }, - ), + let report = engine::generation::generate_starter_site( + db.conn(), + &output_dir, + &project_id, + &metadata, + &sources, + &main_language, + ) + .map_err(|e| e.to_string())?; + tm.report_progress( + tid, + Some(1.0), + Some(t(locale, "engine.generationComplete")), + ); + Ok(format!( + "written={}, skipped={}, output={}", + report.written_paths.len(), + report.skipped_paths.len(), + output_dir.display(), + )) + }, + ) + } Message::RunMetadataDiff => { self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff"); Task::none() @@ -1525,7 +1584,12 @@ impl BdsApp { } Err(err) => { self.task_manager.fail(task_id, err.clone()); - self.notify(ToastLevel::Error, &format!("{label} failed: {err}")); + let message = tw( + self.ui_locale, + "common.operationFailed", + &[("operation", &label), ("error", &err)], + ); + self.notify(ToastLevel::Error, &message); } } let sidebar_task = self.refresh_counts(); @@ -1543,12 +1607,24 @@ impl BdsApp { self.site_validation_state.stale_files = report.stale_pages; self.notify( ToastLevel::Success, - &format!( - "{}: missing={}, extra={}, stale={}", - t(self.ui_locale, "tabBar.siteValidation"), - self.site_validation_state.missing_files.len(), - self.site_validation_state.extra_files.len(), - self.site_validation_state.stale_files.len(), + &tw( + self.ui_locale, + "siteValidation.summary", + &[ + ("label", &t(self.ui_locale, "tabBar.siteValidation")), + ( + "missing", + &self.site_validation_state.missing_files.len().to_string(), + ), + ( + "extra", + &self.site_validation_state.extra_files.len().to_string(), + ), + ( + "stale", + &self.site_validation_state.stale_files.len().to_string(), + ), + ], ), ); } @@ -2124,10 +2200,7 @@ impl BdsApp { &post_id, &media_id, ) { - self.notify( - ToastLevel::Error, - &format!("Failed to unlink media: {err}"), - ); + self.notify_operation_failed("editor.unlinkMedia", err); return Task::none(); } self.refresh_post_relationships(&post_id); @@ -2867,10 +2940,7 @@ impl BdsApp { self.refresh_counts() } Err(error) => { - self.notify( - ToastLevel::Error, - &format!("Failed to create post: {error}"), - ); + self.notify_operation_failed("modal.postInsertLink.createPost", error); Task::none() } } @@ -2910,10 +2980,7 @@ impl BdsApp { self.refresh_counts() } Err(error) => { - self.notify( - ToastLevel::Error, - &format!("Failed to create script: {error}"), - ); + self.notify_operation_failed("common.createScript", error); Task::none() } } @@ -2952,10 +3019,7 @@ impl BdsApp { self.refresh_counts() } Err(error) => { - self.notify( - ToastLevel::Error, - &format!("Failed to create template: {error}"), - ); + self.notify_operation_failed("common.createTemplate", error); Task::none() } } @@ -3020,11 +3084,26 @@ impl BdsApp { for post_id in due_ids { if let Err(error) = self.persist_post_editor_state(&post_id) { - self.notify(ToastLevel::Error, &format!("Auto-save failed: {error}")); + self.notify_operation_failed("common.autoSave", error); } } } + fn operation_failed_text(&self, operation_key: &str, error: impl std::fmt::Display) -> String { + let operation = t(self.ui_locale, operation_key); + let error = error.to_string(); + tw( + self.ui_locale, + "common.operationFailed", + &[("operation", &operation), ("error", &error)], + ) + } + + fn notify_operation_failed(&mut self, operation_key: &str, error: impl std::fmt::Display) { + let message = self.operation_failed_text(operation_key, error); + self.notify(ToastLevel::Error, &message); + } + fn add_output(&mut self, text: &str) { self.output_entries.push(OutputEntry { timestamp: chrono::Utc::now().timestamp(), @@ -3832,14 +3911,14 @@ impl BdsApp { fn save_post_editor(&mut self, post_id: &str) -> Task { match self.persist_post_editor_state(post_id) { Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")), - Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + Err(e) => self.notify_operation_failed("common.save", e), } Task::none() } fn publish_post_editor(&mut self, post_id: &str) -> Task { if let Err(e) = self.persist_post_editor_state(post_id) { - self.notify(ToastLevel::Error, &format!("Publish failed: {e}")); + self.notify_operation_failed("editor.publish", e); return Task::none(); } let Some(ref db) = self.db else { @@ -3863,7 +3942,7 @@ impl BdsApp { self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.published")); } Err(e) => { - self.notify(ToastLevel::Error, &format!("Publish failed: {e}")); + self.notify_operation_failed("editor.publish", e); } } Task::none() @@ -4265,7 +4344,7 @@ impl BdsApp { fn save_media_editor(&mut self, media_id: &str) -> Task { match self.persist_media_editor_state(media_id) { Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")), - Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + Err(e) => self.notify_operation_failed("common.save", e), } Task::none() } @@ -4323,7 +4402,7 @@ impl BdsApp { self.refresh_post_relationships(post_id); } Err(error) => { - self.notify(ToastLevel::Error, &format!("Failed to link post: {error}")); + self.notify_operation_failed("editor.linkToPost", error); } } } @@ -4343,10 +4422,7 @@ impl BdsApp { self.refresh_post_relationships(post_id); } Err(error) => { - self.notify( - ToastLevel::Error, - &format!("Failed to unlink post: {error}"), - ); + self.notify_operation_failed("editor.unlinkMedia", error); } } } @@ -4380,7 +4456,7 @@ impl BdsApp { if let Some(s) = self.template_editors.get_mut(template_id) { s.validation_error = Some(e.clone()); } - self.notify(ToastLevel::Error, &format!("Save failed: {e}")); + self.notify_operation_failed("common.save", e); } } Task::none() @@ -4414,7 +4490,7 @@ impl BdsApp { if let Some(s) = self.script_editors.get_mut(script_id) { s.validation_error = Some(e.clone()); } - self.notify(ToastLevel::Error, &format!("Save failed: {e}")); + self.notify_operation_failed("common.save", e); } } Task::none() @@ -4573,7 +4649,7 @@ impl BdsApp { self.close_entity_tab(post_id); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); } - Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")), + Err(e) => self.notify_operation_failed("modal.confirmDelete.delete", e), } Task::none() } @@ -4613,7 +4689,7 @@ impl BdsApp { self.refresh_post_relationships(post_id); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } - Err(e) => self.notify(ToastLevel::Error, &format!("Discard failed: {e}")), + Err(e) => self.notify_operation_failed("editor.discard", e), } Task::none() } @@ -4643,7 +4719,7 @@ impl BdsApp { self.sync_menu_state(); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } - Err(e) => self.notify(ToastLevel::Error, &format!("Duplicate failed: {e}")), + Err(e) => self.notify_operation_failed("editor.duplicate", e), } Task::none() } @@ -4661,7 +4737,7 @@ impl BdsApp { self.close_entity_tab(media_id); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); } - Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")), + Err(e) => self.notify_operation_failed("modal.confirmDelete.delete", e), } Task::none() } @@ -4679,7 +4755,7 @@ impl BdsApp { self.close_entity_tab(template_id); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); } - Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")), + Err(e) => self.notify_operation_failed("modal.confirmDelete.delete", e), } Task::none() } @@ -4744,7 +4820,7 @@ impl BdsApp { self.close_entity_tab(script_id); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); } - Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")), + Err(e) => self.notify_operation_failed("modal.confirmDelete.delete", e), } Task::none() } @@ -4827,7 +4903,7 @@ impl BdsApp { } self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); } - Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")), + Err(e) => self.notify_operation_failed("modal.confirmDelete.delete", e), } Task::none() } @@ -4853,7 +4929,7 @@ impl BdsApp { } self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } - Err(e) => self.notify(ToastLevel::Error, &format!("Merge failed: {e}")), + Err(e) => self.notify_operation_failed("tags.merge", e), } Task::none() } @@ -5085,7 +5161,7 @@ impl BdsApp { template_slug: tag.post_template_slug.unwrap_or_default(), }); } - Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + Err(e) => self.notify_operation_failed("common.save", e), } } if let Some(editing_tag) = created_editing { @@ -5147,7 +5223,7 @@ impl BdsApp { self.reload_tags_state(); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } - Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + Err(e) => self.notify_operation_failed("common.save", e), } } } @@ -5217,7 +5293,7 @@ impl BdsApp { self.reload_tags_state(); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } - Err(e) => self.notify(ToastLevel::Error, &format!("Discover failed: {e}")), + Err(e) => self.notify_operation_failed("tags.discoverButton", e), } } } @@ -5304,7 +5380,10 @@ impl BdsApp { let max_posts = match state.max_posts_per_page.trim().parse::() { Ok(value) => value, Err(_) => { - self.notify(ToastLevel::Error, "Invalid max posts per page"); + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "settings.maxPostsPerPageInvalid"), + ); return Task::none(); } }; @@ -5355,7 +5434,7 @@ impl BdsApp { meta.blog_languages = state.blog_languages.clone(); meta.semantic_similarity_enabled = state.semantic_similarity_enabled; if let Err(e) = meta.validate() { - self.notify(ToastLevel::Error, &format!("Save failed: {e}")); + self.notify_operation_failed("common.save", e); return Task::none(); } project.name = state.project_name.clone(); @@ -5381,8 +5460,8 @@ impl BdsApp { self.dashboard_state = Some(self.hydrate_dashboard_state()); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } - (Err(e), _) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), - (_, Err(e)) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + (Err(e), _) => self.notify_operation_failed("common.save", e), + (_, Err(e)) => self.notify_operation_failed("common.save", e), } } } @@ -5404,7 +5483,7 @@ impl BdsApp { Ok(_) => { self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")) } - Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + Err(e) => self.notify_operation_failed("common.save", e), } } } @@ -5414,7 +5493,10 @@ impl BdsApp { SettingsMsg::AddCategory => { let category_name = state.new_category_name.trim(); if category_name.is_empty() { - self.notify(ToastLevel::Error, "Category name required"); + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "settings.categoryNameRequired"), + ); return Task::none(); } if state @@ -5422,7 +5504,10 @@ impl BdsApp { .iter() .any(|row| row.name.eq_ignore_ascii_case(category_name)) { - self.notify(ToastLevel::Error, "Category already exists"); + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "settings.categoryAlreadyExists"), + ); return Task::none(); } if let Some(data_dir) = &self.data_dir { @@ -5435,7 +5520,7 @@ impl BdsApp { self.dashboard_state = Some(self.hydrate_dashboard_state()); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } - Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + Err(e) => self.notify_operation_failed("common.save", e), } } } @@ -5486,7 +5571,7 @@ impl BdsApp { self.dashboard_state = Some(self.hydrate_dashboard_state()); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } - Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + Err(e) => self.notify_operation_failed("common.save", e), } } } @@ -5498,7 +5583,7 @@ impl BdsApp { self.dashboard_state = Some(self.hydrate_dashboard_state()); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } - Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + Err(e) => self.notify_operation_failed("common.save", e), } } } @@ -5531,9 +5616,7 @@ impl BdsApp { self.dashboard_state = Some(self.hydrate_dashboard_state()); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } - (Err(e), _) | (_, Err(e)) => { - self.notify(ToastLevel::Error, &format!("Save failed: {e}")) - } + (Err(e), _) | (_, Err(e)) => self.notify_operation_failed("common.save", e), } } } @@ -5577,7 +5660,7 @@ impl BdsApp { Ok(()) => { self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")) } - Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + Err(e) => self.notify_operation_failed("common.save", e), } } } @@ -5620,9 +5703,7 @@ impl BdsApp { Ok(()) => { self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")) } - Err(error) => { - self.notify(ToastLevel::Error, &format!("Save failed: {error}")) - } + Err(error) => self.notify_operation_failed("common.save", error), } } } @@ -5632,9 +5713,7 @@ impl BdsApp { Ok(()) => { self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")) } - Err(error) => { - self.notify(ToastLevel::Error, &format!("Save failed: {error}")) - } + Err(error) => self.notify_operation_failed("common.save", error), } } } @@ -5647,7 +5726,7 @@ impl BdsApp { Ok(()) => { self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")) } - Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + Err(e) => self.notify_operation_failed("common.save", e), } } } @@ -5753,9 +5832,10 @@ impl BdsApp { ); } SettingsMsg::RegenerateThumbnails => { + let locale = self.ui_locale; return self.spawn_engine_task( "settings.regenerateThumbnails", - |db_path, project_id, data_dir, tm, tid| { + move |db_path, project_id, data_dir, tm, tid| { let db = Database::open(&db_path).map_err(|e| e.to_string())?; let regenerated = Self::regenerate_project_thumbnails( &db, @@ -5770,7 +5850,7 @@ impl BdsApp { tm.report_progress( tid, Some(progress), - Some(format!("Regenerating: {name}")), + Some(tw(locale, "engine.regeneratingItem", &[("name", name)])), ); }, )?; @@ -5949,7 +6029,7 @@ impl BdsApp { ); } Err(e) => { - self.notify(ToastLevel::Error, &format!("Failed to load post: {e}")); + self.notify_operation_failed("activity.posts", e); } } } @@ -5973,7 +6053,7 @@ impl BdsApp { ); } Err(e) => { - self.notify(ToastLevel::Error, &format!("Failed to load media: {e}")); + self.notify_operation_failed("activity.media", e); } } } @@ -6001,10 +6081,7 @@ impl BdsApp { ); } Err(e) => { - self.notify( - ToastLevel::Error, - &format!("Failed to load template: {e}"), - ); + self.notify_operation_failed("activity.templates", e); } } } @@ -6029,7 +6106,7 @@ impl BdsApp { .insert(script.id.clone(), ScriptEditorState::from_script(&script)); } Err(e) => { - self.notify(ToastLevel::Error, &format!("Failed to load script: {e}")); + self.notify_operation_failed("activity.scripts", e); } } } @@ -6355,7 +6432,10 @@ impl BdsApp { fn run_post_ai_analysis(&mut self, post_id: &str) -> Task { let Some(db) = &self.db else { - self.notify(ToastLevel::Error, "database unavailable"); + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "common.databaseUnavailable"), + ); return Task::none(); }; let Some(state) = self.post_editors.get(post_id).cloned() else { @@ -6409,7 +6489,10 @@ impl BdsApp { fn run_post_taxonomy_analysis(&mut self, post_id: &str) -> Task { let Some(db) = &self.db else { - self.notify(ToastLevel::Error, "database unavailable"); + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "common.databaseUnavailable"), + ); return Task::none(); }; let Some(state) = self.post_editors.get(post_id).cloned() else { @@ -6457,7 +6540,10 @@ impl BdsApp { fn detect_post_language(&mut self, post_id: &str) -> Task { let Some(db) = &self.db else { - self.notify(ToastLevel::Error, "database unavailable"); + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "common.databaseUnavailable"), + ); return Task::none(); }; let Some(state) = self.post_editors.get(post_id).cloned() else { @@ -6522,11 +6608,17 @@ impl BdsApp { fn translate_post_to(&mut self, post_id: &str, target_language: &str) -> Task { self.active_modal = None; let Some(db) = &self.db else { - self.notify(ToastLevel::Error, "database unavailable"); + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "common.databaseUnavailable"), + ); return Task::none(); }; let Some(data_dir) = &self.data_dir else { - self.notify(ToastLevel::Error, "project data directory unavailable"); + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "engine.previewDataDirUnavailable"), + ); return Task::none(); }; let Some(state) = self.post_editors.get(post_id).cloned() else { @@ -6598,11 +6690,17 @@ impl BdsApp { fn run_media_ai_analysis(&mut self, media_id: &str) -> Task { let Some(db) = &self.db else { - self.notify(ToastLevel::Error, "database unavailable"); + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "common.databaseUnavailable"), + ); return Task::none(); }; let Some(data_dir) = &self.data_dir else { - self.notify(ToastLevel::Error, "project data directory unavailable"); + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "engine.previewDataDirUnavailable"), + ); return Task::none(); }; let Some(state) = self.media_editors.get(media_id).cloned() else { @@ -6671,7 +6769,10 @@ impl BdsApp { fn detect_media_language(&mut self, media_id: &str) -> Task { let Some(db) = &self.db else { - self.notify(ToastLevel::Error, "database unavailable"); + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "common.databaseUnavailable"), + ); return Task::none(); }; let Some(state) = self.media_editors.get(media_id).cloned() else { @@ -6733,7 +6834,10 @@ impl BdsApp { fn translate_media_to(&mut self, media_id: &str, target_language: &str) -> Task { self.active_modal = None; let Some(db) = &self.db else { - self.notify(ToastLevel::Error, "database unavailable"); + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "common.databaseUnavailable"), + ); return Task::none(); }; let Some(state) = self.media_editors.get(media_id).cloned() else { diff --git a/crates/bds-ui/tests/i18n_completeness.rs b/crates/bds-ui/tests/i18n_completeness.rs new file mode 100644 index 0000000..13a3456 --- /dev/null +++ b/crates/bds-ui/tests/i18n_completeness.rs @@ -0,0 +1,373 @@ +use fluent_syntax::ast::{ + CallArguments, Entry, Expression, InlineExpression, Pattern, PatternElement, +}; +use fluent_syntax::parser; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use syn::punctuated::Punctuated; +use syn::visit::Visit; +use syn::{Attribute, Expr, ExprCall, ExprMethodCall, ItemFn, ItemMod, Lit, Token}; + +fn pattern_variables(pattern: &Pattern<&str>, variables: &mut BTreeSet) { + for element in &pattern.elements { + if let PatternElement::Placeable { expression } = element { + expression_variables(expression, variables); + } + } +} + +fn arguments_variables(arguments: &CallArguments<&str>, variables: &mut BTreeSet) { + for argument in &arguments.positional { + inline_variables(argument, variables); + } + for argument in &arguments.named { + inline_variables(&argument.value, variables); + } +} + +fn inline_variables(expression: &InlineExpression<&str>, variables: &mut BTreeSet) { + match expression { + InlineExpression::VariableReference { id } => { + variables.insert(id.name.to_owned()); + } + InlineExpression::FunctionReference { arguments, .. } => { + arguments_variables(arguments, variables); + } + InlineExpression::TermReference { + arguments: Some(arguments), + .. + } => arguments_variables(arguments, variables), + InlineExpression::Placeable { expression } => { + expression_variables(expression, variables); + } + InlineExpression::StringLiteral { .. } + | InlineExpression::NumberLiteral { .. } + | InlineExpression::MessageReference { .. } + | InlineExpression::TermReference { + arguments: None, .. + } => {} + } +} + +fn expression_variables(expression: &Expression<&str>, variables: &mut BTreeSet) { + match expression { + Expression::Inline(expression) => inline_variables(expression, variables), + Expression::Select { selector, variants } => { + inline_variables(selector, variables); + for variant in variants { + pattern_variables(&variant.value, variables); + } + } + } +} + +fn catalog_signature(source: &str) -> BTreeMap> { + let resource = parser::parse(source).unwrap_or_else(|(_, errors)| { + panic!("invalid Fluent catalog: {errors:?}"); + }); + let mut signature = BTreeMap::new(); + + for entry in resource.body { + let (prefix, id, value, attributes) = match entry { + Entry::Message(message) => ("", message.id, message.value, message.attributes), + Entry::Term(term) => ("-", term.id, Some(term.value), term.attributes), + Entry::Comment(_) + | Entry::GroupComment(_) + | Entry::ResourceComment(_) + | Entry::Junk { .. } => continue, + }; + let key = format!("{prefix}{}", id.name); + let mut variables = BTreeSet::new(); + if let Some(value) = value { + pattern_variables(&value, &mut variables); + } + assert!( + signature.insert(key.clone(), variables).is_none(), + "duplicate {key}" + ); + + for attribute in attributes { + let mut variables = BTreeSet::new(); + pattern_variables(&attribute.value, &mut variables); + let key = format!("{key}.{}", attribute.id.name); + assert!( + signature.insert(key.clone(), variables).is_none(), + "duplicate {key}" + ); + } + } + + signature +} + +fn assert_catalog_matches( + domain: &str, + locale: &str, + expected: &BTreeMap>, + source: &str, +) { + let actual = catalog_signature(source); + let missing = expected + .keys() + .filter(|key| !actual.contains_key(*key)) + .collect::>(); + let extra = actual + .keys() + .filter(|key| !expected.contains_key(*key)) + .collect::>(); + let mismatched_variables = expected + .iter() + .filter_map(|(key, variables)| { + actual + .get(key) + .filter(|actual| *actual != variables) + .map(|actual| (key, variables, actual)) + }) + .collect::>(); + + assert!( + missing.is_empty() && extra.is_empty() && mismatched_variables.is_empty(), + "{domain}/{locale}.ftl: missing={missing:?}, extra={extra:?}, variable mismatches={mismatched_variables:?}" + ); +} + +#[test] +fn every_locale_has_the_same_messages_and_variables_as_english() { + let ui = catalog_signature(include_str!("../../../locales/ui/en.ftl")); + for (locale, source) in [ + ("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")), + ] { + assert_catalog_matches("ui", locale, &ui, source); + } + + let render = catalog_signature(include_str!("../../../locales/render/en.ftl")); + for (locale, source) in [ + ("de", include_str!("../../../locales/render/de.ftl")), + ("fr", include_str!("../../../locales/render/fr.ftl")), + ("it", include_str!("../../../locales/render/it.ftl")), + ("es", include_str!("../../../locales/render/es.ftl")), + ] { + assert_catalog_matches("render", locale, &render, source); + } +} + +fn cfg_test(attributes: &[Attribute]) -> bool { + attributes.iter().any(|attribute| { + attribute.path().is_ident("test") + || attribute.path().is_ident("cfg") + && attribute + .meta + .require_list() + .is_ok_and(|list| list.tokens.to_string().contains("test")) + }) +} + +fn is_user_facing(value: &str) -> bool { + if matches!( + value, + "bDS" + | "https://" + | "https://api.example.com/v1" + | "gpt-4.1-mini" + | "sk-..." + | "http://localhost:11434/v1" + | "llama3.2" + ) { + return false; + } + + let mut outside_placeholder = String::new(); + let mut depth = 0; + for character in value.chars() { + match character { + '{' => depth += 1, + '}' if depth > 0 => depth -= 1, + _ if depth == 0 => outside_placeholder.push(character), + _ => {} + } + } + + outside_placeholder + .split(|character: char| !character.is_alphabetic()) + .filter(|word| !word.is_empty()) + .any(|word| !matches!(word, "B" | "KB" | "MB" | "GB")) +} + +#[derive(Default)] +struct LiteralCollector { + literals: Vec, +} + +impl<'ast> Visit<'ast> for LiteralCollector { + fn visit_expr_call(&mut self, call: &'ast ExprCall) { + if let Expr::Path(path) = call.func.as_ref() + && path.path.segments.last().is_some_and(|segment| { + matches!( + segment.ident.to_string().as_str(), + "t" | "tw" | "translate" | "translate_with" + ) + }) + { + return; + } + syn::visit::visit_expr_call(self, call); + } + + fn visit_expr_lit(&mut self, expression: &'ast syn::ExprLit) { + if let Lit::Str(literal) = &expression.lit + && is_user_facing(&literal.value()) + { + self.literals.push(literal.value()); + } + } + + fn visit_expr_macro(&mut self, expression: &'ast syn::ExprMacro) { + let name = expression + .mac + .path + .segments + .last() + .map(|segment| segment.ident.to_string()); + if name + .as_deref() + .is_some_and(|name| matches!(name, "t" | "tw")) + { + return; + } + if name + .as_deref() + .is_some_and(|name| matches!(name, "format" | "format_args" | "concat")) + && let Ok(arguments) = expression + .mac + .parse_body_with(Punctuated::::parse_terminated) + { + for argument in &arguments { + self.visit_expr(argument); + } + } + } +} + +fn collect_literals(expression: &Expr) -> Vec { + let mut collector = LiteralCollector::default(); + collector.visit_expr(expression); + collector.literals +} + +#[derive(Default)] +struct UiLiteralVisitor { + violations: Vec, +} + +impl UiLiteralVisitor { + fn check(&mut self, sink: &str, expression: &Expr) { + self.violations.extend( + collect_literals(expression) + .into_iter() + .map(|literal| format!("{sink}: {literal:?}")), + ); + } +} + +impl<'ast> Visit<'ast> for UiLiteralVisitor { + fn visit_item_mod(&mut self, item: &'ast ItemMod) { + if !cfg_test(&item.attrs) { + syn::visit::visit_item_mod(self, item); + } + } + + fn visit_item_fn(&mut self, item: &'ast ItemFn) { + if !cfg_test(&item.attrs) { + syn::visit::visit_item_fn(self, item); + } + } + + fn visit_expr_call(&mut self, call: &'ast ExprCall) { + if let Expr::Path(path) = call.func.as_ref() { + let segments = path.path.segments.iter().collect::>(); + let last = segments.last().map(|segment| segment.ident.to_string()); + let previous = segments + .get(segments.len().saturating_sub(2)) + .map(|segment| segment.ident.to_string()); + let indices: &[usize] = match (previous.as_deref(), last.as_deref()) { + (Some("Toast"), Some("new")) => &[1], + (Some("Submenu" | "MenuItem"), Some("new")) => &[0], + ( + _, + Some( + "text" | "help_text" | "section_header" | "labeled_checkbox" | "date_label" + | "pick_folder", + ), + ) => &[0], + (_, Some("text_input" | "labeled_input" | "pick_media_files")) => &[0, 1], + _ => &[], + }; + for index in indices { + if let Some(argument) = call.args.get(*index) { + self.check(last.as_deref().unwrap_or("call"), argument); + } + } + } + syn::visit::visit_expr_call(self, call); + } + + fn visit_expr_method_call(&mut self, call: &'ast ExprMethodCall) { + let method = call.method.to_string(); + let index = match method.as_str() { + "notify" => Some(1), + "add_output" | "set_title" | "set_description" | "set_text" | "submit" => Some(0), + "report_progress" => Some(2), + _ => None, + }; + if let Some(index) = index + && let Some(argument) = call.args.get(index) + { + self.check(&method, argument); + } + syn::visit::visit_expr_method_call(self, call); + } +} + +fn rust_files(directory: &Path, files: &mut Vec) { + for entry in fs::read_dir(directory).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + rust_files(&path, files); + } else if path.extension().is_some_and(|extension| extension == "rs") { + files.push(path); + } + } +} + +#[test] +fn ui_text_sinks_do_not_receive_untranslated_literals() { + let source_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + rust_files(&source_root, &mut files); + let mut violations = Vec::new(); + + for path in files { + let source = fs::read_to_string(&path).unwrap(); + let syntax = syn::parse_file(&source).unwrap_or_else(|error| { + panic!("failed to parse {}: {error}", path.display()); + }); + let mut visitor = UiLiteralVisitor::default(); + visitor.visit_file(&syntax); + violations.extend( + visitor + .violations + .into_iter() + .map(|violation| format!("{}: {violation}", path.display())), + ); + } + + assert!( + violations.is_empty(), + "user-facing literals must use t()/tw():\n{}", + violations.join("\n") + ); +} diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 068e63f..b1c9391 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -383,3 +383,23 @@ dashboard-and = und dashboard-pin = Anheften common-tabNotImplemented = Dieser Bereich wird in einem späteren Meilenstein implementiert. editor-noPreviewAvailable = Keine Vorschau verfügbar +common-operationFailed = { $operation } fehlgeschlagen: { $error } +common-databaseUnavailable = Datenbank nicht verfügbar +common-autoSave = Automatisches Speichern +common-metadataSync = Metadatenabgleich +common-load = Laden +common-createScript = Skript erstellen +common-createTemplate = Vorlage erstellen +settings-maxPostsPerPageInvalid = Beiträge pro Seite müssen eine gültige Zahl sein +settings-categoryNameRequired = Kategoriename ist erforderlich +settings-categoryAlreadyExists = Kategorie ist bereits vorhanden +siteValidation-summary = { $label }: fehlend={ $missing }, zusätzlich={ $extra }, veraltet={ $stale } +engine-readingProjectConfig = Projektkonfiguration wird gelesen... +engine-loadingPosts = Beiträge werden geladen... +engine-writingCalendar = Kalender-JSON wird geschrieben... +engine-indexingItem = Indizierung: { $current }/{ $total } — { $name } +engine-checkingItem = Prüfung: { $current }/{ $total } — { $name } +engine-renderingPost = { $post } wird gerendert +engine-writingGeneratedFiles = Generierte Dateien werden geschrieben +engine-generationComplete = Website-Erstellung abgeschlossen +engine-regeneratingItem = Neuerstellung: { $name } diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index 9b5542b..f04d7cc 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -383,3 +383,23 @@ dashboard-and = and dashboard-pin = Pin common-tabNotImplemented = This area will be implemented in a later milestone. editor-noPreviewAvailable = No preview available +common-operationFailed = { $operation } failed: { $error } +common-databaseUnavailable = Database unavailable +common-autoSave = Auto-save +common-metadataSync = Metadata sync +common-load = Load +common-createScript = Create script +common-createTemplate = Create template +settings-maxPostsPerPageInvalid = Posts per page must be a valid number +settings-categoryNameRequired = Category name is required +settings-categoryAlreadyExists = Category already exists +siteValidation-summary = { $label }: missing={ $missing }, extra={ $extra }, stale={ $stale } +engine-readingProjectConfig = Reading project configuration... +engine-loadingPosts = Loading posts... +engine-writingCalendar = Writing calendar JSON... +engine-indexingItem = Indexing: { $current }/{ $total } — { $name } +engine-checkingItem = Checking: { $current }/{ $total } — { $name } +engine-renderingPost = Rendering { $post } +engine-writingGeneratedFiles = Writing generated files +engine-generationComplete = Site generation complete +engine-regeneratingItem = Regenerating: { $name } diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index 2db893e..a254b35 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -383,3 +383,23 @@ dashboard-and = y dashboard-pin = Fijar common-tabNotImplemented = Esta sección se implementará en un hito posterior. editor-noPreviewAvailable = No hay vista previa disponible +common-operationFailed = Error en { $operation }: { $error } +common-databaseUnavailable = Base de datos no disponible +common-autoSave = Guardado automático +common-metadataSync = Sincronización de metadatos +common-load = Cargar +common-createScript = Crear script +common-createTemplate = Crear plantilla +settings-maxPostsPerPageInvalid = Las entradas por página deben ser un número válido +settings-categoryNameRequired = El nombre de la categoría es obligatorio +settings-categoryAlreadyExists = La categoría ya existe +siteValidation-summary = { $label }: faltantes={ $missing }, adicionales={ $extra }, obsoletos={ $stale } +engine-readingProjectConfig = Leyendo la configuración del proyecto... +engine-loadingPosts = Cargando entradas... +engine-writingCalendar = Escribiendo el calendario JSON... +engine-indexingItem = Indexando: { $current }/{ $total } — { $name } +engine-checkingItem = Comprobando: { $current }/{ $total } — { $name } +engine-renderingPost = Renderizando { $post } +engine-writingGeneratedFiles = Escribiendo archivos generados +engine-generationComplete = Generación del sitio completada +engine-regeneratingItem = Regenerando: { $name } diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 113e5dd..4529648 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -383,3 +383,23 @@ dashboard-and = et dashboard-pin = Épingler common-tabNotImplemented = Cette section sera mise en œuvre lors d’une étape ultérieure. editor-noPreviewAvailable = Aucun aperçu disponible +common-operationFailed = Échec de l’opération « { $operation } » : { $error } +common-databaseUnavailable = Base de données indisponible +common-autoSave = Enregistrement automatique +common-metadataSync = Synchronisation des métadonnées +common-load = Charger +common-createScript = Créer un script +common-createTemplate = Créer un modèle +settings-maxPostsPerPageInvalid = Le nombre d’articles par page doit être valide +settings-categoryNameRequired = Le nom de la catégorie est obligatoire +settings-categoryAlreadyExists = Cette catégorie existe déjà +siteValidation-summary = { $label } : manquants={ $missing }, supplémentaires={ $extra }, obsolètes={ $stale } +engine-readingProjectConfig = Lecture de la configuration du projet... +engine-loadingPosts = Chargement des articles... +engine-writingCalendar = Écriture du calendrier JSON... +engine-indexingItem = Indexation : { $current }/{ $total } — { $name } +engine-checkingItem = Vérification : { $current }/{ $total } — { $name } +engine-renderingPost = Rendu de { $post } +engine-writingGeneratedFiles = Écriture des fichiers générés +engine-generationComplete = Génération du site terminée +engine-regeneratingItem = Régénération : { $name } diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index db7ad58..7fc8e8a 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -383,3 +383,23 @@ dashboard-and = e dashboard-pin = Fissa common-tabNotImplemented = Questa sezione verrà implementata in una fase successiva. editor-noPreviewAvailable = Nessuna anteprima disponibile +common-operationFailed = Operazione non riuscita ({ $operation }): { $error } +common-databaseUnavailable = Database non disponibile +common-autoSave = Salvataggio automatico +common-metadataSync = Sincronizzazione dei metadati +common-load = Carica +common-createScript = Crea script +common-createTemplate = Crea modello +settings-maxPostsPerPageInvalid = Il numero di articoli per pagina deve essere valido +settings-categoryNameRequired = Il nome della categoria è obbligatorio +settings-categoryAlreadyExists = La categoria esiste già +siteValidation-summary = { $label }: mancanti={ $missing }, aggiuntivi={ $extra }, obsoleti={ $stale } +engine-readingProjectConfig = Lettura della configurazione del progetto... +engine-loadingPosts = Caricamento degli articoli... +engine-writingCalendar = Scrittura del calendario JSON... +engine-indexingItem = Indicizzazione: { $current }/{ $total } — { $name } +engine-checkingItem = Verifica: { $current }/{ $total } — { $name } +engine-renderingPost = Rendering di { $post } +engine-writingGeneratedFiles = Scrittura dei file generati +engine-generationComplete = Generazione del sito completata +engine-regeneratingItem = Rigenerazione: { $name }