From eca5fafc1c46f0c5df4577bab3bfd63f4c2306ff Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 13 Apr 2026 20:45:29 +0200 Subject: [PATCH] feat: even more M4 closing --- crates/bds-core/src/engine/generation.rs | 294 +++++++++++++++++- crates/bds-core/src/engine/validate_site.rs | 11 +- crates/bds-core/src/render/generation.rs | 16 +- crates/bds-core/tests/m4_generation_engine.rs | 79 ++++- .../tests/m4_generation_primitives.rs | 26 ++ crates/bds-ui/src/app.rs | 82 +++++ crates/bds-ui/src/views/site_validation.rs | 12 +- locales/ui/de.json | 2 + locales/ui/en.json | 2 + locales/ui/es.json | 2 + locales/ui/fr.json | 2 + locales/ui/it.json | 2 + 12 files changed, 521 insertions(+), 9 deletions(-) diff --git a/crates/bds-core/src/engine/generation.rs b/crates/bds-core/src/engine/generation.rs index 8243c40..4a6b187 100644 --- a/crates/bds-core/src/engine/generation.rs +++ b/crates/bds-core/src/engine/generation.rs @@ -1,13 +1,15 @@ +use std::collections::{HashMap, HashSet}; use std::path::Path; -use std::collections::HashMap; use chrono::{DateTime, TimeZone, Utc}; use pagefind::api::PagefindIndex; use pagefind::options::PagefindServiceConfig; use rusqlite::Connection; +use walkdir::WalkDir; use crate::db::queries; use crate::engine::site_assets::write_bundled_site_assets; +use crate::engine::validate_site::SiteValidationReport; use crate::engine::{EngineError, EngineResult}; use crate::model::{Post, ProjectMetadata}; use crate::render::{ @@ -26,6 +28,16 @@ pub struct PublishedPostSource { pub struct GenerationReport { pub written_paths: Vec, pub skipped_paths: Vec, + pub deleted_paths: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum GenerationSection { + Core, + Single, + Category, + Tag, + Date, } pub fn generate_starter_site( @@ -87,6 +99,124 @@ pub fn generate_starter_site( Ok(report) } +pub fn sections_from_validation_report(report: &SiteValidationReport) -> Vec { + let mut sections = HashSet::new(); + let mut saw_unknown = false; + + for path in report + .missing_pages + .iter() + .chain(report.extra_pages.iter()) + .chain(report.stale_pages.iter()) + { + match classify_generated_path(path) { + Some(section) => { + sections.insert(section); + } + None => { + saw_unknown = true; + } + } + } + + if saw_unknown && !report_is_empty(report) { + return all_sections(); + } + + let mut ordered = sections.into_iter().collect::>(); + ordered.sort_by_key(section_sort_key); + ordered +} + +pub fn apply_validation_sections( + conn: &Connection, + output_dir: &Path, + project_id: &str, + metadata: &ProjectMetadata, + posts: &[PublishedPostSource], + sections: &[GenerationSection], +) -> EngineResult { + if sections.is_empty() { + return Ok(GenerationReport::default()); + } + + let section_set = sections.iter().copied().collect::>(); + let data_dir = project_data_dir(output_dir); + let input_posts = posts + .iter() + .map(|source| (source.post.clone(), source.body_markdown.clone())) + .collect::>(); + let artifacts = build_site_render_artifacts( + conn, + &data_dir, + project_id, + metadata, + &input_posts, + ) + .map_err(|error| EngineError::Parse(error.to_string()))?; + let mut report = GenerationReport::default(); + let expected_paths = expected_paths_for_sections(metadata, &artifacts.pages, §ion_set); + + for page in &artifacts.pages { + if path_matches_sections(&page.relative_path, §ion_set) { + write_out(conn, output_dir, project_id, &page.relative_path, &page.html, &mut report)?; + } + } + + if section_set.contains(&GenerationSection::Core) { + write_bundled_site_assets(conn, output_dir, project_id, &mut report)?; + write_out( + conn, + output_dir, + project_id, + "calendar.json", + &build_calendar_json(&posts.iter().map(|source| source.post.clone()).collect::>())?, + &mut report, + )?; + + for render_language in render_languages(metadata) { + let localized_posts = localized_sources( + conn, + &data_dir, + posts, + &render_language, + metadata, + )?; + let prefix = if render_language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) { + String::new() + } else { + format!("{}/", render_language) + }; + let rss = build_rss_xml(metadata, &localized_posts, &render_language); + if prefix.is_empty() { + write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?; + } + write_out(conn, output_dir, project_id, &format!("{prefix}feed.xml"), &rss, &mut report)?; + write_out( + conn, + output_dir, + project_id, + &format!("{prefix}atom.xml"), + &build_atom_xml(metadata, &localized_posts, &render_language), + &mut report, + )?; + write_out( + conn, + output_dir, + project_id, + &format!("{prefix}sitemap.xml"), + &build_sitemap_xml(metadata, &artifacts.pages, &localized_posts, &render_language), + &mut report, + )?; + } + } + + remove_extra_section_paths(output_dir, &expected_paths, §ion_set, &mut report)?; + write_pagefind_indexes(conn, output_dir, project_id, &artifacts.pagefind_documents, &mut report)?; + + Ok(report) +} + fn build_media_rewrite_map( conn: &Connection, project_id: &str, @@ -174,6 +304,168 @@ fn write_pagefind_indexes( Ok(()) } +fn project_data_dir(output_dir: &Path) -> std::path::PathBuf { + if output_dir.join("meta").exists() { + output_dir.to_path_buf() + } else { + output_dir.parent().unwrap_or(output_dir).to_path_buf() + } +} + +fn expected_paths_for_sections( + metadata: &ProjectMetadata, + pages: &[crate::render::SitePage], + sections: &HashSet, +) -> HashSet { + let mut expected = pages + .iter() + .filter(|page| path_matches_sections(&page.relative_path, sections)) + .map(|page| page.relative_path.clone()) + .collect::>(); + + if sections.contains(&GenerationSection::Core) { + expected.insert("calendar.json".to_string()); + expected.insert("rss.xml".to_string()); + for language in render_languages(metadata) { + let prefix = if language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) { + String::new() + } else { + format!("{language}/") + }; + expected.insert(format!("{prefix}feed.xml")); + expected.insert(format!("{prefix}atom.xml")); + expected.insert(format!("{prefix}sitemap.xml")); + } + } + + expected +} + +fn remove_extra_section_paths( + output_dir: &Path, + expected: &HashSet, + sections: &HashSet, + report: &mut GenerationReport, +) -> EngineResult<()> { + if !output_dir.exists() { + return Ok(()); + } + + let mut deleted = Vec::new(); + for entry in WalkDir::new(output_dir).into_iter().filter_map(Result::ok) { + if !entry.file_type().is_file() { + continue; + } + let rel = entry + .path() + .strip_prefix(output_dir) + .unwrap_or(entry.path()) + .to_string_lossy() + .replace('\\', "/"); + if rel.starts_with("meta/") + || rel.starts_with("posts/") + || rel.starts_with("media/") + || rel.starts_with("assets/") + || rel.starts_with("pagefind") + || rel.contains("/pagefind/") + { + continue; + } + if !matches_generated_extension(&rel) || !path_matches_sections(&rel, sections) || expected.contains(&rel) { + continue; + } + std::fs::remove_file(entry.path()).map_err(EngineError::Io)?; + deleted.push(rel); + } + + deleted.sort(); + report.deleted_paths.extend(deleted); + Ok(()) +} + +fn path_matches_sections(path: &str, sections: &HashSet) -> bool { + classify_generated_path(path) + .map(|section| sections.contains(§ion)) + .unwrap_or(false) +} + +fn classify_generated_path(path: &str) -> Option { + if path.ends_with(".xml") || path.ends_with(".json") { + return Some(GenerationSection::Core); + } + + let mut parts = path.split('/').collect::>(); + if parts.is_empty() { + return None; + } + if has_language_prefix(&parts) { + parts.remove(0); + } + + match parts.as_slice() { + ["index.html"] => Some(GenerationSection::Core), + ["category", ..] => Some(GenerationSection::Category), + ["tag", ..] => Some(GenerationSection::Tag), + [year, "index.html"] if is_year_segment(year) => Some(GenerationSection::Date), + [year, month, "index.html"] if is_year_segment(year) && is_month_segment(month) => Some(GenerationSection::Date), + [year, month, day, _slug, "index.html"] + if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) => Some(GenerationSection::Single), + _ => None, + } +} + +fn has_language_prefix(parts: &[&str]) -> bool { + match parts { + [first, second, ..] => { + !is_year_segment(first) + && *first != "category" + && *first != "tag" + && (*second == "index.html" || is_year_segment(second) || *second == "category" || *second == "tag") + } + _ => false, + } +} + +fn is_year_segment(value: &str) -> bool { + value.len() == 4 && value.chars().all(|ch| ch.is_ascii_digit()) +} + +fn is_month_segment(value: &str) -> bool { + value.len() == 2 && value.chars().all(|ch| ch.is_ascii_digit()) +} + +fn is_day_segment(value: &str) -> bool { + is_month_segment(value) +} + +fn matches_generated_extension(path: &str) -> bool { + path.ends_with(".html") || path.ends_with(".xml") || path.ends_with(".json") +} + +fn all_sections() -> Vec { + vec![ + GenerationSection::Core, + GenerationSection::Single, + GenerationSection::Category, + GenerationSection::Tag, + GenerationSection::Date, + ] +} + +fn section_sort_key(section: &GenerationSection) -> u8 { + match section { + GenerationSection::Core => 0, + GenerationSection::Single => 1, + GenerationSection::Category => 2, + GenerationSection::Tag => 3, + GenerationSection::Date => 4, + } +} + +fn report_is_empty(report: &SiteValidationReport) -> bool { + report.missing_pages.is_empty() && report.extra_pages.is_empty() && report.stale_pages.is_empty() +} + fn render_languages(metadata: &ProjectMetadata) -> Vec { let main = metadata.main_language.clone().unwrap_or_else(|| "en".to_string()); let mut languages = vec![main.clone()]; diff --git a/crates/bds-core/src/engine/validate_site.rs b/crates/bds-core/src/engine/validate_site.rs index 72b58ba..2cd8b6d 100644 --- a/crates/bds-core/src/engine/validate_site.rs +++ b/crates/bds-core/src/engine/validate_site.rs @@ -23,7 +23,7 @@ pub fn validate_site( project_id: &str, ) -> EngineResult { let metadata = crate::engine::meta::read_project_json(data_dir)?; - let output_dir = data_dir.to_path_buf(); + let output_dir = generated_output_dir(data_dir); let published_posts = load_published_posts(data_dir, conn, project_id)?; let artifacts = build_site_render_artifacts(conn, data_dir, project_id, &metadata, &published_posts) .map_err(|error| EngineError::Parse(error.to_string()))?; @@ -98,6 +98,15 @@ pub fn validate_site( }) } +fn generated_output_dir(data_dir: &Path) -> std::path::PathBuf { + let html_dir = data_dir.join("html"); + if html_dir.exists() { + html_dir + } else { + data_dir.to_path_buf() + } +} + fn load_published_posts( data_dir: &Path, conn: &Connection, diff --git a/crates/bds-core/src/render/generation.rs b/crates/bds-core/src/render/generation.rs index e5d2f2a..79e23e5 100644 --- a/crates/bds-core/src/render/generation.rs +++ b/crates/bds-core/src/render/generation.rs @@ -8,7 +8,7 @@ use serde::Serialize; use crate::db::queries::generated_file_hash as qhash; use crate::model::{GeneratedFileHash, Post}; -use crate::util::{atomic_write_str, content_hash, now_unix_ms}; +use crate::util::{atomic_write_str, content_hash, file_hash, now_unix_ms}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum GeneratedWriteOutcome { @@ -31,13 +31,16 @@ pub fn write_generated_file( content: &str, ) -> Result> { let hash = content_hash(content.as_bytes()); + let target_path = output_dir.join(relative_path); if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) { - if existing.content_hash == hash { + if existing.content_hash == hash + && target_path.exists() + && file_hash(&target_path)? == hash + { return Ok(GeneratedWriteOutcome::SkippedUnchanged); } } - let target_path = output_dir.join(relative_path); if let Some(parent) = target_path.parent() { fs::create_dir_all(parent)?; } @@ -64,13 +67,16 @@ pub fn write_generated_bytes( content: &[u8], ) -> Result> { let hash = content_hash(content); + let target_path = output_dir.join(relative_path); if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) { - if existing.content_hash == hash { + if existing.content_hash == hash + && target_path.exists() + && file_hash(&target_path)? == hash + { return Ok(GeneratedWriteOutcome::SkippedUnchanged); } } - let target_path = output_dir.join(relative_path); if let Some(parent) = target_path.parent() { fs::create_dir_all(parent)?; } diff --git a/crates/bds-core/tests/m4_generation_engine.rs b/crates/bds-core/tests/m4_generation_engine.rs index dd2d48e..8c8daa0 100644 --- a/crates/bds-core/tests/m4_generation_engine.rs +++ b/crates/bds-core/tests/m4_generation_engine.rs @@ -1,6 +1,9 @@ use bds_core::db::queries::project::insert_project; use bds_core::db::Database; -use bds_core::engine::generation::{PublishedPostSource, generate_starter_site}; +use bds_core::engine::generation::{ + PublishedPostSource, apply_validation_sections, generate_starter_site, + sections_from_validation_report, +}; use bds_core::engine::validate_site::validate_site; use bds_core::model::{Post, PostStatus, Project, ProjectMetadata}; use tempfile::TempDir; @@ -184,4 +187,78 @@ fn site_validation_detects_stale_and_missing_outputs() { assert!(report.stale_pages.contains(&"index.html".to_string())); assert!(report.missing_pages.contains(&"feed.xml".to_string())); assert!(report.extra_pages.is_empty()); +} + +#[test] +fn apply_validation_repairs_core_section_outputs() { + let (db, dir) = setup(); + let metadata = make_metadata(); + let post = make_post("hello", 1_710_000_000_000); + bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap(); + let posts = vec![PublishedPostSource { + post, + body_markdown: "Hello **world**".into(), + }]; + + generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap(); + std::fs::write(dir.path().join("index.html"), "tampered").unwrap(); + std::fs::remove_file(dir.path().join("feed.xml")).unwrap(); + + let report = validate_site(db.conn(), dir.path(), "p1").unwrap(); + let sections = sections_from_validation_report(&report); + let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, §ions).unwrap(); + let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap(); + + assert!(!apply_report.written_paths.is_empty() || !apply_report.skipped_paths.is_empty()); + assert!(repaired.missing_pages.is_empty()); + assert!(repaired.extra_pages.is_empty()); + assert!(repaired.stale_pages.is_empty()); +} + +#[test] +fn apply_validation_removes_extra_section_outputs() { + let (db, dir) = setup(); + let metadata = make_metadata(); + let post = make_post("hello", 1_710_000_000_000); + bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap(); + let posts = vec![PublishedPostSource { + post, + body_markdown: "Hello **world**".into(), + }]; + + generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap(); + let extra_dir = dir.path().join("tag/ghost"); + std::fs::create_dir_all(&extra_dir).unwrap(); + std::fs::write(extra_dir.join("index.html"), "ghost").unwrap(); + + let report = validate_site(db.conn(), dir.path(), "p1").unwrap(); + assert!(report.extra_pages.contains(&"tag/ghost/index.html".to_string())); + let sections = sections_from_validation_report(&report); + let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, §ions).unwrap(); + let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap(); + + assert!(apply_report.deleted_paths.contains(&"tag/ghost/index.html".to_string())); + assert!(repaired.extra_pages.is_empty()); +} + +#[test] +fn site_validation_uses_html_output_directory_when_present() { + let (db, dir) = setup(); + let metadata = make_metadata(); + let post = make_post("hello", 1_710_000_000_000); + bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap(); + let posts = vec![PublishedPostSource { + post, + body_markdown: "Hello **world**".into(), + }]; + + let output_dir = dir.path().join("html"); + std::fs::create_dir_all(&output_dir).unwrap(); + generate_starter_site(db.conn(), &output_dir, "p1", &metadata, &posts, "en").unwrap(); + + let report = validate_site(db.conn(), dir.path(), "p1").unwrap(); + + assert!(report.missing_pages.is_empty()); + assert!(report.extra_pages.is_empty()); + assert!(report.stale_pages.is_empty()); } \ No newline at end of file diff --git a/crates/bds-core/tests/m4_generation_primitives.rs b/crates/bds-core/tests/m4_generation_primitives.rs index c616828..1df8d36 100644 --- a/crates/bds-core/tests/m4_generation_primitives.rs +++ b/crates/bds-core/tests/m4_generation_primitives.rs @@ -75,6 +75,32 @@ fn generated_write_skips_unchanged_content() { assert!(fs::read_to_string(dir.path().join("index.html")).unwrap().contains("changed")); } +#[test] +fn generated_write_rewrites_missing_file_even_when_hash_matches() { + let (db, dir) = setup(); + + let first = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap(); + fs::remove_file(dir.path().join("index.html")).unwrap(); + let second = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap(); + + assert_eq!(first, GeneratedWriteOutcome::Written); + assert_eq!(second, GeneratedWriteOutcome::Written); + assert_eq!(fs::read_to_string(dir.path().join("index.html")).unwrap(), "hello"); +} + +#[test] +fn generated_write_rewrites_stale_file_even_when_db_hash_matches() { + let (db, dir) = setup(); + + let first = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap(); + fs::write(dir.path().join("index.html"), "tampered").unwrap(); + let second = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap(); + + assert_eq!(first, GeneratedWriteOutcome::Written); + assert_eq!(second, GeneratedWriteOutcome::Written); + assert_eq!(fs::read_to_string(dir.path().join("index.html")).unwrap(), "hello"); +} + #[test] fn core_generation_paths_include_language_prefixed_variants() { let paths = build_core_generation_paths("en", &["en".into(), "de".into(), "fr".into()]); diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 05c97fd..79ed63d 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -137,8 +137,10 @@ pub enum Message { GenerateSite, RunMetadataDiff, RunSiteValidation, + ApplySiteValidation, EngineTaskDone { task_id: TaskId, label: String, result: Result }, SiteValidationLoaded(Result), + SiteValidationApplied(Result), // Editor views PostEditor(PostEditorMsg), @@ -2351,6 +2353,7 @@ impl BdsApp { Task::none() } Message::RunSiteValidation => self.start_site_validation(), + Message::ApplySiteValidation => self.apply_site_validation(), Message::EngineTaskDone { task_id, label, result } => { match &result { Ok(detail) => { @@ -2396,6 +2399,21 @@ impl BdsApp { } Task::none() } + Message::SiteValidationApplied(result) => { + self.site_validation_state.is_applying = false; + match result { + Ok(detail) => { + self.site_validation_state.error_message = None; + self.notify(ToastLevel::Success, &detail); + self.start_site_validation() + } + Err(error) => { + self.site_validation_state.error_message = Some(error.clone()); + self.notify(ToastLevel::Error, &error); + Task::none() + } + } + } // ── Toast ── Message::ShowToast(level, msg) => { @@ -3703,6 +3721,70 @@ impl BdsApp { ) } + fn apply_site_validation(&mut self) -> Task { + if self.site_validation_state.is_running || self.site_validation_state.is_applying { + return Task::none(); + } + + let report = engine::validate_site::SiteValidationReport { + missing_pages: self.site_validation_state.missing_files.clone(), + extra_pages: self.site_validation_state.extra_files.clone(), + stale_pages: self.site_validation_state.stale_files.clone(), + }; + let sections = engine::generation::sections_from_validation_report(&report); + if sections.is_empty() { + return Task::none(); + } + + let Some(project_id) = self.active_project.as_ref().map(|project| project.id.clone()) else { + self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.generateSiteNoProject")); + return Task::none(); + }; + let Some(data_dir) = self.data_dir.clone() else { + self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.previewDataDirUnavailable")); + return Task::none(); + }; + + self.site_validation_state.is_applying = true; + self.site_validation_state.error_message = None; + let db_path = self.db_path.clone(); + let applied_label = t(self.ui_locale, "siteValidation.apply"); + + Task::perform( + async move { + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + let metadata = engine::meta::read_project_json(&data_dir).map_err(|error| error.to_string())?; + let all_posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id) + .map_err(|error| error.to_string())?; + let mut sources = Vec::new(); + for post in all_posts.into_iter().filter(|post| post.status == PostStatus::Published) { + let body_markdown = load_generation_post_body(&data_dir, &post)?; + sources.push(engine::generation::PublishedPostSource { post, body_markdown }); + } + let output_dir = data_dir.join("html"); + std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?; + let apply_report = engine::generation::apply_validation_sections( + db.conn(), + &output_dir, + &project_id, + &metadata, + &sources, + §ions, + ) + .map_err(|error| error.to_string())?; + Ok(format!( + "{}: written={}, skipped={}, deleted={}, output={}", + applied_label, + apply_report.written_paths.len(), + apply_report.skipped_paths.len(), + apply_report.deleted_paths.len(), + output_dir.display(), + )) + }, + Message::SiteValidationApplied, + ) + } + /// Spawn a blocking engine operation on a background thread via TaskManager. /// /// Returns `Task::none()` if no active project/db/data_dir. diff --git a/crates/bds-ui/src/views/site_validation.rs b/crates/bds-ui/src/views/site_validation.rs index 72e584f..a1cf9f2 100644 --- a/crates/bds-ui/src/views/site_validation.rs +++ b/crates/bds-ui/src/views/site_validation.rs @@ -11,6 +11,7 @@ use crate::i18n::t; pub struct SiteValidationState { pub has_run: bool, pub is_running: bool, + pub is_applying: bool, pub missing_files: Vec, pub extra_files: Vec, pub stale_files: Vec, @@ -24,6 +25,15 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, button(text(t(locale, "siteValidation.run")).size(13).shaping(Shaping::Advanced)) .on_press(Message::RunSiteValidation) }; + let has_issues = !state.missing_files.is_empty() || !state.extra_files.is_empty() || !state.stale_files.is_empty(); + let apply_button = if state.is_applying { + button(text(t(locale, "siteValidation.applying")).size(13).shaping(Shaping::Advanced)) + } else if !state.is_running && state.error_message.is_none() && has_issues { + button(text(t(locale, "siteValidation.apply")).size(13).shaping(Shaping::Advanced)) + .on_press(Message::ApplySiteValidation) + } else { + button(text(t(locale, "siteValidation.apply")).size(13).shaping(Shaping::Advanced)) + }; let mut content = column![ row![ @@ -31,7 +41,7 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, .size(24) .shaping(Shaping::Advanced) .color(Color::from_rgb(0.88, 0.88, 0.92)), - run_button, + row![run_button, apply_button].spacing(12), ] .spacing(16), ] diff --git a/locales/ui/de.json b/locales/ui/de.json index 2854b6a..a7fdd3e 100644 --- a/locales/ui/de.json +++ b/locales/ui/de.json @@ -94,6 +94,8 @@ "tabBar.siteValidation": "Website-Validierung", "tabBar.translationValidation": "Übersetzungsvalidierung", "siteValidation.run": "Validierung starten", + "siteValidation.apply": "Validierung anwenden", + "siteValidation.applying": "Wird angewendet...", "siteValidation.idle": "Starte die Website-Validierung, um generierte Dateien mit dem erwarteten Output zu vergleichen.", "siteValidation.running": "Validierung läuft...", "siteValidation.clean": "Es wurden keine fehlenden, zusätzlichen oder veralteten generierten Dateien gefunden.", diff --git a/locales/ui/en.json b/locales/ui/en.json index 77b2c91..f34a362 100644 --- a/locales/ui/en.json +++ b/locales/ui/en.json @@ -94,6 +94,8 @@ "tabBar.siteValidation": "Site Validation", "tabBar.translationValidation": "Translation Validation", "siteValidation.run": "Run Validation", + "siteValidation.apply": "Apply Validation", + "siteValidation.applying": "Applying...", "siteValidation.idle": "Run site validation to compare generated files against expected output.", "siteValidation.running": "Validation in progress...", "siteValidation.clean": "No missing, extra, or stale generated files were found.", diff --git a/locales/ui/es.json b/locales/ui/es.json index ea7bcff..2290943 100644 --- a/locales/ui/es.json +++ b/locales/ui/es.json @@ -94,6 +94,8 @@ "tabBar.siteValidation": "Validación del sitio", "tabBar.translationValidation": "Validación de traducciones", "siteValidation.run": "Ejecutar validación", + "siteValidation.apply": "Aplicar validación", + "siteValidation.applying": "Aplicando...", "siteValidation.idle": "Ejecuta la validación del sitio para comparar los archivos generados con la salida esperada.", "siteValidation.running": "Validación en curso...", "siteValidation.clean": "No se encontraron archivos generados faltantes, extra o desactualizados.", diff --git a/locales/ui/fr.json b/locales/ui/fr.json index e677116..4a75a7f 100644 --- a/locales/ui/fr.json +++ b/locales/ui/fr.json @@ -94,6 +94,8 @@ "tabBar.siteValidation": "Validation du site", "tabBar.translationValidation": "Validation des traductions", "siteValidation.run": "Lancer la validation", + "siteValidation.apply": "Appliquer la validation", + "siteValidation.applying": "Application en cours...", "siteValidation.idle": "Lancez la validation du site pour comparer les fichiers générés à la sortie attendue.", "siteValidation.running": "Validation en cours...", "siteValidation.clean": "Aucun fichier généré manquant, supplémentaire ou obsolète n'a été trouvé.", diff --git a/locales/ui/it.json b/locales/ui/it.json index 6d67702..50cbdaf 100644 --- a/locales/ui/it.json +++ b/locales/ui/it.json @@ -94,6 +94,8 @@ "tabBar.siteValidation": "Validazione sito", "tabBar.translationValidation": "Validazione traduzioni", "siteValidation.run": "Esegui validazione", + "siteValidation.apply": "Applica validazione", + "siteValidation.applying": "Applicazione in corso...", "siteValidation.idle": "Esegui la validazione del sito per confrontare i file generati con l'output previsto.", "siteValidation.running": "Validazione in corso...", "siteValidation.clean": "Non sono stati trovati file generati mancanti, aggiuntivi o obsoleti.",