Enforce i18n completeness.

This commit is contained in:
2026-07-18 15:33:38 +02:00
parent 2e35afc731
commit a727c9073d
9 changed files with 798 additions and 217 deletions

2
Cargo.lock generated
View File

@@ -844,6 +844,7 @@ dependencies = [
"bds-editor", "bds-editor",
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
"fluent-syntax",
"iced", "iced",
"muda", "muda",
"objc2 0.6.4", "objc2 0.6.4",
@@ -853,6 +854,7 @@ dependencies = [
"rfd", "rfd",
"rusqlite", "rusqlite",
"serde_json", "serde_json",
"syn 2.0.117",
"tempfile", "tempfile",
"tokio", "tokio",
"uuid", "uuid",

View File

@@ -27,4 +27,6 @@ objc2-foundation = "0.3"
objc2-app-kit = "0.3" objc2-app-kit = "0.3"
[dev-dependencies] [dev-dependencies]
fluent-syntax = "0.12"
syn = { version = "2", features = ["full", "visit"] }
tempfile = "3" tempfile = "3"

View File

@@ -1050,7 +1050,9 @@ impl BdsApp {
// Per metadata.allium StartupSync: sync metadata from filesystem // Per metadata.allium StartupSync: sync metadata from filesystem
if let Some(data_dir) = self.data_dir.clone() { if let Some(data_dir) = self.data_dir.clone() {
if let Err(e) = engine::meta::startup_sync(&data_dir) { 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 // Extract content language from project metadata
if let Ok(meta) = engine::meta::read_project_json(&data_dir) { if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
@@ -1331,58 +1333,81 @@ impl BdsApp {
)) ))
}, },
), ),
Message::ReindexText => self.spawn_engine_task( Message::ReindexText => {
"engine.reindexStarted", let locale = self.ui_locale;
|db_path, project_id, data_dir, tm, tid| { self.spawn_engine_task(
let db = Database::open(&db_path).map_err(|e| e.to_string())?; "engine.reindexStarted",
tm.report_progress(tid, Some(0.0), Some("Reading project config...".into())); move |db_path, project_id, data_dir, tm, tid| {
let main_lang = engine::meta::read_project_json(&data_dir) let db = Database::open(&db_path).map_err(|e| e.to_string())?;
.ok() tm.report_progress(
.and_then(|m| m.main_language) tid,
.unwrap_or_else(|| "en".to_string()); Some(0.0),
let tm2 = Arc::clone(&tm); Some(t(locale, "engine.readingProjectConfig")),
let on_item: engine::search::ItemProgressFn = );
Box::new(move |current, total, name| { let main_lang = engine::meta::read_project_json(&data_dir)
let pct = if total > 0 { .ok()
current as f32 / total as f32 .and_then(|m| m.main_language)
} else { .unwrap_or_else(|| "en".to_string());
1.0 let tm2 = Arc::clone(&tm);
}; let on_item: engine::search::ItemProgressFn =
let msg = format!("Indexing: {current}/{total} \u{2014} {name}"); Box::new(move |current, total, name| {
tm2.report_progress(tid, Some(pct), Some(msg)); let pct = if total > 0 {
}); current as f32 / total as f32
let report = engine::search::reindex_all_with_progress( } else {
db.conn(), 1.0
&project_id, };
&main_lang, let msg = tw(
Some(on_item), locale,
) "engine.indexingItem",
.map_err(|e| e.to_string())?; &[
Ok(format!( ("current", &current.to_string()),
"posts={}, media={}", ("total", &total.to_string()),
report.posts_indexed, report.media_indexed ("name", name),
)) ],
}, );
), tm2.report_progress(tid, Some(pct), Some(msg));
Message::RegenerateCalendar => self.spawn_engine_task( });
"engine.calendarStarted", let report = engine::search::reindex_all_with_progress(
|db_path, project_id, data_dir, tm, tid| { db.conn(),
let db = Database::open(&db_path).map_err(|e| e.to_string())?; &project_id,
tm.report_progress(tid, Some(0.20), Some("Loading posts...".into())); &main_lang,
engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id) Some(on_item),
)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
tm.report_progress(tid, Some(0.90), Some("Writing calendar JSON...".into())); Ok(format!(
Ok("done".to_string()) "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 => { Message::ValidateTranslations => {
let locale = self.ui_locale;
self.open_singleton_tab( self.open_singleton_tab(
TabType::TranslationValidation, TabType::TranslationValidation,
"tabBar.translationValidation", "tabBar.translationValidation",
); );
self.spawn_engine_task( self.spawn_engine_task(
"engine.validateTranslationsStarted", "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 db = Database::open(&db_path).map_err(|e| e.to_string())?;
let meta = engine::meta::read_project_json(&data_dir) let meta = engine::meta::read_project_json(&data_dir)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -1396,7 +1421,15 @@ impl BdsApp {
} else { } else {
1.0 1.0
}; };
let msg = format!("Checking: {current}/{total} \u{2014} {name}"); let msg = tw(
locale,
"engine.checkingItem",
&[
("current", &current.to_string()),
("total", &total.to_string()),
("name", name),
],
);
tm2.report_progress(tid, Some(pct), Some(msg)); tm2.report_progress(tid, Some(pct), Some(msg));
}); });
let report = let report =
@@ -1417,96 +1450,122 @@ impl BdsApp {
}, },
) )
} }
Message::ValidateMedia => self.spawn_engine_task( Message::ValidateMedia => {
"engine.validateMediaStarted", let locale = self.ui_locale;
|db_path, project_id, _data_dir, tm, tid| { self.spawn_engine_task(
let db = Database::open(&db_path).map_err(|e| e.to_string())?; "engine.validateMediaStarted",
let on_item: engine::validate_media::ProgressFn = move |db_path, project_id, _data_dir, tm, tid| {
Box::new(move |current, total, name| { let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let pct = if total > 0 { let on_item: engine::validate_media::ProgressFn =
current as f32 / total as f32 Box::new(move |current, total, name| {
} else { let pct = if total > 0 {
1.0 current as f32 / total as f32
}; } else {
let msg = format!("Checking: {current}/{total} \u{2014} {name}"); 1.0
tm.report_progress(tid, Some(pct), Some(msg)); };
}); let msg = tw(
let report = engine::validate_media::validate_media( locale,
db.conn(), "engine.checkingItem",
&_data_dir, &[
&project_id, ("current", &current.to_string()),
Some(on_item), ("total", &total.to_string()),
) ("name", name),
.map_err(|e| e.to_string())?; ],
Ok(format!( );
"checked={}, issues={}", tm.report_progress(tid, Some(pct), Some(msg));
report.total_checked, });
report.issues.len() let report = engine::validate_media::validate_media(
)) db.conn(),
}, &_data_dir,
), &project_id,
Message::GenerateSite => self.spawn_engine_task( Some(on_item),
"engine.generateSiteStarted", )
|db_path, project_id, data_dir, tm, tid| { .map_err(|e| e.to_string())?;
let db = Database::open(&db_path).map_err(|e| e.to_string())?; Ok(format!(
let metadata = "checked={}, issues={}",
engine::meta::read_project_json(&data_dir).map_err(|e| e.to_string())?; report.total_checked,
if metadata report.issues.len()
.public_url ))
.as_deref() },
.unwrap_or("") )
.trim() }
.is_empty() Message::GenerateSite => {
{ let locale = self.ui_locale;
return Err("public URL is required before generating the site".to_string()); self.spawn_engine_task(
} "engine.generateSiteStarted",
let main_language = metadata move |db_path, project_id, data_dir, tm, tid| {
.main_language let db = Database::open(&db_path).map_err(|e| e.to_string())?;
.clone() let metadata = engine::meta::read_project_json(&data_dir)
.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())?; .map_err(|e| e.to_string())?;
let published_posts = all_posts if metadata
.into_iter() .public_url
.filter(engine::generation::has_published_snapshot) .as_deref()
.collect::<Vec<_>>(); .unwrap_or("")
let total = published_posts.len().max(1) as f32; .trim()
let mut sources = Vec::new(); .is_empty()
for (index, post) in published_posts.into_iter().enumerate() { {
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::<Vec<_>>();
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( tm.report_progress(
tid, tid,
Some(((index as f32) / total) * 0.7), Some(0.85),
Some(format!("Rendering {}", post.slug)), Some(t(locale, "engine.writingGeneratedFiles")),
); );
if let Some(source) = let report = engine::generation::generate_starter_site(
engine::generation::load_published_post_source(&data_dir, post) db.conn(),
.map_err(|error| error.to_string())? &output_dir,
{ &project_id,
sources.push(source); &metadata,
} &sources,
} &main_language,
let output_dir = data_dir.join("html"); )
std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
tm.report_progress(tid, Some(0.85), Some("Writing generated files".into())); tm.report_progress(
let report = engine::generation::generate_starter_site( tid,
db.conn(), Some(1.0),
&output_dir, Some(t(locale, "engine.generationComplete")),
&project_id, );
&metadata, Ok(format!(
&sources, "written={}, skipped={}, output={}",
&main_language, report.written_paths.len(),
) report.skipped_paths.len(),
.map_err(|e| e.to_string())?; output_dir.display(),
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(),
))
},
),
Message::RunMetadataDiff => { Message::RunMetadataDiff => {
self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff"); self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
Task::none() Task::none()
@@ -1525,7 +1584,12 @@ impl BdsApp {
} }
Err(err) => { Err(err) => {
self.task_manager.fail(task_id, err.clone()); 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(); let sidebar_task = self.refresh_counts();
@@ -1543,12 +1607,24 @@ impl BdsApp {
self.site_validation_state.stale_files = report.stale_pages; self.site_validation_state.stale_files = report.stale_pages;
self.notify( self.notify(
ToastLevel::Success, ToastLevel::Success,
&format!( &tw(
"{}: missing={}, extra={}, stale={}", self.ui_locale,
t(self.ui_locale, "tabBar.siteValidation"), "siteValidation.summary",
self.site_validation_state.missing_files.len(), &[
self.site_validation_state.extra_files.len(), ("label", &t(self.ui_locale, "tabBar.siteValidation")),
self.site_validation_state.stale_files.len(), (
"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, &post_id,
&media_id, &media_id,
) { ) {
self.notify( self.notify_operation_failed("editor.unlinkMedia", err);
ToastLevel::Error,
&format!("Failed to unlink media: {err}"),
);
return Task::none(); return Task::none();
} }
self.refresh_post_relationships(&post_id); self.refresh_post_relationships(&post_id);
@@ -2867,10 +2940,7 @@ impl BdsApp {
self.refresh_counts() self.refresh_counts()
} }
Err(error) => { Err(error) => {
self.notify( self.notify_operation_failed("modal.postInsertLink.createPost", error);
ToastLevel::Error,
&format!("Failed to create post: {error}"),
);
Task::none() Task::none()
} }
} }
@@ -2910,10 +2980,7 @@ impl BdsApp {
self.refresh_counts() self.refresh_counts()
} }
Err(error) => { Err(error) => {
self.notify( self.notify_operation_failed("common.createScript", error);
ToastLevel::Error,
&format!("Failed to create script: {error}"),
);
Task::none() Task::none()
} }
} }
@@ -2952,10 +3019,7 @@ impl BdsApp {
self.refresh_counts() self.refresh_counts()
} }
Err(error) => { Err(error) => {
self.notify( self.notify_operation_failed("common.createTemplate", error);
ToastLevel::Error,
&format!("Failed to create template: {error}"),
);
Task::none() Task::none()
} }
} }
@@ -3020,11 +3084,26 @@ impl BdsApp {
for post_id in due_ids { for post_id in due_ids {
if let Err(error) = self.persist_post_editor_state(&post_id) { 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) { fn add_output(&mut self, text: &str) {
self.output_entries.push(OutputEntry { self.output_entries.push(OutputEntry {
timestamp: chrono::Utc::now().timestamp(), timestamp: chrono::Utc::now().timestamp(),
@@ -3832,14 +3911,14 @@ impl BdsApp {
fn save_post_editor(&mut self, post_id: &str) -> Task<Message> { fn save_post_editor(&mut self, post_id: &str) -> Task<Message> {
match self.persist_post_editor_state(post_id) { match self.persist_post_editor_state(post_id) {
Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")), 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() Task::none()
} }
fn publish_post_editor(&mut self, post_id: &str) -> Task<Message> { fn publish_post_editor(&mut self, post_id: &str) -> Task<Message> {
if let Err(e) = self.persist_post_editor_state(post_id) { 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(); return Task::none();
} }
let Some(ref db) = self.db else { let Some(ref db) = self.db else {
@@ -3863,7 +3942,7 @@ impl BdsApp {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.published")); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.published"));
} }
Err(e) => { Err(e) => {
self.notify(ToastLevel::Error, &format!("Publish failed: {e}")); self.notify_operation_failed("editor.publish", e);
} }
} }
Task::none() Task::none()
@@ -4265,7 +4344,7 @@ impl BdsApp {
fn save_media_editor(&mut self, media_id: &str) -> Task<Message> { fn save_media_editor(&mut self, media_id: &str) -> Task<Message> {
match self.persist_media_editor_state(media_id) { match self.persist_media_editor_state(media_id) {
Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")), 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() Task::none()
} }
@@ -4323,7 +4402,7 @@ impl BdsApp {
self.refresh_post_relationships(post_id); self.refresh_post_relationships(post_id);
} }
Err(error) => { 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); self.refresh_post_relationships(post_id);
} }
Err(error) => { Err(error) => {
self.notify( self.notify_operation_failed("editor.unlinkMedia", error);
ToastLevel::Error,
&format!("Failed to unlink post: {error}"),
);
} }
} }
} }
@@ -4380,7 +4456,7 @@ impl BdsApp {
if let Some(s) = self.template_editors.get_mut(template_id) { if let Some(s) = self.template_editors.get_mut(template_id) {
s.validation_error = Some(e.clone()); s.validation_error = Some(e.clone());
} }
self.notify(ToastLevel::Error, &format!("Save failed: {e}")); self.notify_operation_failed("common.save", e);
} }
} }
Task::none() Task::none()
@@ -4414,7 +4490,7 @@ impl BdsApp {
if let Some(s) = self.script_editors.get_mut(script_id) { if let Some(s) = self.script_editors.get_mut(script_id) {
s.validation_error = Some(e.clone()); s.validation_error = Some(e.clone());
} }
self.notify(ToastLevel::Error, &format!("Save failed: {e}")); self.notify_operation_failed("common.save", e);
} }
} }
Task::none() Task::none()
@@ -4573,7 +4649,7 @@ impl BdsApp {
self.close_entity_tab(post_id); self.close_entity_tab(post_id);
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); 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() Task::none()
} }
@@ -4613,7 +4689,7 @@ impl BdsApp {
self.refresh_post_relationships(post_id); self.refresh_post_relationships(post_id);
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); 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() Task::none()
} }
@@ -4643,7 +4719,7 @@ impl BdsApp {
self.sync_menu_state(); self.sync_menu_state();
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); 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() Task::none()
} }
@@ -4661,7 +4737,7 @@ impl BdsApp {
self.close_entity_tab(media_id); self.close_entity_tab(media_id);
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); 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() Task::none()
} }
@@ -4679,7 +4755,7 @@ impl BdsApp {
self.close_entity_tab(template_id); self.close_entity_tab(template_id);
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); 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() Task::none()
} }
@@ -4744,7 +4820,7 @@ impl BdsApp {
self.close_entity_tab(script_id); self.close_entity_tab(script_id);
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); 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() Task::none()
} }
@@ -4827,7 +4903,7 @@ impl BdsApp {
} }
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); 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() Task::none()
} }
@@ -4853,7 +4929,7 @@ impl BdsApp {
} }
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); 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() Task::none()
} }
@@ -5085,7 +5161,7 @@ impl BdsApp {
template_slug: tag.post_template_slug.unwrap_or_default(), 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 { if let Some(editing_tag) = created_editing {
@@ -5147,7 +5223,7 @@ impl BdsApp {
self.reload_tags_state(); self.reload_tags_state();
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); 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.reload_tags_state();
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); 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::<i32>() { let max_posts = match state.max_posts_per_page.trim().parse::<i32>() {
Ok(value) => value, Ok(value) => value,
Err(_) => { Err(_) => {
self.notify(ToastLevel::Error, "Invalid max posts per page"); self.notify(
ToastLevel::Error,
&t(self.ui_locale, "settings.maxPostsPerPageInvalid"),
);
return Task::none(); return Task::none();
} }
}; };
@@ -5355,7 +5434,7 @@ impl BdsApp {
meta.blog_languages = state.blog_languages.clone(); meta.blog_languages = state.blog_languages.clone();
meta.semantic_similarity_enabled = state.semantic_similarity_enabled; meta.semantic_similarity_enabled = state.semantic_similarity_enabled;
if let Err(e) = meta.validate() { if let Err(e) = meta.validate() {
self.notify(ToastLevel::Error, &format!("Save failed: {e}")); self.notify_operation_failed("common.save", e);
return Task::none(); return Task::none();
} }
project.name = state.project_name.clone(); project.name = state.project_name.clone();
@@ -5381,8 +5460,8 @@ impl BdsApp {
self.dashboard_state = Some(self.hydrate_dashboard_state()); self.dashboard_state = Some(self.hydrate_dashboard_state());
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); 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),
(_, Err(e)) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), (_, Err(e)) => self.notify_operation_failed("common.save", e),
} }
} }
} }
@@ -5404,7 +5483,7 @@ impl BdsApp {
Ok(_) => { Ok(_) => {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")) 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 => { SettingsMsg::AddCategory => {
let category_name = state.new_category_name.trim(); let category_name = state.new_category_name.trim();
if category_name.is_empty() { 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(); return Task::none();
} }
if state if state
@@ -5422,7 +5504,10 @@ impl BdsApp {
.iter() .iter()
.any(|row| row.name.eq_ignore_ascii_case(category_name)) .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(); return Task::none();
} }
if let Some(data_dir) = &self.data_dir { if let Some(data_dir) = &self.data_dir {
@@ -5435,7 +5520,7 @@ impl BdsApp {
self.dashboard_state = Some(self.hydrate_dashboard_state()); self.dashboard_state = Some(self.hydrate_dashboard_state());
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); 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.dashboard_state = Some(self.hydrate_dashboard_state());
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); 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.dashboard_state = Some(self.hydrate_dashboard_state());
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); 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.dashboard_state = Some(self.hydrate_dashboard_state());
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
} }
(Err(e), _) | (_, Err(e)) => { (Err(e), _) | (_, Err(e)) => self.notify_operation_failed("common.save", e),
self.notify(ToastLevel::Error, &format!("Save failed: {e}"))
}
} }
} }
} }
@@ -5577,7 +5660,7 @@ impl BdsApp {
Ok(()) => { Ok(()) => {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")) 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(()) => { Ok(()) => {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")) self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"))
} }
Err(error) => { Err(error) => self.notify_operation_failed("common.save", error),
self.notify(ToastLevel::Error, &format!("Save failed: {error}"))
}
} }
} }
} }
@@ -5632,9 +5713,7 @@ impl BdsApp {
Ok(()) => { Ok(()) => {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")) self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"))
} }
Err(error) => { Err(error) => self.notify_operation_failed("common.save", error),
self.notify(ToastLevel::Error, &format!("Save failed: {error}"))
}
} }
} }
} }
@@ -5647,7 +5726,7 @@ impl BdsApp {
Ok(()) => { Ok(()) => {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")) 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 => { SettingsMsg::RegenerateThumbnails => {
let locale = self.ui_locale;
return self.spawn_engine_task( return self.spawn_engine_task(
"settings.regenerateThumbnails", "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 db = Database::open(&db_path).map_err(|e| e.to_string())?;
let regenerated = Self::regenerate_project_thumbnails( let regenerated = Self::regenerate_project_thumbnails(
&db, &db,
@@ -5770,7 +5850,7 @@ impl BdsApp {
tm.report_progress( tm.report_progress(
tid, tid,
Some(progress), Some(progress),
Some(format!("Regenerating: {name}")), Some(tw(locale, "engine.regeneratingItem", &[("name", name)])),
); );
}, },
)?; )?;
@@ -5949,7 +6029,7 @@ impl BdsApp {
); );
} }
Err(e) => { 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) => { 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) => { Err(e) => {
self.notify( self.notify_operation_failed("activity.templates", e);
ToastLevel::Error,
&format!("Failed to load template: {e}"),
);
} }
} }
} }
@@ -6029,7 +6106,7 @@ impl BdsApp {
.insert(script.id.clone(), ScriptEditorState::from_script(&script)); .insert(script.id.clone(), ScriptEditorState::from_script(&script));
} }
Err(e) => { 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<Message> { fn run_post_ai_analysis(&mut self, post_id: &str) -> Task<Message> {
let Some(db) = &self.db else { 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(); return Task::none();
}; };
let Some(state) = self.post_editors.get(post_id).cloned() else { 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<Message> { fn run_post_taxonomy_analysis(&mut self, post_id: &str) -> Task<Message> {
let Some(db) = &self.db else { 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(); return Task::none();
}; };
let Some(state) = self.post_editors.get(post_id).cloned() else { 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<Message> { fn detect_post_language(&mut self, post_id: &str) -> Task<Message> {
let Some(db) = &self.db else { 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(); return Task::none();
}; };
let Some(state) = self.post_editors.get(post_id).cloned() else { 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<Message> { fn translate_post_to(&mut self, post_id: &str, target_language: &str) -> Task<Message> {
self.active_modal = None; self.active_modal = None;
let Some(db) = &self.db else { 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(); return Task::none();
}; };
let Some(data_dir) = &self.data_dir else { 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(); return Task::none();
}; };
let Some(state) = self.post_editors.get(post_id).cloned() else { 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<Message> { fn run_media_ai_analysis(&mut self, media_id: &str) -> Task<Message> {
let Some(db) = &self.db else { 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(); return Task::none();
}; };
let Some(data_dir) = &self.data_dir else { 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(); return Task::none();
}; };
let Some(state) = self.media_editors.get(media_id).cloned() else { 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<Message> { fn detect_media_language(&mut self, media_id: &str) -> Task<Message> {
let Some(db) = &self.db else { 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(); return Task::none();
}; };
let Some(state) = self.media_editors.get(media_id).cloned() else { 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<Message> { fn translate_media_to(&mut self, media_id: &str, target_language: &str) -> Task<Message> {
self.active_modal = None; self.active_modal = None;
let Some(db) = &self.db else { 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(); return Task::none();
}; };
let Some(state) = self.media_editors.get(media_id).cloned() else { let Some(state) = self.media_editors.get(media_id).cloned() else {

View File

@@ -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<String>) {
for element in &pattern.elements {
if let PatternElement::Placeable { expression } = element {
expression_variables(expression, variables);
}
}
}
fn arguments_variables(arguments: &CallArguments<&str>, variables: &mut BTreeSet<String>) {
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<String>) {
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<String>) {
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<String, BTreeSet<String>> {
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<String, BTreeSet<String>>,
source: &str,
) {
let actual = catalog_signature(source);
let missing = expected
.keys()
.filter(|key| !actual.contains_key(*key))
.collect::<Vec<_>>();
let extra = actual
.keys()
.filter(|key| !expected.contains_key(*key))
.collect::<Vec<_>>();
let mismatched_variables = expected
.iter()
.filter_map(|(key, variables)| {
actual
.get(key)
.filter(|actual| *actual != variables)
.map(|actual| (key, variables, actual))
})
.collect::<Vec<_>>();
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<String>,
}
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::<Expr, Token![,]>::parse_terminated)
{
for argument in &arguments {
self.visit_expr(argument);
}
}
}
}
fn collect_literals(expression: &Expr) -> Vec<String> {
let mut collector = LiteralCollector::default();
collector.visit_expr(expression);
collector.literals
}
#[derive(Default)]
struct UiLiteralVisitor {
violations: Vec<String>,
}
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::<Vec<_>>();
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<PathBuf>) {
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")
);
}

View File

@@ -383,3 +383,23 @@ dashboard-and = und
dashboard-pin = Anheften dashboard-pin = Anheften
common-tabNotImplemented = Dieser Bereich wird in einem späteren Meilenstein implementiert. common-tabNotImplemented = Dieser Bereich wird in einem späteren Meilenstein implementiert.
editor-noPreviewAvailable = Keine Vorschau verfügbar 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 }

View File

@@ -383,3 +383,23 @@ dashboard-and = and
dashboard-pin = Pin dashboard-pin = Pin
common-tabNotImplemented = This area will be implemented in a later milestone. common-tabNotImplemented = This area will be implemented in a later milestone.
editor-noPreviewAvailable = No preview available 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 }

View File

@@ -383,3 +383,23 @@ dashboard-and = y
dashboard-pin = Fijar dashboard-pin = Fijar
common-tabNotImplemented = Esta sección se implementará en un hito posterior. common-tabNotImplemented = Esta sección se implementará en un hito posterior.
editor-noPreviewAvailable = No hay vista previa disponible 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 }

View File

@@ -383,3 +383,23 @@ dashboard-and = et
dashboard-pin = Épingler dashboard-pin = Épingler
common-tabNotImplemented = Cette section sera mise en œuvre lors dune étape ultérieure. common-tabNotImplemented = Cette section sera mise en œuvre lors dune étape ultérieure.
editor-noPreviewAvailable = Aucun aperçu disponible editor-noPreviewAvailable = Aucun aperçu disponible
common-operationFailed = Échec de lopé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 darticles 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 }

View File

@@ -383,3 +383,23 @@ dashboard-and = e
dashboard-pin = Fissa dashboard-pin = Fissa
common-tabNotImplemented = Questa sezione verrà implementata in una fase successiva. common-tabNotImplemented = Questa sezione verrà implementata in una fase successiva.
editor-noPreviewAvailable = Nessuna anteprima disponibile 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 }