Enforce i18n completeness.
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,11 +1333,17 @@ impl BdsApp {
|
||||
))
|
||||
},
|
||||
),
|
||||
Message::ReindexText => self.spawn_engine_task(
|
||||
Message::ReindexText => {
|
||||
let locale = self.ui_locale;
|
||||
self.spawn_engine_task(
|
||||
"engine.reindexStarted",
|
||||
|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())?;
|
||||
tm.report_progress(tid, Some(0.0), Some("Reading project config...".into()));
|
||||
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)
|
||||
@@ -1348,7 +1356,15 @@ impl BdsApp {
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let msg = format!("Indexing: {current}/{total} \u{2014} {name}");
|
||||
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(
|
||||
@@ -1363,26 +1379,35 @@ impl BdsApp {
|
||||
report.posts_indexed, report.media_indexed
|
||||
))
|
||||
},
|
||||
),
|
||||
Message::RegenerateCalendar => self.spawn_engine_task(
|
||||
)
|
||||
}
|
||||
Message::RegenerateCalendar => {
|
||||
let locale = self.ui_locale;
|
||||
self.spawn_engine_task(
|
||||
"engine.calendarStarted",
|
||||
|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())?;
|
||||
tm.report_progress(tid, Some(0.20), Some("Loading posts...".into()));
|
||||
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("Writing calendar JSON...".into()));
|
||||
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,9 +1450,11 @@ impl BdsApp {
|
||||
},
|
||||
)
|
||||
}
|
||||
Message::ValidateMedia => self.spawn_engine_task(
|
||||
Message::ValidateMedia => {
|
||||
let locale = self.ui_locale;
|
||||
self.spawn_engine_task(
|
||||
"engine.validateMediaStarted",
|
||||
|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 on_item: engine::validate_media::ProgressFn =
|
||||
Box::new(move |current, total, name| {
|
||||
@@ -1428,7 +1463,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),
|
||||
],
|
||||
);
|
||||
tm.report_progress(tid, Some(pct), Some(msg));
|
||||
});
|
||||
let report = engine::validate_media::validate_media(
|
||||
@@ -1444,13 +1487,16 @@ impl BdsApp {
|
||||
report.issues.len()
|
||||
))
|
||||
},
|
||||
),
|
||||
Message::GenerateSite => self.spawn_engine_task(
|
||||
)
|
||||
}
|
||||
Message::GenerateSite => {
|
||||
let locale = self.ui_locale;
|
||||
self.spawn_engine_task(
|
||||
"engine.generateSiteStarted",
|
||||
|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 metadata =
|
||||
engine::meta::read_project_json(&data_dir).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()
|
||||
@@ -1458,14 +1504,18 @@ impl BdsApp {
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err("public URL is required before generating the site".to_string());
|
||||
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)
|
||||
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()
|
||||
@@ -1477,7 +1527,7 @@ impl BdsApp {
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(((index as f32) / total) * 0.7),
|
||||
Some(format!("Rendering {}", post.slug)),
|
||||
Some(tw(locale, "engine.renderingPost", &[("post", &post.slug)])),
|
||||
);
|
||||
if let Some(source) =
|
||||
engine::generation::load_published_post_source(&data_dir, post)
|
||||
@@ -1488,7 +1538,11 @@ impl BdsApp {
|
||||
}
|
||||
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()));
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(0.85),
|
||||
Some(t(locale, "engine.writingGeneratedFiles")),
|
||||
);
|
||||
let report = engine::generation::generate_starter_site(
|
||||
db.conn(),
|
||||
&output_dir,
|
||||
@@ -1498,7 +1552,11 @@ impl BdsApp {
|
||||
&main_language,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tm.report_progress(tid, Some(1.0), Some("Site generation complete".into()));
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(1.0),
|
||||
Some(t(locale, "engine.generationComplete")),
|
||||
);
|
||||
Ok(format!(
|
||||
"written={}, skipped={}, output={}",
|
||||
report.written_paths.len(),
|
||||
@@ -1506,7 +1564,8 @@ impl BdsApp {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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::<i32>() {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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 {
|
||||
|
||||
373
crates/bds-ui/tests/i18n_completeness.rs
Normal file
373
crates/bds-ui/tests/i18n_completeness.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user