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",
"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",

View File

@@ -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"

View File

@@ -1050,7 +1050,9 @@ impl BdsApp {
// Per metadata.allium StartupSync: sync metadata from filesystem
if let Some(data_dir) = self.data_dir.clone() {
if let Err(e) = engine::meta::startup_sync(&data_dir) {
self.add_output(&format!("Metadata sync failed: {e}"));
let message =
self.operation_failed_text("common.metadataSync", e.to_string());
self.add_output(&message);
}
// Extract content language from project metadata
if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
@@ -1331,58 +1333,81 @@ impl BdsApp {
))
},
),
Message::ReindexText => self.spawn_engine_task(
"engine.reindexStarted",
|db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
tm.report_progress(tid, Some(0.0), Some("Reading project config...".into()));
let main_lang = engine::meta::read_project_json(&data_dir)
.ok()
.and_then(|m| m.main_language)
.unwrap_or_else(|| "en".to_string());
let tm2 = Arc::clone(&tm);
let on_item: engine::search::ItemProgressFn =
Box::new(move |current, total, name| {
let pct = if total > 0 {
current as f32 / total as f32
} else {
1.0
};
let msg = format!("Indexing: {current}/{total} \u{2014} {name}");
tm2.report_progress(tid, Some(pct), Some(msg));
});
let report = engine::search::reindex_all_with_progress(
db.conn(),
&project_id,
&main_lang,
Some(on_item),
)
.map_err(|e| e.to_string())?;
Ok(format!(
"posts={}, media={}",
report.posts_indexed, report.media_indexed
))
},
),
Message::RegenerateCalendar => self.spawn_engine_task(
"engine.calendarStarted",
|db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
tm.report_progress(tid, Some(0.20), Some("Loading posts...".into()));
engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id)
Message::ReindexText => {
let locale = self.ui_locale;
self.spawn_engine_task(
"engine.reindexStarted",
move |db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
tm.report_progress(
tid,
Some(0.0),
Some(t(locale, "engine.readingProjectConfig")),
);
let main_lang = engine::meta::read_project_json(&data_dir)
.ok()
.and_then(|m| m.main_language)
.unwrap_or_else(|| "en".to_string());
let tm2 = Arc::clone(&tm);
let on_item: engine::search::ItemProgressFn =
Box::new(move |current, total, name| {
let pct = if total > 0 {
current as f32 / total as f32
} else {
1.0
};
let msg = tw(
locale,
"engine.indexingItem",
&[
("current", &current.to_string()),
("total", &total.to_string()),
("name", name),
],
);
tm2.report_progress(tid, Some(pct), Some(msg));
});
let report = engine::search::reindex_all_with_progress(
db.conn(),
&project_id,
&main_lang,
Some(on_item),
)
.map_err(|e| e.to_string())?;
tm.report_progress(tid, Some(0.90), Some("Writing calendar JSON...".into()));
Ok("done".to_string())
},
),
Ok(format!(
"posts={}, media={}",
report.posts_indexed, report.media_indexed
))
},
)
}
Message::RegenerateCalendar => {
let locale = self.ui_locale;
self.spawn_engine_task(
"engine.calendarStarted",
move |db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
tm.report_progress(tid, Some(0.20), Some(t(locale, "engine.loadingPosts")));
engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id)
.map_err(|e| e.to_string())?;
tm.report_progress(
tid,
Some(0.90),
Some(t(locale, "engine.writingCalendar")),
);
Ok("done".to_string())
},
)
}
Message::ValidateTranslations => {
let locale = self.ui_locale;
self.open_singleton_tab(
TabType::TranslationValidation,
"tabBar.translationValidation",
);
self.spawn_engine_task(
"engine.validateTranslationsStarted",
|db_path, project_id, data_dir, tm, tid| {
move |db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let meta = engine::meta::read_project_json(&data_dir)
.map_err(|e| e.to_string())?;
@@ -1396,7 +1421,15 @@ impl BdsApp {
} else {
1.0
};
let msg = format!("Checking: {current}/{total} \u{2014} {name}");
let msg = tw(
locale,
"engine.checkingItem",
&[
("current", &current.to_string()),
("total", &total.to_string()),
("name", name),
],
);
tm2.report_progress(tid, Some(pct), Some(msg));
});
let report =
@@ -1417,96 +1450,122 @@ impl BdsApp {
},
)
}
Message::ValidateMedia => self.spawn_engine_task(
"engine.validateMediaStarted",
|db_path, project_id, _data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let on_item: engine::validate_media::ProgressFn =
Box::new(move |current, total, name| {
let pct = if total > 0 {
current as f32 / total as f32
} else {
1.0
};
let msg = format!("Checking: {current}/{total} \u{2014} {name}");
tm.report_progress(tid, Some(pct), Some(msg));
});
let report = engine::validate_media::validate_media(
db.conn(),
&_data_dir,
&project_id,
Some(on_item),
)
.map_err(|e| e.to_string())?;
Ok(format!(
"checked={}, issues={}",
report.total_checked,
report.issues.len()
))
},
),
Message::GenerateSite => self.spawn_engine_task(
"engine.generateSiteStarted",
|db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let metadata =
engine::meta::read_project_json(&data_dir).map_err(|e| e.to_string())?;
if metadata
.public_url
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
return Err("public URL is required before generating the site".to_string());
}
let main_language = metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string());
let all_posts =
bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)
Message::ValidateMedia => {
let locale = self.ui_locale;
self.spawn_engine_task(
"engine.validateMediaStarted",
move |db_path, project_id, _data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let on_item: engine::validate_media::ProgressFn =
Box::new(move |current, total, name| {
let pct = if total > 0 {
current as f32 / total as f32
} else {
1.0
};
let msg = tw(
locale,
"engine.checkingItem",
&[
("current", &current.to_string()),
("total", &total.to_string()),
("name", name),
],
);
tm.report_progress(tid, Some(pct), Some(msg));
});
let report = engine::validate_media::validate_media(
db.conn(),
&_data_dir,
&project_id,
Some(on_item),
)
.map_err(|e| e.to_string())?;
Ok(format!(
"checked={}, issues={}",
report.total_checked,
report.issues.len()
))
},
)
}
Message::GenerateSite => {
let locale = self.ui_locale;
self.spawn_engine_task(
"engine.generateSiteStarted",
move |db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let metadata = engine::meta::read_project_json(&data_dir)
.map_err(|e| e.to_string())?;
let published_posts = all_posts
.into_iter()
.filter(engine::generation::has_published_snapshot)
.collect::<Vec<_>>();
let total = published_posts.len().max(1) as f32;
let mut sources = Vec::new();
for (index, post) in published_posts.into_iter().enumerate() {
if metadata
.public_url
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
return Err(
"public URL is required before generating the site".to_string()
);
}
let main_language = metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string());
let all_posts = bds_core::db::queries::post::list_posts_by_project(
db.conn(),
&project_id,
)
.map_err(|e| e.to_string())?;
let published_posts = all_posts
.into_iter()
.filter(engine::generation::has_published_snapshot)
.collect::<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(
tid,
Some(((index as f32) / total) * 0.7),
Some(format!("Rendering {}", post.slug)),
Some(0.85),
Some(t(locale, "engine.writingGeneratedFiles")),
);
if let Some(source) =
engine::generation::load_published_post_source(&data_dir, post)
.map_err(|error| error.to_string())?
{
sources.push(source);
}
}
let output_dir = data_dir.join("html");
std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?;
tm.report_progress(tid, Some(0.85), Some("Writing generated files".into()));
let report = engine::generation::generate_starter_site(
db.conn(),
&output_dir,
&project_id,
&metadata,
&sources,
&main_language,
)
.map_err(|e| e.to_string())?;
tm.report_progress(tid, Some(1.0), Some("Site generation complete".into()));
Ok(format!(
"written={}, skipped={}, output={}",
report.written_paths.len(),
report.skipped_paths.len(),
output_dir.display(),
))
},
),
let report = engine::generation::generate_starter_site(
db.conn(),
&output_dir,
&project_id,
&metadata,
&sources,
&main_language,
)
.map_err(|e| e.to_string())?;
tm.report_progress(
tid,
Some(1.0),
Some(t(locale, "engine.generationComplete")),
);
Ok(format!(
"written={}, skipped={}, output={}",
report.written_paths.len(),
report.skipped_paths.len(),
output_dir.display(),
))
},
)
}
Message::RunMetadataDiff => {
self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
Task::none()
@@ -1525,7 +1584,12 @@ impl BdsApp {
}
Err(err) => {
self.task_manager.fail(task_id, err.clone());
self.notify(ToastLevel::Error, &format!("{label} failed: {err}"));
let message = tw(
self.ui_locale,
"common.operationFailed",
&[("operation", &label), ("error", &err)],
);
self.notify(ToastLevel::Error, &message);
}
}
let sidebar_task = self.refresh_counts();
@@ -1543,12 +1607,24 @@ impl BdsApp {
self.site_validation_state.stale_files = report.stale_pages;
self.notify(
ToastLevel::Success,
&format!(
"{}: missing={}, extra={}, stale={}",
t(self.ui_locale, "tabBar.siteValidation"),
self.site_validation_state.missing_files.len(),
self.site_validation_state.extra_files.len(),
self.site_validation_state.stale_files.len(),
&tw(
self.ui_locale,
"siteValidation.summary",
&[
("label", &t(self.ui_locale, "tabBar.siteValidation")),
(
"missing",
&self.site_validation_state.missing_files.len().to_string(),
),
(
"extra",
&self.site_validation_state.extra_files.len().to_string(),
),
(
"stale",
&self.site_validation_state.stale_files.len().to_string(),
),
],
),
);
}
@@ -2124,10 +2200,7 @@ impl BdsApp {
&post_id,
&media_id,
) {
self.notify(
ToastLevel::Error,
&format!("Failed to unlink media: {err}"),
);
self.notify_operation_failed("editor.unlinkMedia", err);
return Task::none();
}
self.refresh_post_relationships(&post_id);
@@ -2867,10 +2940,7 @@ impl BdsApp {
self.refresh_counts()
}
Err(error) => {
self.notify(
ToastLevel::Error,
&format!("Failed to create post: {error}"),
);
self.notify_operation_failed("modal.postInsertLink.createPost", error);
Task::none()
}
}
@@ -2910,10 +2980,7 @@ impl BdsApp {
self.refresh_counts()
}
Err(error) => {
self.notify(
ToastLevel::Error,
&format!("Failed to create script: {error}"),
);
self.notify_operation_failed("common.createScript", error);
Task::none()
}
}
@@ -2952,10 +3019,7 @@ impl BdsApp {
self.refresh_counts()
}
Err(error) => {
self.notify(
ToastLevel::Error,
&format!("Failed to create template: {error}"),
);
self.notify_operation_failed("common.createTemplate", error);
Task::none()
}
}
@@ -3020,11 +3084,26 @@ impl BdsApp {
for post_id in due_ids {
if let Err(error) = self.persist_post_editor_state(&post_id) {
self.notify(ToastLevel::Error, &format!("Auto-save failed: {error}"));
self.notify_operation_failed("common.autoSave", error);
}
}
}
fn operation_failed_text(&self, operation_key: &str, error: impl std::fmt::Display) -> String {
let operation = t(self.ui_locale, operation_key);
let error = error.to_string();
tw(
self.ui_locale,
"common.operationFailed",
&[("operation", &operation), ("error", &error)],
)
}
fn notify_operation_failed(&mut self, operation_key: &str, error: impl std::fmt::Display) {
let message = self.operation_failed_text(operation_key, error);
self.notify(ToastLevel::Error, &message);
}
fn add_output(&mut self, text: &str) {
self.output_entries.push(OutputEntry {
timestamp: chrono::Utc::now().timestamp(),
@@ -3832,14 +3911,14 @@ impl BdsApp {
fn save_post_editor(&mut self, post_id: &str) -> Task<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 {

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
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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -383,3 +383,23 @@ dashboard-and = et
dashboard-pin = Épingler
common-tabNotImplemented = Cette section sera mise en œuvre lors dune étape ultérieure.
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
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 }