feat: even more M4 closing
This commit is contained in:
@@ -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<String>,
|
||||
pub skipped_paths: Vec<String>,
|
||||
pub deleted_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<GenerationSection> {
|
||||
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::<Vec<_>>();
|
||||
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<GenerationReport> {
|
||||
if sections.is_empty() {
|
||||
return Ok(GenerationReport::default());
|
||||
}
|
||||
|
||||
let section_set = sections.iter().copied().collect::<HashSet<_>>();
|
||||
let data_dir = project_data_dir(output_dir);
|
||||
let input_posts = posts
|
||||
.iter()
|
||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
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::<Vec<_>>())?,
|
||||
&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<GenerationSection>,
|
||||
) -> HashSet<String> {
|
||||
let mut expected = pages
|
||||
.iter()
|
||||
.filter(|page| path_matches_sections(&page.relative_path, sections))
|
||||
.map(|page| page.relative_path.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
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<String>,
|
||||
sections: &HashSet<GenerationSection>,
|
||||
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<GenerationSection>) -> bool {
|
||||
classify_generated_path(path)
|
||||
.map(|section| sections.contains(§ion))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn classify_generated_path(path: &str) -> Option<GenerationSection> {
|
||||
if path.ends_with(".xml") || path.ends_with(".json") {
|
||||
return Some(GenerationSection::Core);
|
||||
}
|
||||
|
||||
let mut parts = path.split('/').collect::<Vec<_>>();
|
||||
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<GenerationSection> {
|
||||
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<String> {
|
||||
let main = metadata.main_language.clone().unwrap_or_else(|| "en".to_string());
|
||||
let mut languages = vec![main.clone()];
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn validate_site(
|
||||
project_id: &str,
|
||||
) -> EngineResult<SiteValidationReport> {
|
||||
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,
|
||||
|
||||
@@ -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<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
|
||||
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<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
|
||||
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)?;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()]);
|
||||
|
||||
@@ -137,8 +137,10 @@ pub enum Message {
|
||||
GenerateSite,
|
||||
RunMetadataDiff,
|
||||
RunSiteValidation,
|
||||
ApplySiteValidation,
|
||||
EngineTaskDone { task_id: TaskId, label: String, result: Result<String, String> },
|
||||
SiteValidationLoaded(Result<engine::validate_site::SiteValidationReport, String>),
|
||||
SiteValidationApplied(Result<String, String>),
|
||||
|
||||
// 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<Message> {
|
||||
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.
|
||||
|
||||
@@ -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<String>,
|
||||
pub extra_files: Vec<String>,
|
||||
pub stale_files: Vec<String>,
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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é.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user