feat: more closing of M4
This commit is contained in:
@@ -59,9 +59,11 @@ struct PreviewServerState {
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct DraftPreviewQuery {
|
||||
language: Option<String>,
|
||||
theme: Option<String>,
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
struct StylePreviewQuery {
|
||||
theme: Option<String>,
|
||||
mode: Option<String>,
|
||||
@@ -115,9 +117,10 @@ pub fn start_preview_server(
|
||||
|
||||
async fn handle_preview_request(
|
||||
State(state): State<PreviewServerState>,
|
||||
Query(query): Query<StylePreviewQuery>,
|
||||
uri: Uri,
|
||||
) -> Response {
|
||||
match render_preview_response(&state, uri.path(), None) {
|
||||
match render_preview_response(&state, uri.path(), None, Some(&query)) {
|
||||
Ok(response) => response,
|
||||
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
@@ -128,7 +131,11 @@ async fn handle_draft_preview(
|
||||
AxumPath(post_id): AxumPath<String>,
|
||||
Query(query): Query<DraftPreviewQuery>,
|
||||
) -> Response {
|
||||
match render_preview_response(&state, &format!("/__draft/{post_id}"), query.language.as_deref()) {
|
||||
let style = StylePreviewQuery {
|
||||
theme: query.theme.clone(),
|
||||
mode: query.mode.clone(),
|
||||
};
|
||||
match render_preview_response(&state, &format!("/__draft/{post_id}"), query.language.as_deref(), Some(&style)) {
|
||||
Ok(response) => response,
|
||||
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
@@ -153,9 +160,10 @@ fn render_preview_response(
|
||||
state: &PreviewServerState,
|
||||
path: &str,
|
||||
requested_language: Option<&str>,
|
||||
style: Option<&StylePreviewQuery>,
|
||||
) -> EngineResult<Response> {
|
||||
if let Some(post_id) = path.strip_prefix("/__draft/") {
|
||||
let html = render_draft_preview(state, post_id, requested_language)?;
|
||||
let html = apply_preview_style(render_draft_preview(state, post_id, requested_language)?, style);
|
||||
return Ok(Html(html).into_response());
|
||||
}
|
||||
|
||||
@@ -173,7 +181,53 @@ fn render_preview_response(
|
||||
let response = build_preview_response(db.conn(), &state.data_dir, &state.project_id, &metadata, &input_posts, path)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
let status = StatusCode::from_u16(response.status_code).unwrap_or(StatusCode::OK);
|
||||
Ok((status, Html(response.html)).into_response())
|
||||
Ok((status, Html(apply_preview_style(response.html, style))).into_response())
|
||||
}
|
||||
|
||||
fn apply_preview_style(html: String, style: Option<&StylePreviewQuery>) -> String {
|
||||
let Some(style) = style else {
|
||||
return html;
|
||||
};
|
||||
if style.theme.as_deref().unwrap_or("").is_empty() && style.mode.as_deref().unwrap_or("").is_empty() {
|
||||
return html;
|
||||
}
|
||||
|
||||
let Some(start) = html.find("<html") else {
|
||||
return html;
|
||||
};
|
||||
let Some(end_rel) = html[start..].find('>') else {
|
||||
return html;
|
||||
};
|
||||
let end = start + end_rel;
|
||||
|
||||
let mut attrs = String::new();
|
||||
if let Some(theme) = style.theme.as_deref().filter(|value| !value.is_empty()) {
|
||||
attrs.push_str(" data-theme=\"");
|
||||
attrs.push_str(&escape_html_attr(theme));
|
||||
attrs.push('"');
|
||||
}
|
||||
if let Some(mode) = style.mode.as_deref().filter(|value| !value.is_empty()) {
|
||||
attrs.push_str(" data-mode=\"");
|
||||
attrs.push_str(&escape_html_attr(mode));
|
||||
attrs.push('"');
|
||||
}
|
||||
if attrs.is_empty() {
|
||||
return html;
|
||||
}
|
||||
|
||||
let mut styled = String::with_capacity(html.len() + attrs.len());
|
||||
styled.push_str(&html[..end]);
|
||||
styled.push_str(&attrs);
|
||||
styled.push_str(&html[end..]);
|
||||
styled
|
||||
}
|
||||
|
||||
fn escape_html_attr(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn render_draft_preview(
|
||||
@@ -579,4 +633,30 @@ mod tests {
|
||||
assert!(body.contains("nightfall"));
|
||||
assert!(body.contains("dark"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_server_applies_theme_query_to_rendered_pages() {
|
||||
let _guard = preview_port_guard().lock().unwrap();
|
||||
let (dir, _db) = setup_preview_fixture();
|
||||
|
||||
let server = start_preview_server(
|
||||
dir.path().join("bds.db"),
|
||||
dir.path().to_path_buf(),
|
||||
"project-1".into(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client
|
||||
.get(format!(
|
||||
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/?theme=nightfall&mode=dark"
|
||||
))
|
||||
.send()
|
||||
.unwrap();
|
||||
let body = response.text().unwrap();
|
||||
server.stop().unwrap();
|
||||
|
||||
assert!(body.contains("data-theme=\"nightfall\""));
|
||||
assert!(body.contains("data-mode=\"dark\""));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use rusqlite::Connection;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::db::queries;
|
||||
use crate::engine::menu::{self, MenuItemKind};
|
||||
use crate::model::{CategorySettings, Media, Post, ProjectMetadata, Tag, Template, TemplateKind, TemplateStatus};
|
||||
use crate::render::{RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path, render_liquid_template, resolve_post_template};
|
||||
use crate::util::frontmatter::{read_template_file, read_translation_file};
|
||||
@@ -100,6 +101,7 @@ pub fn build_site_render_artifacts(
|
||||
let localized_posts = load_language_posts(conn, data_dir, published_posts, &language, &main_language)?;
|
||||
let routes = build_language_routes(&localized_posts, metadata, &language, &tags);
|
||||
let post_data_json_by_id = build_post_data_json_by_id(&localized_posts);
|
||||
let menu_items = build_menu_items(data_dir, &language, &main_language)?;
|
||||
let rendered_list_pages = routes
|
||||
.par_iter()
|
||||
.map(|route| {
|
||||
@@ -109,6 +111,7 @@ pub fn build_site_render_artifacts(
|
||||
&language,
|
||||
&localized_posts,
|
||||
&tags,
|
||||
&menu_items,
|
||||
&post_data_json_by_id,
|
||||
&bundle,
|
||||
)
|
||||
@@ -144,6 +147,7 @@ pub fn build_site_render_artifacts(
|
||||
&category_settings,
|
||||
&media_by_id,
|
||||
&canonical_map,
|
||||
&menu_items,
|
||||
&post_data_json_by_id,
|
||||
&bundle,
|
||||
)?;
|
||||
@@ -186,7 +190,8 @@ pub fn build_preview_response(
|
||||
|
||||
let bundle = load_template_bundle(conn, data_dir, project_id)?;
|
||||
let language = language_from_path(&normalized, metadata);
|
||||
let html = render_not_found_route(&bundle, metadata, &language, &normalized)?;
|
||||
let menu_items = build_menu_items(data_dir, &language, main_language(metadata))?;
|
||||
let html = render_not_found_route(&bundle, metadata, &language, &normalized, &menu_items)?;
|
||||
Ok(PreviewRenderResult {
|
||||
status_code: 404,
|
||||
html,
|
||||
@@ -447,6 +452,7 @@ fn render_list_route(
|
||||
language: &str,
|
||||
posts: &[RenderPostRecord],
|
||||
tags: &[Tag],
|
||||
menu_items: &[Value],
|
||||
post_data_json_by_id: &HashMap<String, Value>,
|
||||
bundle: &TemplateBundle,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
@@ -461,7 +467,7 @@ fn render_list_route(
|
||||
"pico_stylesheet_href": pico_stylesheet_href(metadata),
|
||||
"blog_languages": build_list_blog_languages(metadata, language, &route.url_path),
|
||||
"alternate_links": build_alternate_list_links(metadata, &route.url_path),
|
||||
"menu_items": Vec::<Value>::new(),
|
||||
"menu_items": menu_items,
|
||||
"calendar_initial_year": route.posts.first().map(|post| calendar_initial_parts(&post.post).0).unwrap_or(1970),
|
||||
"calendar_initial_month": route.posts.first().map(|post| calendar_initial_parts(&post.post).1).unwrap_or(1),
|
||||
"archive_context": route.archive_context,
|
||||
@@ -506,6 +512,7 @@ fn render_post_route(
|
||||
category_settings: &HashMap<String, CategorySettings>,
|
||||
media_by_id: &HashMap<String, Media>,
|
||||
canonical_post_path_by_slug: &HashMap<String, String>,
|
||||
menu_items: &[Value],
|
||||
post_data_json_by_id: &HashMap<String, Value>,
|
||||
bundle: &TemplateBundle,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
@@ -553,7 +560,7 @@ fn render_post_route(
|
||||
"html_theme_attribute": serde_json::Value::Null,
|
||||
"alternate_links": build_alternate_post_links(&record.post, metadata),
|
||||
"blog_languages": build_post_blog_languages(&record.post, metadata, language),
|
||||
"menu_items": Vec::<Value>::new(),
|
||||
"menu_items": menu_items,
|
||||
"calendar_initial_year": calendar_initial_parts(&record.post).0,
|
||||
"calendar_initial_month": calendar_initial_parts(&record.post).1,
|
||||
"post": post_context(&record.post, &record.body_markdown, linked_media, outgoing_link_context, incoming_link_context),
|
||||
@@ -588,6 +595,7 @@ fn render_not_found_route(
|
||||
metadata: &ProjectMetadata,
|
||||
language: &str,
|
||||
requested_path: &str,
|
||||
menu_items: &[Value],
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let context = json!({
|
||||
"language": language,
|
||||
@@ -597,7 +605,7 @@ fn render_not_found_route(
|
||||
"pico_stylesheet_href": pico_stylesheet_href(metadata),
|
||||
"blog_languages": build_list_blog_languages(metadata, language, requested_path),
|
||||
"alternate_links": build_alternate_list_links(metadata, requested_path),
|
||||
"menu_items": Vec::<Value>::new(),
|
||||
"menu_items": menu_items,
|
||||
"calendar_initial_year": 1970,
|
||||
"calendar_initial_month": 1,
|
||||
"post": serde_json::Value::Null,
|
||||
@@ -618,7 +626,7 @@ fn render_not_found_route(
|
||||
"prev_page_href": serde_json::Value::Null,
|
||||
"next_page_href": serde_json::Value::Null,
|
||||
"not_found_message": format!("No rendered page exists for {}", requested_path),
|
||||
"not_found_back_label": "Back to home",
|
||||
"not_found_back_label": serde_json::Value::Null,
|
||||
"canonical_post_path_by_slug": HashMap::<String, String>::new(),
|
||||
"canonical_media_path_by_source_path": HashMap::<String, String>::new(),
|
||||
"post_data_json_by_id": HashMap::<String, Value>::new(),
|
||||
@@ -626,6 +634,68 @@ fn render_not_found_route(
|
||||
Ok(render_liquid_template(&bundle.not_found_template, &bundle.partials, &context)?)
|
||||
}
|
||||
|
||||
fn build_menu_items(
|
||||
data_dir: &Path,
|
||||
language: &str,
|
||||
main_language: &str,
|
||||
) -> Result<Vec<Value>, Box<dyn Error + Send + Sync>> {
|
||||
let items = menu::read_menu(data_dir)?;
|
||||
Ok(items
|
||||
.iter()
|
||||
.map(|item| menu_item_context(item, language, main_language))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn menu_item_context(item: &menu::MenuItem, language: &str, main_language: &str) -> Value {
|
||||
let children = item
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| menu_item_context(child, language, main_language))
|
||||
.collect::<Vec<_>>();
|
||||
json!({
|
||||
"title": item.label,
|
||||
"href": menu_item_href(item, language, main_language),
|
||||
"has_children": !children.is_empty(),
|
||||
"children": children,
|
||||
})
|
||||
}
|
||||
|
||||
fn menu_item_href(item: &menu::MenuItem, language: &str, main_language: &str) -> String {
|
||||
let prefix = language_prefix(language, main_language);
|
||||
match item.kind {
|
||||
MenuItemKind::Home => {
|
||||
if prefix.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("{prefix}/")
|
||||
}
|
||||
}
|
||||
MenuItemKind::Submenu => "#".to_string(),
|
||||
MenuItemKind::Page => item
|
||||
.slug
|
||||
.as_deref()
|
||||
.map(|slug| prefixed_slug_path(&prefix, slug))
|
||||
.unwrap_or_else(|| "#".to_string()),
|
||||
MenuItemKind::CategoryArchive => item
|
||||
.slug
|
||||
.as_deref()
|
||||
.map(|slug| format!("{}/category/{}/", prefix_or_root(&prefix), slugify(slug)))
|
||||
.unwrap_or_else(|| "#".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn prefixed_slug_path(prefix: &str, slug: &str) -> String {
|
||||
format!("{}{}/", prefix_or_root(prefix), slug.trim_matches('/'))
|
||||
}
|
||||
|
||||
fn prefix_or_root(prefix: &str) -> &str {
|
||||
if prefix.is_empty() {
|
||||
"/"
|
||||
} else {
|
||||
prefix
|
||||
}
|
||||
}
|
||||
|
||||
fn route_href(route: &RouteSpec, page: usize) -> String {
|
||||
let base = route.url_path.trim_end_matches('/');
|
||||
if page <= 1 {
|
||||
|
||||
@@ -30,6 +30,7 @@ use crate::views::{
|
||||
modal, workspace,
|
||||
post_editor::{LinkedMediaItem, PostEditorMsg, PostEditorState, ResolvedPostLink},
|
||||
media_editor::{LinkedPostItem, MediaEditorState, MediaEditorMsg},
|
||||
site_validation::SiteValidationState,
|
||||
template_editor::{TemplateEditorState, TemplateEditorMsg},
|
||||
script_editor::{ScriptEditorState, ScriptEditorMsg},
|
||||
tags_view::{self, TagsMsg, TagsSection, TagsViewState},
|
||||
@@ -135,7 +136,9 @@ pub enum Message {
|
||||
ValidateMedia,
|
||||
GenerateSite,
|
||||
RunMetadataDiff,
|
||||
RunSiteValidation,
|
||||
EngineTaskDone { task_id: TaskId, label: String, result: Result<String, String> },
|
||||
SiteValidationLoaded(Result<engine::validate_site::SiteValidationReport, String>),
|
||||
|
||||
// Editor views
|
||||
PostEditor(PostEditorMsg),
|
||||
@@ -555,8 +558,11 @@ mod tests {
|
||||
use bds_core::db::fts::ensure_fts_tables;
|
||||
use bds_core::db::queries::project::insert_project;
|
||||
use bds_core::db::Database;
|
||||
use bds_core::engine::{media, post, script, template};
|
||||
use bds_core::engine::{ai, media, post, script, template};
|
||||
use bds_core::model::{Project, ScriptKind, TemplateKind};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_project() -> Project {
|
||||
@@ -609,6 +615,28 @@ mod tests {
|
||||
(db, project, tempdir)
|
||||
}
|
||||
|
||||
fn spawn_models_server() -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
thread::spawn(move || {
|
||||
if let Some(stream) = listener.incoming().next() {
|
||||
let mut stream = stream.unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let size = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..size]).to_string();
|
||||
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
|
||||
let body = r#"{"data":[{"id":"llama3.2","name":"Llama 3.2"}]}"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body,
|
||||
);
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
}
|
||||
});
|
||||
format!("http://{}", addr)
|
||||
}
|
||||
|
||||
fn make_app(db: Database, project: Project, tmp: &TempDir) -> BdsApp {
|
||||
BdsApp::new_for_tests(db, project, tmp.path().to_path_buf())
|
||||
}
|
||||
@@ -801,6 +829,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_ai_settings_allows_airplane_only_configuration() {
|
||||
let (db, _project, _tmp) = setup();
|
||||
let mut state = SettingsViewState::default();
|
||||
state.airplane_endpoint_url = spawn_models_server();
|
||||
state.airplane_endpoint_model = "llama3.2".to_string();
|
||||
state.system_prompt = iced::widget::text_editor::Content::with_text("Use JSON only.");
|
||||
|
||||
BdsApp::save_ai_settings_state(&db, &mut state).unwrap();
|
||||
|
||||
let settings = ai::load_ai_settings(db.conn(), false).unwrap();
|
||||
assert!(settings.online_endpoint.url.is_empty());
|
||||
assert!(settings.online_endpoint.model.is_empty());
|
||||
assert_eq!(settings.airplane_endpoint.url, state.airplane_endpoint_url);
|
||||
assert_eq!(settings.airplane_endpoint.model, "llama3.2");
|
||||
assert_eq!(settings.system_prompt.trim_end(), "Use JSON only.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_editor_save_flow_persists_changes() {
|
||||
let (db, project, tmp) = setup();
|
||||
@@ -1607,6 +1653,7 @@ pub struct BdsApp {
|
||||
tags_view_state: Option<TagsViewState>,
|
||||
settings_state: Option<SettingsViewState>,
|
||||
dashboard_state: Option<DashboardState>,
|
||||
site_validation_state: SiteValidationState,
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────
|
||||
@@ -1739,6 +1786,7 @@ impl BdsApp {
|
||||
tags_view_state: None,
|
||||
settings_state: None,
|
||||
dashboard_state: None,
|
||||
site_validation_state: SiteValidationState::default(),
|
||||
},
|
||||
init_task,
|
||||
)
|
||||
@@ -1796,6 +1844,7 @@ impl BdsApp {
|
||||
tags_view_state: None,
|
||||
settings_state: None,
|
||||
dashboard_state: None,
|
||||
site_validation_state: SiteValidationState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2301,6 +2350,7 @@ impl BdsApp {
|
||||
self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
|
||||
Task::none()
|
||||
}
|
||||
Message::RunSiteValidation => self.start_site_validation(),
|
||||
Message::EngineTaskDone { task_id, label, result } => {
|
||||
match &result {
|
||||
Ok(detail) => {
|
||||
@@ -2316,6 +2366,36 @@ impl BdsApp {
|
||||
self.refresh_task_snapshots();
|
||||
sidebar_task
|
||||
}
|
||||
Message::SiteValidationLoaded(result) => {
|
||||
self.site_validation_state.is_running = false;
|
||||
self.site_validation_state.has_run = true;
|
||||
match result {
|
||||
Ok(report) => {
|
||||
self.site_validation_state.error_message = None;
|
||||
self.site_validation_state.missing_files = report.missing_pages;
|
||||
self.site_validation_state.extra_files = report.extra_pages;
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
self.site_validation_state.error_message = Some(error.clone());
|
||||
self.site_validation_state.missing_files.clear();
|
||||
self.site_validation_state.extra_files.clear();
|
||||
self.site_validation_state.stale_files.clear();
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Toast ──
|
||||
Message::ShowToast(level, msg) => {
|
||||
@@ -3232,6 +3312,7 @@ impl BdsApp {
|
||||
self.tags_view_state.as_ref(),
|
||||
self.settings_state.as_ref(),
|
||||
self.dashboard_state.as_ref(),
|
||||
&self.site_validation_state,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3330,20 +3411,7 @@ impl BdsApp {
|
||||
MenuAction::GenerateSitemap => Task::done(Message::GenerateSite),
|
||||
MenuAction::ValidateSite => {
|
||||
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
|
||||
self.spawn_engine_task(
|
||||
"tabBar.siteValidation",
|
||||
|db_path, project_id, data_dir, _tm, _tid| {
|
||||
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||
let report = engine::validate_site::validate_site(db.conn(), &data_dir, &project_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(format!(
|
||||
"missing={}, extra={}, stale={}",
|
||||
report.missing_pages.len(),
|
||||
report.extra_pages.len(),
|
||||
report.stale_pages.len(),
|
||||
))
|
||||
},
|
||||
)
|
||||
self.start_site_validation()
|
||||
}
|
||||
MenuAction::UploadSite => {
|
||||
if self.offline_mode {
|
||||
@@ -3603,6 +3671,38 @@ impl BdsApp {
|
||||
self.add_output(text);
|
||||
}
|
||||
|
||||
fn start_site_validation(&mut self) -> Task<Message> {
|
||||
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
|
||||
let Some(_db) = self.db.as_ref() else {
|
||||
self.site_validation_state.is_running = false;
|
||||
self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.generateSiteNoProject"));
|
||||
return Task::none();
|
||||
};
|
||||
let Some(project_id) = self.active_project.as_ref().map(|project| project.id.clone()) else {
|
||||
self.site_validation_state.is_running = false;
|
||||
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.is_running = false;
|
||||
self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.previewDataDirUnavailable"));
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
self.site_validation_state.is_running = true;
|
||||
self.site_validation_state.error_message = None;
|
||||
let db_path = self.db_path.clone();
|
||||
|
||||
Task::perform(
|
||||
async move {
|
||||
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
||||
engine::validate_site::validate_site(db.conn(), &data_dir, &project_id)
|
||||
.map_err(|error| error.to_string())
|
||||
},
|
||||
Message::SiteValidationLoaded,
|
||||
)
|
||||
}
|
||||
|
||||
/// Spawn a blocking engine operation on a background thread via TaskManager.
|
||||
///
|
||||
/// Returns `Task::none()` if no active project/db/data_dir.
|
||||
@@ -6167,12 +6267,18 @@ impl BdsApp {
|
||||
db: &Database,
|
||||
state: &mut SettingsViewState,
|
||||
) -> Result<(), String> {
|
||||
let online_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Online)?;
|
||||
let airplane_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Airplane)?;
|
||||
ai::test_endpoint(&online_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::test_endpoint(&airplane_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::save_endpoint(db.conn(), &online_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::save_endpoint(db.conn(), &airplane_endpoint).map_err(|error| error.to_string())?;
|
||||
if Self::endpoint_has_configuration(state, AiEndpointKind::Online) {
|
||||
let online_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Online)?;
|
||||
ai::test_endpoint(&online_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::save_endpoint(db.conn(), &online_endpoint).map_err(|error| error.to_string())?;
|
||||
state.online_api_key_input.clear();
|
||||
state.online_api_key_configured = true;
|
||||
}
|
||||
if Self::endpoint_has_configuration(state, AiEndpointKind::Airplane) {
|
||||
let airplane_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Airplane)?;
|
||||
ai::test_endpoint(&airplane_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::save_endpoint(db.conn(), &airplane_endpoint).map_err(|error| error.to_string())?;
|
||||
}
|
||||
ai::save_model_preferences(
|
||||
db.conn(),
|
||||
(!state.default_model.trim().is_empty()).then_some(state.default_model.as_str()),
|
||||
@@ -6181,11 +6287,27 @@ impl BdsApp {
|
||||
&state.system_prompt.text(),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
state.online_api_key_input.clear();
|
||||
state.online_api_key_configured = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn endpoint_has_configuration(
|
||||
state: &SettingsViewState,
|
||||
kind: AiEndpointKind,
|
||||
) -> bool {
|
||||
match kind {
|
||||
AiEndpointKind::Online => {
|
||||
!state.online_endpoint_url.trim().is_empty()
|
||||
|| !state.online_endpoint_model.trim().is_empty()
|
||||
|| !state.online_api_key_input.trim().is_empty()
|
||||
|| state.online_api_key_configured
|
||||
}
|
||||
AiEndpointKind::Airplane => {
|
||||
!state.airplane_endpoint_url.trim().is_empty()
|
||||
|| !state.airplane_endpoint_model.trim().is_empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compose_ai_endpoint(
|
||||
state: &SettingsViewState,
|
||||
kind: AiEndpointKind,
|
||||
|
||||
@@ -15,3 +15,4 @@ pub mod script_editor;
|
||||
pub mod tags_view;
|
||||
pub mod settings_view;
|
||||
pub mod dashboard;
|
||||
pub mod site_validation;
|
||||
|
||||
128
crates/bds-ui/src/views/site_validation.rs
Normal file
128
crates/bds-ui/src/views/site_validation.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{button, column, container, row, scrollable, text};
|
||||
use iced::{Background, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::i18n::t;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SiteValidationState {
|
||||
pub has_run: bool,
|
||||
pub is_running: bool,
|
||||
pub missing_files: Vec<String>,
|
||||
pub extra_files: Vec<String>,
|
||||
pub stale_files: Vec<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, Message> {
|
||||
let run_button = if state.is_running {
|
||||
button(text(t(locale, "siteValidation.running")).size(13).shaping(Shaping::Advanced))
|
||||
} else {
|
||||
button(text(t(locale, "siteValidation.run")).size(13).shaping(Shaping::Advanced))
|
||||
.on_press(Message::RunSiteValidation)
|
||||
};
|
||||
|
||||
let mut content = column![
|
||||
row![
|
||||
text(t(locale, "tabBar.siteValidation"))
|
||||
.size(24)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.88, 0.88, 0.92)),
|
||||
run_button,
|
||||
]
|
||||
.spacing(16),
|
||||
]
|
||||
.spacing(16);
|
||||
|
||||
if state.is_running {
|
||||
content = content.push(help_text(t(locale, "siteValidation.running")));
|
||||
} else if let Some(error) = &state.error_message {
|
||||
content = content.push(section(
|
||||
t(locale, "siteValidation.error"),
|
||||
std::slice::from_ref(error),
|
||||
Color::from_rgb(0.75, 0.28, 0.28),
|
||||
));
|
||||
} else if !state.has_run {
|
||||
content = content.push(help_text(t(locale, "siteValidation.idle")));
|
||||
} else if state.missing_files.is_empty() && state.extra_files.is_empty() && state.stale_files.is_empty() {
|
||||
content = content.push(help_text(t(locale, "siteValidation.clean")));
|
||||
} else {
|
||||
if !state.missing_files.is_empty() {
|
||||
content = content.push(section(
|
||||
format!("{} ({})", t(locale, "siteValidation.missing"), state.missing_files.len()),
|
||||
&state.missing_files,
|
||||
Color::from_rgb(0.70, 0.25, 0.25),
|
||||
));
|
||||
}
|
||||
if !state.extra_files.is_empty() {
|
||||
content = content.push(section(
|
||||
format!("{} ({})", t(locale, "siteValidation.extra"), state.extra_files.len()),
|
||||
&state.extra_files,
|
||||
Color::from_rgb(0.68, 0.48, 0.18),
|
||||
));
|
||||
}
|
||||
if !state.stale_files.is_empty() {
|
||||
content = content.push(section(
|
||||
format!("{} ({})", t(locale, "siteValidation.stale"), state.stale_files.len()),
|
||||
&state.stale_files,
|
||||
Color::from_rgb(0.62, 0.32, 0.18),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
container(scrollable(content))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.padding(24)
|
||||
.style(|_theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.11, 0.11, 0.14))),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn help_text<'a>(value: String) -> Element<'a, Message> {
|
||||
container(
|
||||
text(value)
|
||||
.size(14)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.66, 0.66, 0.72)),
|
||||
)
|
||||
.padding(16)
|
||||
.style(|_theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.18))),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn section<'a>(title: String, entries: &'a [String], accent: Color) -> Element<'a, Message> {
|
||||
let items = entries.iter().fold(column!().spacing(6), |column, entry| {
|
||||
column.push(
|
||||
text(entry)
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.82, 0.82, 0.88)),
|
||||
)
|
||||
});
|
||||
|
||||
container(
|
||||
column![
|
||||
text(title.to_string())
|
||||
.size(16)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(accent),
|
||||
items,
|
||||
]
|
||||
.spacing(10),
|
||||
)
|
||||
.padding(16)
|
||||
.style(|_theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.18))),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use crate::views::{
|
||||
modal, panel,
|
||||
post_editor::{self, PostEditorState},
|
||||
project_selector,
|
||||
site_validation::{self, SiteValidationState},
|
||||
script_editor::{self, ScriptEditorState},
|
||||
settings_view::{self, SettingsViewState},
|
||||
sidebar, status_bar, tab_bar,
|
||||
@@ -110,6 +111,7 @@ pub fn view<'a>(
|
||||
tags_view_state: Option<&'a TagsViewState>,
|
||||
settings_state: Option<&'a SettingsViewState>,
|
||||
dashboard_state: Option<&'a DashboardState>,
|
||||
site_validation_state: &'a SiteValidationState,
|
||||
) -> Element<'a, Message> {
|
||||
// Activity bar (leftmost column)
|
||||
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
|
||||
@@ -131,6 +133,7 @@ pub fn view<'a>(
|
||||
tags_view_state,
|
||||
settings_state,
|
||||
dashboard_state,
|
||||
site_validation_state,
|
||||
);
|
||||
|
||||
// Right column: tab bar + content + panel
|
||||
@@ -353,6 +356,7 @@ fn route_content_area<'a>(
|
||||
tags_view_state: Option<&'a TagsViewState>,
|
||||
settings_state: Option<&'a SettingsViewState>,
|
||||
dashboard_state: Option<&'a DashboardState>,
|
||||
site_validation_state: &'a SiteValidationState,
|
||||
) -> Element<'a, Message> {
|
||||
match route_kind(
|
||||
tabs,
|
||||
@@ -364,6 +368,7 @@ fn route_content_area<'a>(
|
||||
tags_view_state,
|
||||
settings_state,
|
||||
dashboard_state,
|
||||
site_validation_state,
|
||||
) {
|
||||
ContentRoute::Dashboard(state) => crate::views::dashboard::view(state, locale),
|
||||
ContentRoute::Welcome => welcome::view(locale),
|
||||
@@ -411,6 +416,7 @@ fn route_content_area<'a>(
|
||||
loading_view(locale)
|
||||
}
|
||||
}
|
||||
ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale),
|
||||
ContentRoute::Placeholder(title) => welcome::tab_placeholder(title, None),
|
||||
}
|
||||
}
|
||||
@@ -426,6 +432,7 @@ enum ContentRoute<'a> {
|
||||
Scripts(&'a str),
|
||||
Tags,
|
||||
Settings,
|
||||
SiteValidation,
|
||||
Placeholder(&'a str),
|
||||
}
|
||||
|
||||
@@ -439,6 +446,7 @@ fn route_kind<'a>(
|
||||
tags_view_state: Option<&'a TagsViewState>,
|
||||
settings_state: Option<&'a SettingsViewState>,
|
||||
dashboard_state: Option<&'a DashboardState>,
|
||||
_site_validation_state: &'a SiteValidationState,
|
||||
) -> ContentRoute<'a> {
|
||||
let Some(tab_id) = active_tab else {
|
||||
return dashboard_state.map(ContentRoute::Dashboard).unwrap_or(ContentRoute::Welcome);
|
||||
@@ -466,6 +474,7 @@ fn route_kind<'a>(
|
||||
TabType::Settings => {
|
||||
if settings_state.is_some() { ContentRoute::Settings } else { ContentRoute::Loading }
|
||||
}
|
||||
TabType::SiteValidation => ContentRoute::SiteValidation,
|
||||
TabType::Style
|
||||
| TabType::Chat
|
||||
| TabType::Import
|
||||
@@ -474,7 +483,6 @@ fn route_kind<'a>(
|
||||
| TabType::GitDiff
|
||||
| TabType::Documentation
|
||||
| TabType::ApiDocumentation
|
||||
| TabType::SiteValidation
|
||||
| TabType::TranslationValidation
|
||||
| TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title),
|
||||
}
|
||||
@@ -501,6 +509,7 @@ mod tests {
|
||||
let empty_media = HashMap::new();
|
||||
let empty_templates = HashMap::new();
|
||||
let empty_scripts = HashMap::new();
|
||||
let site_validation_state = SiteValidationState::default();
|
||||
let unsupported = [
|
||||
TabType::Style,
|
||||
TabType::Chat,
|
||||
@@ -510,7 +519,6 @@ mod tests {
|
||||
TabType::GitDiff,
|
||||
TabType::Documentation,
|
||||
TabType::ApiDocumentation,
|
||||
TabType::SiteValidation,
|
||||
TabType::TranslationValidation,
|
||||
TabType::FindDuplicates,
|
||||
];
|
||||
@@ -527,6 +535,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&site_validation_state,
|
||||
);
|
||||
match route {
|
||||
ContentRoute::Placeholder(title) => assert_eq!(title, "Tool"),
|
||||
@@ -542,6 +551,7 @@ mod tests {
|
||||
let empty_media = HashMap::new();
|
||||
let empty_templates = HashMap::new();
|
||||
let empty_scripts = HashMap::new();
|
||||
let site_validation_state = SiteValidationState::default();
|
||||
let route = route_kind(
|
||||
&[],
|
||||
None,
|
||||
@@ -552,12 +562,41 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
Some(&dashboard),
|
||||
&site_validation_state,
|
||||
);
|
||||
match route {
|
||||
ContentRoute::Dashboard(state) => assert_eq!(state.subtitle, "Test Project"),
|
||||
_ => panic!("expected dashboard route"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_validation_tab_routes_to_real_view() {
|
||||
let empty_posts = HashMap::new();
|
||||
let empty_media = HashMap::new();
|
||||
let empty_templates = HashMap::new();
|
||||
let empty_scripts = HashMap::new();
|
||||
let site_validation_state = SiteValidationState::default();
|
||||
let tabs = vec![tab("site_validation", TabType::SiteValidation, "Validation")];
|
||||
|
||||
let route = route_kind(
|
||||
&tabs,
|
||||
Some("site_validation"),
|
||||
&empty_posts,
|
||||
&empty_media,
|
||||
&empty_templates,
|
||||
&empty_scripts,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&site_validation_state,
|
||||
);
|
||||
|
||||
match route {
|
||||
ContentRoute::SiteValidation => {}
|
||||
_ => panic!("expected site validation route"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> {
|
||||
|
||||
@@ -93,6 +93,14 @@
|
||||
"tabBar.metadataDiff": "Metadaten-Diff",
|
||||
"tabBar.siteValidation": "Website-Validierung",
|
||||
"tabBar.translationValidation": "Übersetzungsvalidierung",
|
||||
"siteValidation.run": "Validierung starten",
|
||||
"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.",
|
||||
"siteValidation.error": "Validierung fehlgeschlagen",
|
||||
"siteValidation.missing": "Fehlende Dateien",
|
||||
"siteValidation.extra": "Zusätzliche Dateien",
|
||||
"siteValidation.stale": "Veraltete Dateien",
|
||||
"tabBar.documentation": "Dokumentation",
|
||||
"tabBar.apiDocumentation": "API-Dokumentation",
|
||||
"tabBar.findDuplicates": "Duplikate finden",
|
||||
|
||||
@@ -93,6 +93,14 @@
|
||||
"tabBar.metadataDiff": "Metadata Diff",
|
||||
"tabBar.siteValidation": "Site Validation",
|
||||
"tabBar.translationValidation": "Translation Validation",
|
||||
"siteValidation.run": "Run Validation",
|
||||
"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.",
|
||||
"siteValidation.error": "Validation failed",
|
||||
"siteValidation.missing": "Missing Files",
|
||||
"siteValidation.extra": "Extra Files",
|
||||
"siteValidation.stale": "Stale Files",
|
||||
"tabBar.documentation": "Documentation",
|
||||
"tabBar.apiDocumentation": "API Documentation",
|
||||
"tabBar.findDuplicates": "Find Duplicates",
|
||||
|
||||
@@ -93,6 +93,14 @@
|
||||
"tabBar.metadataDiff": "Diff de metadatos",
|
||||
"tabBar.siteValidation": "Validación del sitio",
|
||||
"tabBar.translationValidation": "Validación de traducciones",
|
||||
"siteValidation.run": "Ejecutar validación",
|
||||
"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.",
|
||||
"siteValidation.error": "La validación falló",
|
||||
"siteValidation.missing": "Archivos faltantes",
|
||||
"siteValidation.extra": "Archivos adicionales",
|
||||
"siteValidation.stale": "Archivos desactualizados",
|
||||
"tabBar.documentation": "Documentación",
|
||||
"tabBar.apiDocumentation": "Documentación API",
|
||||
"tabBar.findDuplicates": "Buscar duplicados",
|
||||
|
||||
@@ -93,6 +93,14 @@
|
||||
"tabBar.metadataDiff": "Diff métadonnées",
|
||||
"tabBar.siteValidation": "Validation du site",
|
||||
"tabBar.translationValidation": "Validation des traductions",
|
||||
"siteValidation.run": "Lancer la validation",
|
||||
"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é.",
|
||||
"siteValidation.error": "La validation a échoué",
|
||||
"siteValidation.missing": "Fichiers manquants",
|
||||
"siteValidation.extra": "Fichiers supplémentaires",
|
||||
"siteValidation.stale": "Fichiers obsolètes",
|
||||
"tabBar.documentation": "Documentation",
|
||||
"tabBar.apiDocumentation": "Documentation API",
|
||||
"tabBar.findDuplicates": "Trouver les doublons",
|
||||
|
||||
@@ -93,6 +93,14 @@
|
||||
"tabBar.metadataDiff": "Diff metadati",
|
||||
"tabBar.siteValidation": "Validazione sito",
|
||||
"tabBar.translationValidation": "Validazione traduzioni",
|
||||
"siteValidation.run": "Esegui validazione",
|
||||
"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.",
|
||||
"siteValidation.error": "Validazione non riuscita",
|
||||
"siteValidation.missing": "File mancanti",
|
||||
"siteValidation.extra": "File aggiuntivi",
|
||||
"siteValidation.stale": "File obsoleti",
|
||||
"tabBar.documentation": "Documentazione",
|
||||
"tabBar.apiDocumentation": "Documentazione API",
|
||||
"tabBar.findDuplicates": "Trova duplicati",
|
||||
|
||||
Reference in New Issue
Block a user