diff --git a/Cargo.lock b/Cargo.lock index fa769e3..69fe678 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -807,6 +807,7 @@ dependencies = [ "libsqlite3-sys", "liquid", "liquid-core", + "mlua", "pagefind", "pulldown-cmark", "quick-xml 0.41.0", @@ -821,6 +822,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "url", "uuid", "walkdir", ] @@ -852,6 +854,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "open", + "rayon", "rfd", "serde_json", "syn 2.0.117", @@ -1016,6 +1019,16 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "built" version = "0.8.0" @@ -2154,6 +2167,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -4278,6 +4302,25 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lua-src" +version = "550.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c110c2fa33f34e0de05448e1f3eb2e0631e7a69e2d8ae1586cffc9fc9f9949" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.7.2+b925b3e" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920cf654b23d217c550ceea57c32cd2a413ea27b6d47ed77b5ee0cf655adefa6" +dependencies = [ + "cc", + "which", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -4447,6 +4490,38 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mlua" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad72ffa037cf5970c9860674f32f703fda25d86cf217475fe7a79c5f9961bcaa" +dependencies = [ + "bstr", + "either", + "erased-serde", + "libc", + "mlua-sys", + "num-traits", + "parking_lot 0.12.5", + "rustc-hash 2.1.2", + "serde", + "serde-value", +] + +[[package]] +name = "mlua-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92136787b906d4e55cfe96cd6c62e010bb1a56889d0d6cf83eb016dbad07576b" +dependencies = [ + "cc", + "cfg-if", + "libc", + "lua-src", + "luajit-src", + "pkg-config", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -5130,6 +5205,15 @@ dependencies = [ "libredox", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.4.3" @@ -6556,6 +6640,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -7703,6 +7797,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.19.0" @@ -8491,6 +8591,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "8.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +dependencies = [ + "libc", +] + [[package]] name = "widestring" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index e1d7961..4748460 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,8 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "sync-s diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] } diesel_migrations = "2.3.2" libsqlite3-sys = { version = "0.37.0", features = ["bundled"] } +mlua = { version = "0.12", features = ["lua54", "vendored", "serialize"] } +url = "2.5" # UI framework iced = { version = "0.13", features = ["wgpu", "advanced", "image", "svg", "tokio"] } diff --git a/crates/bds-core/Cargo.toml b/crates/bds-core/Cargo.toml index 36699bb..0641619 100644 --- a/crates/bds-core/Cargo.toml +++ b/crates/bds-core/Cargo.toml @@ -31,6 +31,8 @@ axum = { workspace = true } diesel = { workspace = true } diesel_migrations = { workspace = true } libsqlite3-sys = { workspace = true } +mlua = { workspace = true } +url = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/bds-core/src/engine/auto_translation.rs b/crates/bds-core/src/engine/auto_translation.rs new file mode 100644 index 0000000..9bf95e9 --- /dev/null +++ b/crates/bds-core/src/engine/auto_translation.rs @@ -0,0 +1,441 @@ +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +use serde_json::json; + +use crate::db::DbConnection as Connection; +use crate::db::queries::{ + media as qm, media_translation as qmt, post as qp, post_media, post_translation, +}; +use crate::engine::ai::{ + self, MediaTranslationResult, OneShotOperation, OneShotRequest, OneShotResponse, + TranslationResult, +}; +use crate::engine::{EngineError, EngineResult}; +use crate::model::{Media, Post, PostStatus}; +use crate::util::frontmatter::read_post_file; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FillMissingTranslationsReport { + pub translated_posts: usize, + pub translated_media: usize, + pub failed_count: usize, + pub warned_count: usize, + pub nothing_to_do: bool, + pub errors: Vec, +} + +pub fn configured_languages(main_language: &str, blog_languages: &[String]) -> Vec { + let mut seen = HashSet::new(); + std::iter::once(main_language.to_string()) + .chain(blog_languages.iter().cloned()) + .map(|language| normalize_language(&language)) + .filter(|language| !language.is_empty() && seen.insert(language.clone())) + .collect() +} + +pub fn missing_languages( + conn: &Connection, + post: &Post, + configured: &[String], +) -> EngineResult> { + if post.do_not_translate { + return Ok(Vec::new()); + } + let source = normalize_language(post.language.as_deref().unwrap_or("en")); + let existing = post_translation::list_post_translations_by_post(conn, &post.id)? + .into_iter() + .map(|translation| normalize_language(&translation.language)) + .collect::>(); + Ok(configured + .iter() + .filter(|language| **language != source && !existing.contains(*language)) + .cloned() + .collect()) +} + +/// Batch maintenance path. Generated post translations are published, while +/// per-item failures are accumulated and never abort the batch. +pub fn fill_missing_translations( + conn: &Connection, + data_dir: &Path, + project_id: &str, + main_language: &str, + blog_languages: &[String], + offline_mode: bool, + mut on_progress: impl FnMut(f32, &str), +) -> EngineResult { + fill_missing_translations_with( + conn, + data_dir, + project_id, + main_language, + blog_languages, + &mut |post, language| translate_post_ai(conn, offline_mode, post, language), + &mut |media, language| translate_media_ai(conn, offline_mode, media, language), + &mut on_progress, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "testable translation orchestration dependencies" +)] +fn fill_missing_translations_with( + conn: &Connection, + data_dir: &Path, + project_id: &str, + main_language: &str, + blog_languages: &[String], + post_translator: &mut dyn FnMut(&Post, &str) -> EngineResult, + media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult, + on_progress: &mut dyn FnMut(f32, &str), +) -> EngineResult { + let configured = configured_languages(main_language, blog_languages); + if configured.len() <= 1 { + return Ok(FillMissingTranslationsReport { + nothing_to_do: true, + ..Default::default() + }); + } + let posts = qp::list_posts_by_project(conn, project_id)?; + on_progress(0.0, "Scanning published posts"); + let mut work = Vec::new(); + for post in posts + .into_iter() + .filter(|post| post.status == PostStatus::Published && !post.do_not_translate) + { + for language in missing_languages(conn, &post, &configured)? { + work.push((post.clone(), language)); + } + } + if work.is_empty() { + return Ok(FillMissingTranslationsReport { + nothing_to_do: true, + ..Default::default() + }); + } + + let mut report = FillMissingTranslationsReport::default(); + for (index, (post, language)) in work.iter().enumerate() { + on_progress( + 0.15 + (index as f32 / work.len() as f32) * 0.85, + &format!("{} → {language}", post.title), + ); + match translate_one_post( + conn, + data_dir, + post, + language, + true, + post_translator, + media_translator, + ) { + Ok(media_count) => { + report.translated_posts += 1; + report.translated_media += media_count; + } + Err(error) => { + report.failed_count += 1; + report + .errors + .push(format!("{} ({language}): {error}", post.title)); + } + } + } + on_progress(1.0, "Translation batch complete"); + Ok(report) +} + +/// Reactive manual-save path. Generated translations remain drafts. +pub fn translate_missing_for_post( + conn: &Connection, + data_dir: &Path, + post_id: &str, + main_language: &str, + blog_languages: &[String], + offline_mode: bool, +) -> EngineResult { + let post = qp::get_post_by_id(conn, post_id)?; + let configured = configured_languages(main_language, blog_languages); + let targets = missing_languages(conn, &post, &configured)?; + let mut report = FillMissingTranslationsReport { + nothing_to_do: targets.is_empty(), + ..Default::default() + }; + for language in targets { + let result = translate_one_post( + conn, + data_dir, + &post, + &language, + false, + &mut |post, language| translate_post_ai(conn, offline_mode, post, language), + &mut |media, language| translate_media_ai(conn, offline_mode, media, language), + ); + match result { + Ok(media_count) => { + report.translated_posts += 1; + report.translated_media += media_count; + } + Err(error) => { + report.failed_count += 1; + report + .errors + .push(format!("{} ({language}): {error}", post.title)); + } + } + } + Ok(report) +} + +fn translate_one_post( + conn: &Connection, + data_dir: &Path, + post: &Post, + language: &str, + auto_publish: bool, + post_translator: &mut dyn FnMut(&Post, &str) -> EngineResult, + media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult, +) -> EngineResult { + if post.do_not_translate { + return Ok(0); + } + let body = post_body(data_dir, post)?; + if body.trim().is_empty() { + return Err(EngineError::Validation("no content to translate".into())); + } + let mut input = post.clone(); + input.content = Some(body); + let translated = post_translator(&input, language)?; + let translation = crate::engine::post::upsert_translation( + conn, + data_dir, + &post.id, + language, + &translated.title, + Some(&translated.excerpt), + Some(&translated.content), + )?; + if auto_publish { + crate::engine::post::publish_post_translation(conn, data_dir, &translation.id)?; + } + + let mut translated_media = 0; + for link in post_media::list_post_media_by_post(conn, &post.id)? { + let media = qm::get_media_by_id(conn, &link.media_id)?; + let source = normalize_language(media.language.as_deref().unwrap_or("")); + if source.is_empty() || source == language { + continue; + } + if qmt::get_media_translation_by_media_and_language(conn, &media.id, language).is_ok() { + continue; + } + let translated = media_translator(&media, language)?; + crate::engine::media::upsert_media_translation( + conn, + data_dir, + &media.id, + language, + Some(&translated.title), + Some(&translated.alt), + Some(&translated.caption), + )?; + translated_media += 1; + } + Ok(translated_media) +} + +fn post_body(data_dir: &Path, post: &Post) -> EngineResult { + if let Some(content) = &post.content { + return Ok(content.clone()); + } + if post.file_path.is_empty() { + return Ok(String::new()); + } + let raw = fs::read_to_string(data_dir.join(&post.file_path))?; + read_post_file(&raw) + .map(|(_, body)| body) + .map_err(EngineError::Parse) +} + +fn translate_post_ai( + conn: &Connection, + offline_mode: bool, + post: &Post, + language: &str, +) -> EngineResult { + match ai::run_one_shot( + conn, + offline_mode, + &OneShotRequest { + operation: OneShotOperation::TranslatePost { + target_language: language.to_string(), + }, + content: json!({ + "title": post.title, + "excerpt": post.excerpt, + "content": post.content, + }), + }, + )? { + OneShotResponse::Translation(result) => Ok(result), + _ => Err(EngineError::Parse( + "unexpected post translation response".into(), + )), + } +} + +fn translate_media_ai( + conn: &Connection, + offline_mode: bool, + media: &Media, + language: &str, +) -> EngineResult { + match ai::run_one_shot( + conn, + offline_mode, + &OneShotRequest { + operation: OneShotOperation::TranslateMedia { + target_language: language.to_string(), + }, + content: json!({ + "title": media.title, + "alt": media.alt, + "caption": media.caption, + }), + }, + )? { + OneShotResponse::MediaTranslation(result) => Ok(result), + _ => Err(EngineError::Parse( + "unexpected media translation response".into(), + )), + } +} + +fn normalize_language(language: &str) -> String { + language + .split(['-', '_']) + .next() + .unwrap_or("") + .trim() + .to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::Database; + use crate::db::fts::ensure_fts_tables; + use crate::db::queries::project::{insert_project, make_test_project}; + use crate::engine::post::{create_post, publish_post}; + use tempfile::TempDir; + + #[test] + fn batch_translates_only_missing_languages_and_publishes() { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + ensure_fts_tables(db.conn()).unwrap(); + insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap(); + let dir = TempDir::new().unwrap(); + let post = create_post( + db.conn(), + dir.path(), + "p1", + "Hello", + Some("Body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + publish_post(db.conn(), dir.path(), &post.id).unwrap(); + let mut requested = Vec::new(); + let report = fill_missing_translations_with( + db.conn(), + dir.path(), + "p1", + "en", + &["de".into(), "fr".into(), "de-DE".into()], + &mut |_post, language| { + requested.push(language.to_string()); + Ok(TranslationResult { + title: format!("Title {language}"), + excerpt: format!("Excerpt {language}"), + content: format!("Body {language}"), + }) + }, + &mut |_media, _language| unreachable!(), + &mut |_, _| {}, + ) + .unwrap(); + + assert_eq!(requested, vec!["de", "fr"]); + assert_eq!(report.translated_posts, 2); + for language in ["de", "fr"] { + let translation = post_translation::get_post_translation_by_post_and_language( + db.conn(), + &post.id, + language, + ) + .unwrap(); + assert_eq!(translation.status, PostStatus::Published); + assert!(dir.path().join(&translation.file_path).is_file()); + assert!(translation.content.is_none()); + } + } + + #[test] + fn skips_do_not_translate_posts() { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + ensure_fts_tables(db.conn()).unwrap(); + insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap(); + let dir = TempDir::new().unwrap(); + let post = create_post( + db.conn(), + dir.path(), + "p1", + "Private", + Some("Body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + let post = crate::engine::post::update_post( + db.conn(), + dir.path(), + &post.id, + None, + None, + None, + None, + None, + None, + None, + None, + None, + Some(true), + ) + .unwrap(); + publish_post(db.conn(), dir.path(), &post.id).unwrap(); + let report = fill_missing_translations_with( + db.conn(), + dir.path(), + "p1", + "en", + &["de".into()], + &mut |_, _| panic!("translator must not run"), + &mut |_, _| panic!("translator must not run"), + &mut |_, _| {}, + ) + .unwrap(); + assert!(report.nothing_to_do); + } +} diff --git a/crates/bds-core/src/engine/blogmark.rs b/crates/bds-core/src/engine/blogmark.rs new file mode 100644 index 0000000..83d046c --- /dev/null +++ b/crates/bds-core/src/engine/blogmark.rs @@ -0,0 +1,343 @@ +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use url::Url; + +use crate::db::DbConnection as Connection; +use crate::db::queries::script as script_queries; +use crate::engine::{EngineError, EngineResult}; +use crate::model::{Post, Script, ScriptKind}; +use crate::scripting::{self, ExecutionControl, ExecutionKind}; + +const MAX_TITLE_LENGTH: usize = 200; +const MAX_URL_LENGTH: usize = 2_048; +const MAX_TOASTS_TOTAL: usize = 20; +const MAX_TOAST_LENGTH: usize = 300; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BlogmarkCandidate { + pub title: String, + pub url: Option, + pub content: Option, + pub tags: Vec, + pub categories: Vec, + pub project_id: Option, +} + +#[derive(Debug, Clone)] +pub struct BlogmarkImportResult { + pub post: Post, + pub toasts: Vec, + pub transform_errors: Vec, +} + +pub fn parse_deep_link(raw: &str) -> EngineResult { + let parsed = + Url::parse(raw).map_err(|_| EngineError::Validation("invalid blogmark URL".into()))?; + if parsed.scheme() != "bds2" { + return Err(EngineError::Validation( + "unsupported blogmark scheme".into(), + )); + } + if parsed.host_str() != Some("new-post") { + return Err(EngineError::Validation( + "unsupported blogmark action".into(), + )); + } + let params = parsed + .query_pairs() + .into_owned() + .collect::>(); + let url = params.get("url").and_then(|value| sanitize_http_url(value)); + let fallback = url + .as_ref() + .and_then(|value| Url::parse(value).ok()) + .and_then(|value| value.host_str().map(str::to_string)); + let title = sanitize_title(params.get("title").map(String::as_str), fallback.as_deref()); + Ok(BlogmarkCandidate { + title, + url, + content: nonempty(params.get("content")), + tags: list_param(params.get("tags")), + categories: list_param(params.get("categories")), + project_id: nonempty(params.get("project_id")), + }) +} + +pub fn receive_deep_link( + conn: &Connection, + data_dir: &Path, + project_id: &str, + raw: &str, +) -> EngineResult { + let mut candidate = parse_deep_link(raw)?; + if let Some(target) = &candidate.project_id + && target != project_id + { + return Err(EngineError::Validation( + "blogmark targets a different project".into(), + )); + } + if candidate.content.is_none() + && let Some(url) = &candidate.url + { + candidate.content = Some(format!( + "[{}]({url})", + escape_markdown_link_text(&candidate.title) + )); + } + let (mut candidate, toasts, transform_errors) = + run_transforms(conn, data_dir, project_id, candidate)?; + if candidate.categories.is_empty() { + let metadata = crate::engine::meta::read_project_json(data_dir)?; + if let Some(category) = metadata + .blogmark_category + .filter(|value| !value.trim().is_empty()) + { + candidate.categories.push(category); + } + } + let metadata = crate::engine::meta::read_project_json(data_dir)?; + let post = crate::engine::post::create_post( + conn, + data_dir, + project_id, + &candidate.title, + candidate.content.as_deref(), + candidate.tags, + candidate.categories, + metadata.default_author.as_deref(), + metadata.main_language.as_deref(), + None, + )?; + Ok(BlogmarkImportResult { + post, + toasts, + transform_errors, + }) +} + +fn run_transforms( + conn: &Connection, + data_dir: &Path, + project_id: &str, + candidate: BlogmarkCandidate, +) -> EngineResult<(BlogmarkCandidate, Vec, Vec)> { + let mut transforms = script_queries::list_scripts_by_project(conn, project_id)? + .into_iter() + .filter(|script| script.kind == ScriptKind::Transform && script.enabled) + .collect::>(); + transforms.sort_by(|left, right| { + left.updated_at + .cmp(&right.updated_at) + .then_with(|| left.slug.cmp(&right.slug)) + .then_with(|| left.id.cmp(&right.id)) + }); + let mut current = + serde_json::to_value(candidate).map_err(|error| EngineError::Parse(error.to_string()))?; + let mut toasts = Vec::new(); + let mut errors = Vec::new(); + for script in transforms { + if script.entrypoint.trim().is_empty() { + continue; + } + let source = resolved_script_content(data_dir, &script)?; + let context = json!({ + "source": "blogmark", + "url": current.get("url").cloned().unwrap_or(Value::Null), + }); + match scripting::execute_many( + &source, + &script.entrypoint, + &[current.clone(), context], + ExecutionKind::Transform, + &ExecutionControl::default(), + ) { + Ok(execution) => { + let (next, returned_toasts) = split_transform_result(execution.value, ¤t); + current = next; + accept_toasts( + &mut toasts, + execution.toasts.into_iter().chain(returned_toasts), + ); + } + Err(error) => errors.push(format!("{}: {error}", script.slug)), + } + } + let candidate = serde_json::from_value(current).map_err(|error| { + EngineError::Validation(format!("transform returned invalid post data: {error}")) + })?; + Ok((candidate, toasts, errors)) +} + +fn split_transform_result(value: Value, previous: &Value) -> (Value, Vec) { + if let Some(object) = value.as_object() { + if let Some(data) = object.get("data").filter(|value| value.is_object()) { + let toasts = object + .get("toasts") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(); + return (data.clone(), toasts); + } + return (value, Vec::new()); + } + (previous.clone(), Vec::new()) +} + +fn accept_toasts(target: &mut Vec, source: impl IntoIterator) { + for message in source.into_iter().take(5) { + if target.len() >= MAX_TOASTS_TOTAL { + break; + } + target.push(message.chars().take(MAX_TOAST_LENGTH).collect()); + } +} + +fn resolved_script_content(data_dir: &Path, script: &Script) -> EngineResult { + if let Some(content) = &script.content { + return Ok(content.clone()); + } + let raw = fs::read_to_string(data_dir.join(&script.file_path))?; + crate::util::frontmatter::read_script_file(&raw) + .map(|(_, body)| body) + .map_err(EngineError::Parse) +} + +fn sanitize_title(value: Option<&str>, fallback: Option<&str>) -> String { + let cleaned = value + .unwrap_or_default() + .chars() + .filter(|ch| !ch.is_control()) + .collect::(); + let cleaned = cleaned.trim(); + let selected = if cleaned.is_empty() { + fallback.unwrap_or_default() + } else { + cleaned + }; + selected.chars().take(MAX_TITLE_LENGTH).collect() +} + +fn sanitize_http_url(value: &str) -> Option { + let cleaned = value + .chars() + .filter(|ch| !ch.is_control()) + .collect::(); + let mut parsed = Url::parse(cleaned.trim()).ok()?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { + return None; + } + parsed.set_fragment(None); + parsed.set_username("").ok()?; + parsed.set_password(None).ok()?; + let normalized = parsed.to_string(); + (normalized.chars().count() <= MAX_URL_LENGTH).then_some(normalized) +} + +fn nonempty(value: Option<&String>) -> Option { + value + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn list_param(value: Option<&String>) -> Vec { + value + .map(|value| { + value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn escape_markdown_link_text(value: &str) -> String { + value + .trim() + .replace('\\', "\\\\") + .replace('[', "\\[") + .replace(']', "\\]") + .replace('(', "\\(") + .replace(')', "\\)") + .replace(['\r', '\n'], " ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::Database; + use crate::model::ScriptKind; + use tempfile::TempDir; + + #[test] + fn parses_and_hardens_blogmark_links() { + let candidate = parse_deep_link( + "bds2://new-post?title=%00Hello&url=https%3A%2F%2Fuser%3Apass%40example.com%2Fa%23frag&tags=one%2Ctwo", + ) + .unwrap(); + assert_eq!(candidate.title, "Hello"); + assert_eq!(candidate.url.as_deref(), Some("https://example.com/a")); + assert_eq!(candidate.tags, vec!["one", "two"]); + } + + #[test] + fn rejects_other_schemes_and_actions() { + assert!(parse_deep_link("bds://new-post?title=x").is_err()); + assert!(parse_deep_link("bds2://other?title=x").is_err()); + } + + #[test] + fn invalid_source_url_never_reaches_candidate() { + let candidate = + parse_deep_link("bds2://new-post?title=x&url=javascript%3Aalert%281%29").unwrap(); + assert!(candidate.url.is_none()); + } + + #[test] + fn imports_draft_after_ordered_transform_pipeline() { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + crate::db::fts::ensure_fts_tables(db.conn()).unwrap(); + let directory = TempDir::new().unwrap(); + let project = crate::engine::project::create_project( + db.conn(), + "Blog", + Some(directory.path().to_str().unwrap()), + ) + .unwrap(); + crate::engine::script::create_script( + db.conn(), + &project.id, + "Add tag", + ScriptKind::Transform, + "function main(data, context) data.tags = {context.source}; bds.app.toast('done'); return data end", + None, + ) + .unwrap(); + + let result = receive_deep_link( + db.conn(), + directory.path(), + &project.id, + "bds2://new-post?title=Example&url=https%3A%2F%2Fexample.com", + ) + .unwrap(); + + assert_eq!(result.post.tags, vec!["blogmark"]); + assert_eq!( + result.post.content.as_deref(), + Some("[Example](https://example.com/)") + ); + assert_eq!(result.toasts, vec!["done"]); + assert!(result.transform_errors.is_empty()); + } +} diff --git a/crates/bds-core/src/engine/generation.rs b/crates/bds-core/src/engine/generation.rs index d8f391d..b1fc77d 100644 --- a/crates/bds-core/src/engine/generation.rs +++ b/crates/bds-core/src/engine/generation.rs @@ -72,6 +72,26 @@ pub fn generate_starter_site( metadata: &ProjectMetadata, posts: &[PublishedPostSource], _language: &str, +) -> EngineResult { + generate_starter_site_with_progress( + conn, + output_dir, + project_id, + metadata, + posts, + _language, + |_current, _total, _path| {}, + ) +} + +pub fn generate_starter_site_with_progress( + conn: &Connection, + output_dir: &Path, + project_id: &str, + metadata: &ProjectMetadata, + posts: &[PublishedPostSource], + _language: &str, + mut on_page: impl FnMut(usize, usize, &str), ) -> EngineResult { let mut report = GenerationReport::default(); let data_dir = project_data_dir(output_dir); @@ -85,7 +105,8 @@ pub fn generate_starter_site( build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts) .map_err(|error| EngineError::Parse(error.to_string()))?; - for page in &artifacts.pages { + let total_pages = artifacts.pages.len(); + for (index, page) in artifacts.pages.iter().enumerate() { write_out( conn, output_dir, @@ -94,6 +115,7 @@ pub fn generate_starter_site( &page.html, &mut report, )?; + on_page(index + 1, total_pages, &page.url_path); } write_bundled_site_assets(conn, output_dir, project_id, &mut report)?; diff --git a/crates/bds-core/src/engine/media.rs b/crates/bds-core/src/engine/media.rs index b07e6f3..1190c3c 100644 --- a/crates/bds-core/src/engine/media.rs +++ b/crates/bds-core/src/engine/media.rs @@ -198,6 +198,128 @@ pub fn update_media( Ok(media) } +/// Replace a media item's binary while preserving its identity, public path, +/// metadata, translations, and post links. Returns `None` when the new file is +/// byte-for-byte identical to the stored binary. +pub fn replace_media_file( + conn: &Connection, + data_dir: &Path, + media_id: &str, + new_source_path: &Path, +) -> EngineResult> { + let mut media = qm::get_media_by_id(conn, media_id) + .map_err(|_| EngineError::NotFound(format!("media {media_id}")))?; + if !new_source_path.is_file() { + return Err(EngineError::Validation(format!( + "replacement file does not exist: {}", + new_source_path.display() + ))); + } + + let replacement_checksum = crate::util::file_hash(new_source_path)?; + if media.checksum.as_deref() == Some(replacement_checksum.as_str()) { + return Ok(None); + } + + let destination = data_dir.join(&media.file_path); + if !destination.is_file() { + return Err(EngineError::NotFound(format!( + "stored media file {}", + destination.display() + ))); + } + + let (width, height) = image_dimensions(new_source_path) + .map(|(w, h)| (Some(w as i32), Some(h as i32))) + .map_err(|error| { + EngineError::Validation(format!( + "replacement is not a readable image '{}': {error}", + new_source_path.display(), + )) + })?; + let replacement_size = fs::metadata(new_source_path)?.len() as i64; + + // Generate all derived files before touching the canonical binary. + let staged_thumbnails = data_dir + .join("thumbnails") + .join(format!(".replace-{media_id}")); + if staged_thumbnails.exists() { + fs::remove_dir_all(&staged_thumbnails)?; + } + let staged_paths = generate_all_thumbnails(new_source_path, &staged_thumbnails, media_id) + .map_err(EngineError::Parse)?; + + let backup = destination.with_extension(format!( + "{}.bak", + destination + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or("media") + )); + if backup.exists() { + fs::remove_file(&backup)?; + } + fs::rename(&destination, &backup)?; + if let Err(error) = fs::copy(new_source_path, &destination) { + let _ = fs::rename(&backup, &destination); + let _ = fs::remove_dir_all(&staged_thumbnails); + return Err(error.into()); + } + + let previous_media = media.clone(); + let previous_sidecar = fs::read_to_string(data_dir.join(&media.sidecar_path)).ok(); + media.size = replacement_size; + media.width = width; + media.height = height; + media.checksum = Some(replacement_checksum); + media.updated_at = now_unix_ms(); + + let apply_result = (|| -> EngineResult<()> { + conn.begin_savepoint()?; + qm::update_media(conn, &media)?; + let linked = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default(); + let linked_post_ids: Vec = linked.iter().map(|item| item.post_id.clone()).collect(); + let sidecar = MediaSidecar::from_media(&media, &linked_post_ids); + atomic_write_str(&data_dir.join(&media.sidecar_path), &sidecar.to_string())?; + fts_index_media(conn, &media)?; + + for staged in &staged_paths { + let staged = Path::new(staged); + let relative = staged.strip_prefix(&staged_thumbnails).map_err(|error| { + EngineError::Validation(format!("invalid staged thumbnail path: {error}")) + })?; + let target = data_dir.join("thumbnails").join(relative); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(staged, target)?; + } + conn.release_savepoint()?; + Ok(()) + })(); + + let _ = fs::remove_dir_all(&staged_thumbnails); + match apply_result { + Ok(()) => { + fs::remove_file(backup)?; + Ok(Some(media)) + } + Err(error) => { + let _ = conn.rollback_savepoint(); + let _ = fs::remove_file(&destination); + let _ = fs::rename(&backup, &destination); + let _ = qm::update_media(conn, &previous_media); + if let Some(previous_sidecar) = previous_sidecar { + let _ = atomic_write_str( + &data_dir.join(&previous_media.sidecar_path), + &previous_sidecar, + ); + } + Err(error) + } + } +} + /// Delete a media item and all related artifacts. pub fn delete_media(conn: &Connection, data_dir: &Path, media_id: &str) -> EngineResult<()> { let media = qm::get_media_by_id(conn, media_id)?; @@ -499,7 +621,7 @@ fn is_translation_sidecar(file_name: &str) -> bool { } /// Rebuild a canonical media from its sidecar file. Returns true if created, false if updated. -fn rebuild_canonical_media( +pub(crate) fn rebuild_canonical_media( conn: &Connection, data_dir: &Path, project_id: &str, @@ -612,7 +734,7 @@ fn rebuild_canonical_media( } /// Rebuild a translation from a `*.{lang}.meta` sidecar. Returns true if created, false if updated. -fn rebuild_translation_sidecar( +pub(crate) fn rebuild_translation_sidecar( conn: &Connection, _data_dir: &Path, project_id: &str, @@ -800,6 +922,89 @@ mod tests { assert_eq!(sc.tags, vec!["updated-tag"]); } + #[test] + fn replace_media_file_preserves_identity_and_regenerates_artifacts() { + let (db, dir) = setup(); + let source = create_test_png(dir.path()); + let media = import_media( + db.conn(), + dir.path(), + "p1", + &source, + "photo.png", + Some("Kept title"), + None, + None, + None, + None, + vec!["kept".into()], + ) + .unwrap(); + let replacement = dir.path().join("replacement.png"); + DynamicImage::new_rgb8(320, 180).save(&replacement).unwrap(); + let old_checksum = media.checksum.clone(); + + let updated = replace_media_file(db.conn(), dir.path(), &media.id, &replacement) + .unwrap() + .unwrap(); + + assert_eq!(updated.id, media.id); + assert_eq!(updated.file_path, media.file_path); + assert_eq!(updated.original_name, media.original_name); + assert_eq!(updated.title, media.title); + assert_eq!(updated.tags, media.tags); + assert_eq!((updated.width, updated.height), (Some(320), Some(180))); + assert_ne!(updated.checksum, old_checksum); + assert!(!dir.path().join(format!("{}.bak", media.file_path)).exists()); + + let sidecar = + read_sidecar(&fs::read_to_string(dir.path().join(&updated.sidecar_path)).unwrap()) + .unwrap(); + assert_eq!((sidecar.width, sidecar.height), (Some(320), Some(180))); + + let prefix = &media.id[..2]; + let ai_thumb = dir + .path() + .join("thumbnails") + .join(prefix) + .join(format!("{}-ai.jpg", media.id)); + assert!(ai_thumb.is_file()); + } + + #[test] + fn replace_media_file_is_noop_for_identical_content() { + let (db, dir) = setup(); + let source = create_test_png(dir.path()); + let media = import_media( + db.conn(), + dir.path(), + "p1", + &source, + "photo.png", + None, + None, + None, + None, + None, + vec![], + ) + .unwrap(); + let stored = dir.path().join(&media.file_path); + let updated_at = media.updated_at; + + assert!( + replace_media_file(db.conn(), dir.path(), &media.id, &stored) + .unwrap() + .is_none() + ); + assert_eq!( + qm::get_media_by_id(db.conn(), &media.id) + .unwrap() + .updated_at, + updated_at + ); + } + #[test] fn delete_media_removes_everything() { let (db, dir) = setup(); diff --git a/crates/bds-core/src/engine/meta.rs b/crates/bds-core/src/engine/meta.rs index 6a77afd..007cae7 100644 --- a/crates/bds-core/src/engine/meta.rs +++ b/crates/bds-core/src/engine/meta.rs @@ -177,6 +177,18 @@ pub fn update_project_metadata(data_dir: &Path, changes: &serde_json::Value) -> if let Some(max) = changes.get("maxPostsPerPage").and_then(|v| v.as_i64()) { meta.max_posts_per_page = max as i32; } + if let Some(concurrency) = changes.get("imageImportConcurrency") { + meta.image_import_concurrency = concurrency + .as_i64() + .map(|value| value as i32) + .or_else(|| { + concurrency + .as_str() + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(4) + .clamp(1, 8); + } if let Some(cat) = changes.get("blogmarkCategory") { meta.blogmark_category = cat.as_str().map(|s| s.to_string()); } @@ -218,6 +230,7 @@ pub fn startup_sync(data_dir: &Path) -> EngineResult<()> { main_language: None, default_author: None, max_posts_per_page: 50, + image_import_concurrency: 4, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, @@ -286,6 +299,7 @@ mod tests { main_language: Some("en".into()), default_author: None, max_posts_per_page: 25, + image_import_concurrency: 4, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, diff --git a/crates/bds-core/src/engine/metadata_diff.rs b/crates/bds-core/src/engine/metadata_diff.rs index 09bce7b..636486d 100644 --- a/crates/bds-core/src/engine/metadata_diff.rs +++ b/crates/bds-core/src/engine/metadata_diff.rs @@ -7,16 +7,23 @@ use walkdir::WalkDir; use crate::db::from_row::{script_kind_to_str, template_kind_to_str}; use crate::db::queries::media as qm; +use crate::db::queries::media_translation as qmt; use crate::db::queries::post as qp; use crate::db::queries::post_translation as qt; +use crate::db::queries::project as qproject; use crate::db::queries::script as qs; use crate::db::queries::template as qtpl; use crate::engine::{EngineError, EngineResult}; -use crate::model::{Media, Post, PostStatus, PostTranslation, Script, Template}; +use crate::model::{Media, MediaTranslation, Post, PostStatus, PostTranslation, Script, Template}; use crate::util::frontmatter::{ - read_post_file, read_script_file, read_template_file, read_translation_file, + ScriptFrontmatter, TemplateFrontmatter, read_post_file, read_script_file, read_template_file, + read_translation_file, write_post_file, write_script_file, write_template_file, + write_translation_file, }; -use crate::util::sidecar::read_sidecar; +use crate::util::sidecar::{ + MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar, +}; +use crate::util::{atomic_write_str, media_translation_sidecar_path}; /// A single field difference. #[derive(Debug, Clone)] @@ -43,13 +50,19 @@ pub struct OrphanFile { } /// Complete diff report. -#[derive(Debug, Default)] +#[derive(Debug, Clone, Default)] pub struct DiffReport { pub diffs: Vec, pub orphans: Vec, pub errors: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RepairDirection { + FileToDatabase, + DatabaseToFile, +} + /// Compare DB state vs filesystem files and report all differences. /// /// This function does NOT modify anything -- it only reports differences. @@ -60,6 +73,14 @@ pub fn compute_metadata_diff( ) -> EngineResult { let mut report = DiffReport::default(); + if let Ok(project) = qproject::get_project_by_id(conn, project_id) { + match diff_project(data_dir, &project) { + Ok(Some(diff)) => report.diffs.push(diff), + Ok(None) => {} + Err(error) => report.errors.push(format!("project {project_id}: {error}")), + } + } + // 1. Diff posts let posts = qp::list_posts_by_project(conn, project_id)?; for post in &posts { @@ -94,6 +115,17 @@ pub fn compute_metadata_diff( if m.sidecar_path.is_empty() { continue; } + + let translations = qmt::list_media_translations_by_media(conn, &m.id)?; + for translation in &translations { + match diff_media_translation(data_dir, m, translation) { + Ok(Some(diff)) => report.diffs.push(diff), + Ok(None) => {} + Err(error) => report + .errors + .push(format!("media translation {}: {error}", translation.id)), + } + } match diff_media(data_dir, m) { Ok(Some(d)) => report.diffs.push(d), Ok(None) => {} @@ -134,6 +166,255 @@ pub fn compute_metadata_diff( Ok(report) } +/// Resolve one reported difference in the selected direction. +pub fn repair_metadata_diff_item( + conn: &Connection, + data_dir: &Path, + project_id: &str, + direction: RepairDirection, + item: &EntityDiff, +) -> EngineResult<()> { + match direction { + RepairDirection::FileToDatabase => { + let path = data_dir.join(&item.file_path); + match item.entity_type.as_str() { + "project" => sync_project_from_file(conn, data_dir, project_id)?, + "post" => { + crate::engine::post::rebuild_canonical_post(conn, data_dir, project_id, &path)?; + } + "post_translation" => { + crate::engine::post::rebuild_translation(conn, data_dir, project_id, &path)?; + } + "media" => { + crate::engine::media::rebuild_canonical_media( + conn, data_dir, project_id, &path, + )?; + } + "media_translation" => { + crate::engine::media::rebuild_translation_sidecar( + conn, data_dir, project_id, &path, + )?; + } + "script" => { + crate::engine::script_rebuild::rebuild_single_script( + conn, data_dir, project_id, &path, + )?; + } + "template" => { + crate::engine::template_rebuild::rebuild_single_template( + conn, data_dir, project_id, &path, + )?; + } + other => return unsupported_repair(other), + } + } + RepairDirection::DatabaseToFile => match item.entity_type.as_str() { + "project" => rewrite_project_from_database(conn, data_dir, project_id)?, + "post" => rewrite_post_from_database(conn, data_dir, &item.entity_id)?, + "post_translation" => { + rewrite_post_translation_from_database(conn, data_dir, &item.entity_id)? + } + "media" => rewrite_media_from_database(conn, data_dir, &item.entity_id)?, + "media_translation" => rewrite_media_translation_from_database( + conn, + data_dir, + project_id, + &item.entity_id, + )?, + "script" => rewrite_script_from_database(conn, data_dir, &item.entity_id)?, + "template" => rewrite_template_from_database(conn, data_dir, &item.entity_id)?, + other => return unsupported_repair(other), + }, + } + Ok(()) +} + +fn diff_project( + data_dir: &Path, + project: &crate::model::Project, +) -> EngineResult> { + let metadata = crate::engine::meta::read_project_json(data_dir)?; + let mut fields = Vec::new(); + compare_field(&mut fields, "name", &project.name, &metadata.name); + compare_field( + &mut fields, + "description", + project.description.as_deref().unwrap_or(""), + metadata.description.as_deref().unwrap_or(""), + ); + Ok((!fields.is_empty()).then_some(EntityDiff { + entity_type: "project".into(), + entity_id: project.id.clone(), + file_path: "meta/project.json".into(), + fields, + })) +} + +fn sync_project_from_file( + conn: &Connection, + data_dir: &Path, + project_id: &str, +) -> EngineResult<()> { + let metadata = crate::engine::meta::read_project_json(data_dir)?; + let mut project = qproject::get_project_by_id(conn, project_id)?; + project.name = metadata.name; + project.description = metadata.description; + project.updated_at = crate::util::now_unix_ms(); + qproject::update_project(conn, &project)?; + Ok(()) +} + +fn rewrite_project_from_database( + conn: &Connection, + data_dir: &Path, + project_id: &str, +) -> EngineResult<()> { + let project = qproject::get_project_by_id(conn, project_id)?; + let mut metadata = crate::engine::meta::read_project_json(data_dir)?; + metadata.name = project.name; + metadata.description = project.description; + crate::engine::meta::write_project_json(data_dir, &metadata) +} + +fn unsupported_repair(entity_type: &str) -> EngineResult { + Err(EngineError::Validation(format!( + "unsupported metadata diff entity type: {entity_type}" + ))) +} + +fn rewrite_post_from_database( + conn: &Connection, + data_dir: &Path, + entity_id: &str, +) -> EngineResult<()> { + let post = qp::get_post_by_id(conn, entity_id)?; + let path = data_dir.join(&post.file_path); + let body = fs::read_to_string(&path) + .ok() + .and_then(|content| read_post_file(&content).ok().map(|(_, body)| body)) + .unwrap_or_default(); + atomic_write_str(&path, &write_post_file(&post, &body))?; + Ok(()) +} + +fn rewrite_post_translation_from_database( + conn: &Connection, + data_dir: &Path, + entity_id: &str, +) -> EngineResult<()> { + let translation = qt::get_post_translation_by_id(conn, entity_id)?; + let path = data_dir.join(&translation.file_path); + let body = fs::read_to_string(&path) + .ok() + .and_then(|content| read_translation_file(&content).ok().map(|(_, body)| body)) + .unwrap_or_default(); + atomic_write_str(&path, &write_translation_file(&translation, &body))?; + Ok(()) +} + +fn rewrite_media_from_database( + conn: &Connection, + data_dir: &Path, + entity_id: &str, +) -> EngineResult<()> { + let media = qm::get_media_by_id(conn, entity_id)?; + let linked = crate::db::queries::post_media::list_post_media_by_media(conn, entity_id) + .unwrap_or_default(); + let post_ids = linked + .into_iter() + .map(|link| link.post_id) + .collect::>(); + atomic_write_str( + &data_dir.join(&media.sidecar_path), + &MediaSidecar::from_media(&media, &post_ids).to_string(), + )?; + Ok(()) +} + +fn rewrite_media_translation_from_database( + conn: &Connection, + data_dir: &Path, + project_id: &str, + entity_id: &str, +) -> EngineResult<()> { + let media_items = qm::list_media_by_project(conn, project_id)?; + for media in media_items { + if let Some(translation) = qmt::list_media_translations_by_media(conn, &media.id)? + .into_iter() + .find(|translation| translation.id == entity_id) + { + let sidecar = MediaTranslationSidecar { + translation_for: translation.translation_for, + language: translation.language.clone(), + title: translation.title, + alt: translation.alt, + caption: translation.caption, + }; + let path = media_translation_sidecar_path(&media.file_path, &translation.language); + atomic_write_str(&data_dir.join(path), &sidecar.to_string())?; + return Ok(()); + } + } + Err(EngineError::NotFound(format!( + "media translation {entity_id}" + ))) +} + +fn rewrite_script_from_database( + conn: &Connection, + data_dir: &Path, + entity_id: &str, +) -> EngineResult<()> { + let script = qs::get_script_by_id(conn, entity_id)?; + let path = data_dir.join(&script.file_path); + let body = fs::read_to_string(&path) + .ok() + .and_then(|content| read_script_file(&content).ok().map(|(_, body)| body)) + .or(script.content.clone()) + .unwrap_or_default(); + let frontmatter = ScriptFrontmatter { + id: script.id, + project_id: Some(script.project_id), + slug: script.slug, + title: script.title, + kind: script_kind_to_str(&script.kind).to_owned(), + entrypoint: script.entrypoint, + enabled: script.enabled, + version: script.version, + created_at: script.created_at, + updated_at: script.updated_at, + }; + atomic_write_str(&path, &write_script_file(&frontmatter, &body))?; + Ok(()) +} + +fn rewrite_template_from_database( + conn: &Connection, + data_dir: &Path, + entity_id: &str, +) -> EngineResult<()> { + let template = qtpl::get_template_by_id(conn, entity_id)?; + let path = data_dir.join(&template.file_path); + let body = fs::read_to_string(&path) + .ok() + .and_then(|content| read_template_file(&content).ok().map(|(_, body)| body)) + .or(template.content.clone()) + .unwrap_or_default(); + let frontmatter = TemplateFrontmatter { + id: template.id, + project_id: Some(template.project_id), + slug: template.slug, + title: template.title, + kind: template_kind_to_str(&template.kind).to_owned(), + enabled: template.enabled, + version: template.version, + created_at: template.created_at, + updated_at: template.updated_at, + }; + atomic_write_str(&path, &write_template_file(&frontmatter, &body))?; + Ok(()) +} + // --- Internal helpers --- fn opt_to_str(opt: &Option) -> String { @@ -361,6 +642,58 @@ fn diff_media(data_dir: &Path, media: &Media) -> EngineResult } } +fn diff_media_translation( + data_dir: &Path, + media: &Media, + translation: &MediaTranslation, +) -> EngineResult> { + let relative_path = media_translation_sidecar_path(&media.file_path, &translation.language); + let absolute_path = data_dir.join(&relative_path); + if !absolute_path.is_file() { + return Ok(None); + } + let sidecar = read_translation_sidecar(&fs::read_to_string(&absolute_path)?) + .map_err(EngineError::Parse)?; + let mut fields = Vec::new(); + compare_field( + &mut fields, + "translationFor", + &translation.translation_for, + &sidecar.translation_for, + ); + compare_field( + &mut fields, + "language", + &translation.language, + &sidecar.language, + ); + compare_field( + &mut fields, + "title", + &opt_to_str(&translation.title), + &opt_to_str(&sidecar.title), + ); + compare_field( + &mut fields, + "alt", + &opt_to_str(&translation.alt), + &opt_to_str(&sidecar.alt), + ); + compare_field( + &mut fields, + "caption", + &opt_to_str(&translation.caption), + &opt_to_str(&sidecar.caption), + ); + + Ok((!fields.is_empty()).then_some(EntityDiff { + entity_type: "media_translation".into(), + entity_id: translation.id.clone(), + file_path: relative_path, + fields, + })) +} + fn diff_template(data_dir: &Path, tpl: &Template) -> EngineResult> { let abs_path = data_dir.join(&tpl.file_path); if !abs_path.exists() { @@ -484,6 +817,12 @@ fn detect_orphan_files( if !m.sidecar_path.is_empty() { db_file_paths.insert(m.sidecar_path.clone()); } + for translation in qmt::list_media_translations_by_media(conn, &m.id)? { + db_file_paths.insert(media_translation_sidecar_path( + &m.file_path, + &translation.language, + )); + } } let templates = qtpl::list_templates_by_project(conn, project_id)?; @@ -578,6 +917,15 @@ fn detect_orphan_files( }); } } + for translation in qmt::list_media_translations_by_media(conn, &m.id)? { + let path = media_translation_sidecar_path(&m.file_path, &translation.language); + if !data_dir.join(&path).is_file() { + orphans.push(OrphanFile { + file_path: path, + reason: "db_entry_without_file".to_string(), + }); + } + } } for t in &templates { @@ -719,6 +1067,89 @@ mod tests { assert_eq!(title_diffs[0].file_value, "Tampered Title"); } + #[test] + fn repairs_one_post_diff_from_file_to_database() { + let (db, dir) = setup(); + let post = create_post( + db.conn(), + dir.path(), + "p1", + "Database Title", + Some("body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + let published = publish_post(db.conn(), dir.path(), &post.id).unwrap(); + let path = dir.path().join(&published.file_path); + let content = fs::read_to_string(&path).unwrap(); + fs::write(&path, content.replace("Database Title", "Filesystem Title")).unwrap(); + let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); + let item = report + .diffs + .iter() + .find(|item| item.entity_type == "post") + .unwrap(); + + repair_metadata_diff_item( + db.conn(), + dir.path(), + "p1", + RepairDirection::FileToDatabase, + item, + ) + .unwrap(); + + assert_eq!( + qp::get_post_by_id(db.conn(), &post.id).unwrap().title, + "Filesystem Title" + ); + } + + #[test] + fn repairs_one_post_diff_from_database_to_file_without_losing_body() { + let (db, dir) = setup(); + let post = create_post( + db.conn(), + dir.path(), + "p1", + "Database Title", + Some("body that must survive"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + let published = publish_post(db.conn(), dir.path(), &post.id).unwrap(); + let path = dir.path().join(&published.file_path); + let content = fs::read_to_string(&path).unwrap(); + fs::write(&path, content.replace("Database Title", "Filesystem Title")).unwrap(); + let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); + let item = report + .diffs + .iter() + .find(|item| item.entity_type == "post") + .unwrap(); + + repair_metadata_diff_item( + db.conn(), + dir.path(), + "p1", + RepairDirection::DatabaseToFile, + item, + ) + .unwrap(); + + let repaired = fs::read_to_string(path).unwrap(); + assert!(repaired.contains("title: Database Title")); + assert!(repaired.contains("body that must survive")); + } + #[test] fn detects_media_sidecar_drift() { let (db, dir) = setup(); diff --git a/crates/bds-core/src/engine/mod.rs b/crates/bds-core/src/engine/mod.rs index c65c5e4..ed44d46 100644 --- a/crates/bds-core/src/engine/mod.rs +++ b/crates/bds-core/src/engine/mod.rs @@ -1,4 +1,6 @@ pub mod ai; +pub mod auto_translation; +pub mod blogmark; pub mod calendar; pub mod context; pub mod error; @@ -11,6 +13,7 @@ pub mod post; pub mod post_media; pub mod preview; pub mod project; +pub mod publishing; pub mod rebuild; pub mod script; pub mod script_rebuild; @@ -20,7 +23,6 @@ pub mod tag; pub mod task; pub mod template; pub mod template_rebuild; -pub mod validate_content; pub mod validate_media; pub mod validate_site; pub mod validate_translations; diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index caafa7c..48b164b 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -662,6 +662,30 @@ pub fn delete_translation( Ok(()) } +/// Publish one draft translation without republishing its canonical post. +pub fn publish_post_translation( + conn: &Connection, + data_dir: &Path, + translation_id: &str, +) -> EngineResult { + let mut translation = qt::get_post_translation_by_id(conn, translation_id)?; + if translation.status == PostStatus::Published { + return Ok(translation); + } + let post = qp::get_post_by_id(conn, &translation.translation_for)?; + conn.begin_savepoint()?; + match publish_translation(conn, data_dir, &mut translation, &post) { + Ok(()) => { + conn.release_savepoint()?; + Ok(translation) + } + Err(error) => { + let _ = conn.rollback_savepoint(); + Err(error) + } + } +} + /// Rebuild posts from filesystem. Walk posts/ dir, parse .md files, upsert into DB. pub fn rebuild_posts_from_filesystem( conn: &Connection, @@ -943,7 +967,7 @@ fn is_translation_filename(stem: &str) -> bool { } /// Rebuild a canonical post from a .md file. Returns true if created, false if updated. -fn rebuild_canonical_post( +pub(crate) fn rebuild_canonical_post( conn: &Connection, data_dir: &Path, project_id: &str, @@ -1033,7 +1057,7 @@ fn rebuild_canonical_post( } /// Rebuild a translation from a .{lang}.md file. Returns true if created, false if updated. -fn rebuild_translation( +pub(crate) fn rebuild_translation( conn: &Connection, data_dir: &Path, project_id: &str, diff --git a/crates/bds-core/src/engine/preview.rs b/crates/bds-core/src/engine/preview.rs index 3970f64..ddab096 100644 --- a/crates/bds-core/src/engine/preview.rs +++ b/crates/bds-core/src/engine/preview.rs @@ -480,6 +480,7 @@ mod tests { main_language: Some("en".into()), default_author: None, max_posts_per_page: 50, + image_import_concurrency: 4, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, diff --git a/crates/bds-core/src/engine/project.rs b/crates/bds-core/src/engine/project.rs index 0ae2bdb..12e742d 100644 --- a/crates/bds-core/src/engine/project.rs +++ b/crates/bds-core/src/engine/project.rs @@ -28,7 +28,7 @@ pub fn create_project( }); let now = now_unix_ms(); - let project = Project { + let mut project = Project { id: id.clone(), name: name.to_string(), slug: slug.clone(), @@ -49,12 +49,98 @@ pub fn create_project( // Create directory structure create_directory_structure(&data_dir)?; + // Store a stable, absolute machine-local pointer after the folder exists. + if data_path.is_some() { + project.data_path = Some(data_dir.canonicalize()?.to_string_lossy().to_string()); + q::update_project(conn, &project)?; + } + // Write default meta files write_default_meta_files(&data_dir, name)?; Ok(project) } +/// Open an existing portable project folder and remember its current location +/// in the machine-local project registry (the application database). +/// +/// The folder itself is authoritative: `data_path` is derived from the +/// location of `meta/project.json` and is never written into that file. +pub fn open_project(conn: &Connection, folder_path: &Path) -> EngineResult { + let folder_path = folder_path.canonicalize().map_err(|error| { + EngineError::Validation(format!( + "cannot open project folder '{}': {error}", + folder_path.display() + )) + })?; + let metadata_path = folder_path.join("meta/project.json"); + if !metadata_path.is_file() { + return Err(EngineError::Validation(format!( + "project folder must contain meta/project.json: {}", + folder_path.display() + ))); + } + + let metadata: ProjectMetadata = serde_json::from_str(&fs::read_to_string(&metadata_path)?)?; + metadata.validate().map_err(EngineError::Validation)?; + let resolved_path = folder_path.to_string_lossy().to_string(); + let projects = q::list_projects(conn)?; + + // Reopening a registered folder is idempotent. If a registered folder no + // longer exists and its portable metadata name matches uniquely, treat the + // selected folder as that project's new location. + let exact = projects.iter().find(|project| { + project.data_path.as_deref().is_some_and(|path| { + Path::new(path) + .canonicalize() + .map(|registered| registered == folder_path) + .unwrap_or(false) + }) + }); + let moved_matches: Vec<&Project> = projects + .iter() + .filter(|project| { + project.name == metadata.name + && project + .data_path + .as_deref() + .is_some_and(|path| !Path::new(path).join("meta/project.json").is_file()) + }) + .collect(); + + let moved = if moved_matches.len() == 1 { + Some(moved_matches[0]) + } else { + None + }; + if let Some(existing) = exact.or(moved) { + let mut project = existing.clone(); + project.name = metadata.name; + project.description = metadata.description; + project.data_path = Some(resolved_path); + project.updated_at = now_unix_ms(); + q::update_project(conn, &project)?; + return Ok(project); + } + + let now = now_unix_ms(); + let base_slug = slugify(&metadata.name); + let project = Project { + id: Uuid::new_v4().to_string(), + name: metadata.name, + slug: ensure_unique(&base_slug, |candidate| { + q::get_project_by_slug(conn, candidate).is_ok() + }), + description: metadata.description, + data_path: Some(resolved_path), + is_active: false, + created_at: now, + updated_at: now, + }; + q::insert_project(conn, &project)?; + Ok(project) +} + /// Ensure the default project (id="default") exists. /// Creates it on first launch if missing, per the DefaultProjectExists invariant. /// Returns the project (existing or newly created). @@ -182,6 +268,7 @@ fn write_default_meta_files(data_dir: &Path, project_name: &str) -> EngineResult main_language: None, default_author: None, max_posts_per_page: 50, + image_import_concurrency: 4, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, @@ -210,69 +297,11 @@ fn write_default_meta_files(data_dir: &Path, project_name: &str) -> EngineResult let default_opml = crate::engine::menu::default_menu_opml(); atomic_write_str(&meta_dir.join("menu.opml"), &default_opml)?; - // Starter templates — per project.allium StarterTemplatesCopied - copy_starter_templates(data_dir)?; copy_bundled_site_assets(data_dir)?; Ok(()) } -/// Copy bundled starter templates into the project templates directory. -/// Per project.allium: "Bundled starter templates are copied into the new project." -fn copy_starter_templates(data_dir: &Path) -> EngineResult<()> { - let templates_dir = data_dir.join("templates"); - let partials_dir = templates_dir.join("partials"); - fs::create_dir_all(&partials_dir)?; - - // Starter templates embedded at compile time from assets/starter-templates/ - let templates: &[(&str, &str)] = &[ - ( - "single-post.liquid", - include_str!("../../../../assets/starter-templates/single-post.liquid"), - ), - ( - "post-list.liquid", - include_str!("../../../../assets/starter-templates/post-list.liquid"), - ), - ( - "not-found.liquid", - include_str!("../../../../assets/starter-templates/not-found.liquid"), - ), - ]; - let partials: &[(&str, &str)] = &[ - ( - "head.liquid", - include_str!("../../../../assets/starter-templates/partials/head.liquid"), - ), - ( - "menu.liquid", - include_str!("../../../../assets/starter-templates/partials/menu.liquid"), - ), - ( - "menu-items.liquid", - include_str!("../../../../assets/starter-templates/partials/menu-items.liquid"), - ), - ( - "language-switcher.liquid", - include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid"), - ), - ]; - - for (name, content) in templates { - let path = templates_dir.join(name); - if !path.exists() { - atomic_write_str(&path, content)?; - } - } - for (name, content) in partials { - let path = partials_dir.join(name); - if !path.exists() { - atomic_write_str(&path, content)?; - } - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -308,6 +337,15 @@ mod tests { assert!(data_path.join("assets/pico.min.css").is_file()); assert!(data_path.join("assets/tag-cloud.js").is_file()); + // Bundled defaults stay in the application. Project templates are + // reserved for user-managed overrides. + assert!( + fs::read_dir(data_path.join("templates")) + .unwrap() + .next() + .is_none() + ); + // Verify meta files let project_json: serde_json::Value = serde_json::from_str(&fs::read_to_string(data_path.join("meta/project.json")).unwrap()) @@ -343,6 +381,54 @@ mod tests { assert_eq!(p2.slug, "blog-2"); } + #[test] + fn open_project_registers_existing_folder_without_rewriting_metadata() { + let (db, dir) = setup(); + let data_path = dir.path().join("portable-blog"); + fs::create_dir_all(data_path.join("meta")).unwrap(); + let metadata = r#"{"name":"Portable Blog","description":"Moved safely"}"#; + fs::write(data_path.join("meta/project.json"), metadata).unwrap(); + + let project = open_project(db.conn(), &data_path).unwrap(); + + assert_eq!(project.name, "Portable Blog"); + assert_eq!(project.description.as_deref(), Some("Moved safely")); + assert_eq!( + project.data_path.as_deref(), + data_path.canonicalize().unwrap().to_str() + ); + assert_eq!( + fs::read_to_string(data_path.join("meta/project.json")).unwrap(), + metadata + ); + } + + #[test] + fn open_project_updates_moved_matching_project_location() { + let (db, dir) = setup(); + let old_path = dir.path().join("old-location"); + let project = + create_project(db.conn(), "Movable Blog", Some(old_path.to_str().unwrap())).unwrap(); + let new_path = dir.path().join("new-location"); + fs::rename(&old_path, &new_path).unwrap(); + + let reopened = open_project(db.conn(), &new_path).unwrap(); + + assert_eq!(reopened.id, project.id); + assert_eq!( + reopened.data_path.as_deref(), + new_path.canonicalize().unwrap().to_str() + ); + assert_eq!(list_projects(db.conn()).unwrap().len(), 1); + } + + #[test] + fn open_project_rejects_folder_without_project_metadata() { + let (db, dir) = setup(); + let error = open_project(db.conn(), dir.path()).unwrap_err(); + assert!(error.to_string().contains("meta/project.json")); + } + #[test] fn get_active_project_none() { let (db, _dir) = setup(); diff --git a/crates/bds-core/src/engine/publishing.rs b/crates/bds-core/src/engine/publishing.rs new file mode 100644 index 0000000..6a59dde --- /dev/null +++ b/crates/bds-core/src/engine/publishing.rs @@ -0,0 +1,462 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde::{Deserialize, Serialize}; +use walkdir::WalkDir; + +use crate::engine::{EngineError, EngineResult}; +use crate::model::{PublishingPreferences, SshMode}; +use crate::util::atomic_write_str; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PublishJobStatus { + Pending, + Running, + Completed, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UploadTargetKind { + Html, + Thumbnails, + Media, +} + +impl UploadTargetKind { + fn as_str(self) -> &'static str { + match self { + Self::Html => "html", + Self::Thumbnails => "thumbnails", + Self::Media => "media", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UploadTarget { + pub kind: UploadTargetKind, + pub local_dir: PathBuf, + pub remote_dir: String, +} + +#[derive(Debug, Clone)] +pub struct PublishJob { + pub ssh_host: String, + pub ssh_user: String, + pub ssh_remote_path: String, + pub ssh_mode: SshMode, + pub status: PublishJobStatus, + pub completed_targets: Vec, + pub error: Option, +} + +impl PublishJob { + fn start(&mut self) -> EngineResult<()> { + if self.status != PublishJobStatus::Pending { + return Err(EngineError::Conflict( + "publish job can only start while pending".into(), + )); + } + self.status = PublishJobStatus::Running; + Ok(()) + } + + fn complete(&mut self) -> EngineResult<()> { + if self.status != PublishJobStatus::Running { + return Err(EngineError::Conflict( + "publish job can only complete while running".into(), + )); + } + self.status = PublishJobStatus::Completed; + Ok(()) + } + + fn fail(&mut self, error: impl Into) { + if self.status == PublishJobStatus::Running { + self.status = PublishJobStatus::Failed; + self.error = Some(error.into()); + } + } +} + +#[derive(Debug, Clone)] +struct Credentials { + host: String, + user: String, + remote_path: String, + mode: SshMode, +} + +impl Credentials { + fn from_preferences(preferences: &PublishingPreferences) -> EngineResult { + let required = |value: &Option, field: &str| { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| EngineError::Validation(format!("missing {field}"))) + }; + Ok(Self { + host: required(&preferences.ssh_host, "SSH host")?, + user: required(&preferences.ssh_user, "SSH user")?, + remote_path: required(&preferences.ssh_remote_path, "SSH remote path")?, + mode: preferences.ssh_mode.clone(), + }) + } + + fn remote_base(&self) -> String { + format!("{}@{}", self.user, self.host) + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct ScpMtimeCache(BTreeMap); + +type CommandRunner<'a> = dyn FnMut(&str, &[String]) -> Result<(), String> + 'a; + +/// Upload the generated site using non-interactive SSH-agent authentication. +pub fn upload_site( + data_dir: &Path, + private_cache_dir: &Path, + preferences: &PublishingPreferences, + mut on_progress: impl FnMut(usize, usize, UploadTargetKind), +) -> EngineResult { + if std::env::var_os("SSH_AUTH_SOCK").is_none() { + return Err(EngineError::Validation( + "SSH agent is unavailable (SSH_AUTH_SOCK is not set)".into(), + )); + } + upload_site_with_runner( + data_dir, + private_cache_dir, + preferences, + &mut |program, args| run_command(program, args), + &mut on_progress, + ) +} + +fn upload_site_with_runner( + data_dir: &Path, + private_cache_dir: &Path, + preferences: &PublishingPreferences, + runner: &mut CommandRunner<'_>, + on_progress: &mut dyn FnMut(usize, usize, UploadTargetKind), +) -> EngineResult { + let credentials = Credentials::from_preferences(preferences)?; + let targets = build_upload_targets(data_dir, &credentials); + let mut job = PublishJob { + ssh_host: credentials.host.clone(), + ssh_user: credentials.user.clone(), + ssh_remote_path: credentials.remote_path.clone(), + ssh_mode: credentials.mode.clone(), + status: PublishJobStatus::Pending, + completed_targets: Vec::new(), + error: None, + }; + job.start()?; + + let cache_path = private_cache_dir.join("publishing-scp-mtimes.json"); + let mut cache = read_cache(&cache_path); + for (index, target) in targets.iter().enumerate() { + on_progress(index + 1, targets.len(), target.kind); + let result = match credentials.mode { + SshMode::Rsync => upload_rsync(target, &credentials, runner), + SshMode::Scp => upload_scp(target, &credentials, &mut cache, runner), + }; + if let Err(error) = result { + job.fail(error.clone()); + return Err(EngineError::Parse(error)); + } + job.completed_targets.push(target.kind); + if matches!(credentials.mode, SshMode::Scp) { + write_cache(&cache_path, &cache)?; + } + } + job.complete()?; + Ok(job) +} + +fn build_upload_targets(data_dir: &Path, credentials: &Credentials) -> Vec { + let root = credentials.remote_path.trim_end_matches('/'); + vec![ + UploadTarget { + kind: UploadTargetKind::Html, + local_dir: data_dir.join("html"), + remote_dir: root.to_owned(), + }, + UploadTarget { + kind: UploadTargetKind::Thumbnails, + local_dir: data_dir.join("thumbnails"), + remote_dir: format!("{root}/thumbnails"), + }, + UploadTarget { + kind: UploadTargetKind::Media, + local_dir: data_dir.join("media"), + remote_dir: format!("{root}/media"), + }, + ] +} + +fn upload_rsync( + target: &UploadTarget, + credentials: &Credentials, + runner: &mut CommandRunner<'_>, +) -> Result<(), String> { + if !target.local_dir.is_dir() { + return Ok(()); + } + let mut args = vec![ + "--update".into(), + "--compress".into(), + "--verbose".into(), + "--recursive".into(), + "--times".into(), + ]; + if target.kind == UploadTargetKind::Media { + args.push("--exclude=*.meta".into()); + } + args.push(format!("{}/", target.local_dir.display())); + args.push(format!( + "{}:{}/", + credentials.remote_base(), + target.remote_dir.trim_end_matches('/') + )); + runner("rsync", &args) +} + +fn upload_scp( + target: &UploadTarget, + credentials: &Credentials, + cache: &mut ScpMtimeCache, + runner: &mut CommandRunner<'_>, +) -> Result<(), String> { + let files = list_target_files(target).map_err(|error| error.to_string())?; + let mut remote_dirs = BTreeSet::new(); + let mut pending = Vec::new(); + for relative in files { + let local = target.local_dir.join(&relative); + let mtime = modified_seconds(&local).map_err(|error| error.to_string())?; + let key = cache_key(credentials, target, &relative); + if cache.0.get(&key).is_some_and(|recorded| *recorded >= mtime) { + continue; + } + let remote_file = format!("{}/{}", target.remote_dir, relative.to_string_lossy()); + if let Some(parent) = Path::new(&remote_file).parent() { + remote_dirs.insert(parent.to_string_lossy().to_string()); + } + pending.push((local, remote_file, key, mtime)); + } + if pending.is_empty() { + return Ok(()); + } + + let mut mkdir_args = vec![credentials.remote_base(), "mkdir".into(), "-p".into()]; + mkdir_args.extend(remote_dirs); + runner("ssh", &mkdir_args)?; + + for (local, remote_file, key, mtime) in pending { + runner( + "scp", + &[ + "-q".into(), + "-p".into(), + local.to_string_lossy().to_string(), + format!("{}:{remote_file}", credentials.remote_base()), + ], + )?; + cache.0.insert(key, mtime); + } + Ok(()) +} + +fn list_target_files(target: &UploadTarget) -> EngineResult> { + if !target.local_dir.is_dir() { + return Ok(Vec::new()); + } + let mut files = WalkDir::new(&target.local_dir) + .into_iter() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_file()) + .filter_map(|entry| { + entry + .path() + .strip_prefix(&target.local_dir) + .ok() + .map(PathBuf::from) + }) + .filter(|relative| { + target.kind != UploadTargetKind::Media || !relative.to_string_lossy().ends_with(".meta") + }) + .collect::>(); + files.sort(); + Ok(files) +} + +fn cache_key(credentials: &Credentials, target: &UploadTarget, relative: &Path) -> String { + format!( + "{}|{}|{}|{}|{}|{}", + credentials.host, + credentials.user, + credentials.remote_path, + target.kind.as_str(), + target.remote_dir, + relative.to_string_lossy() + ) +} + +fn modified_seconds(path: &Path) -> std::io::Result { + Ok(fs::metadata(path)? + .modified()? + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs()) +} + +fn read_cache(path: &Path) -> ScpMtimeCache { + fs::read_to_string(path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + .unwrap_or_default() +} + +fn write_cache(path: &Path, cache: &ScpMtimeCache) -> EngineResult<()> { + atomic_write_str(path, &serde_json::to_string(cache)?)?; + Ok(()) +} + +fn run_command(program: &str, args: &[String]) -> Result<(), String> { + let output = Command::new(program) + .args(args) + .output() + .map_err(|error| format!("failed to start {program}: {error}"))?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { + format!("{program} exited with {}", output.status) + } else { + stderr + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn preferences(mode: SshMode) -> PublishingPreferences { + PublishingPreferences { + ssh_host: Some("example.test".into()), + ssh_user: Some("alice".into()), + ssh_remote_path: Some("/srv/blog".into()), + ssh_mode: mode, + } + } + + #[test] + fn rsync_uses_required_flags_and_excludes_media_sidecars() { + let dir = TempDir::new().unwrap(); + let cache = TempDir::new().unwrap(); + for subdir in ["html", "thumbnails", "media"] { + fs::create_dir_all(dir.path().join(subdir)).unwrap(); + fs::write(dir.path().join(subdir).join("file.txt"), "x").unwrap(); + } + let mut commands = Vec::<(String, Vec)>::new(); + let job = upload_site_with_runner( + dir.path(), + cache.path(), + &preferences(SshMode::Rsync), + &mut |program, args| { + commands.push((program.to_owned(), args.to_vec())); + Ok(()) + }, + &mut |_, _, _| {}, + ) + .unwrap(); + + assert_eq!(job.status, PublishJobStatus::Completed); + assert_eq!(job.completed_targets.len(), 3); + assert!(commands.iter().all(|(program, _)| program == "rsync")); + assert!(commands.iter().all(|(_, args)| { + args.contains(&"--update".into()) + && args.contains(&"--compress".into()) + && args.contains(&"--verbose".into()) + })); + assert_eq!( + commands + .iter() + .filter(|(_, args)| args.contains(&"--exclude=*.meta".into())) + .count(), + 1 + ); + } + + #[test] + fn scp_excludes_sidecars_and_skips_unchanged_files() { + let dir = TempDir::new().unwrap(); + let cache = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join("media/nested")).unwrap(); + fs::write(dir.path().join("media/nested/photo.jpg"), "image").unwrap(); + fs::write(dir.path().join("media/nested/photo.jpg.meta"), "metadata").unwrap(); + let prefs = preferences(SshMode::Scp); + + let mut first = Vec::<(String, Vec)>::new(); + upload_site_with_runner( + dir.path(), + cache.path(), + &prefs, + &mut |program, args| { + first.push((program.to_owned(), args.to_vec())); + Ok(()) + }, + &mut |_, _, _| {}, + ) + .unwrap(); + assert_eq!( + first.iter().filter(|(program, _)| program == "scp").count(), + 1 + ); + assert!( + first + .iter() + .all(|(_, args)| !args.iter().any(|arg| arg.ends_with(".meta"))) + ); + + let mut second = Vec::::new(); + upload_site_with_runner( + dir.path(), + cache.path(), + &prefs, + &mut |program, _| { + second.push(program.to_owned()); + Ok(()) + }, + &mut |_, _, _| {}, + ) + .unwrap(); + assert!(second.is_empty()); + } + + #[test] + fn failed_target_does_not_complete_job() { + let dir = TempDir::new().unwrap(); + let cache = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join("html")).unwrap(); + fs::write(dir.path().join("html/index.html"), "x").unwrap(); + let error = upload_site_with_runner( + dir.path(), + cache.path(), + &preferences(SshMode::Rsync), + &mut |_, _| Err("network down".into()), + &mut |_, _, _| {}, + ) + .unwrap_err(); + assert!(error.to_string().contains("network down")); + } +} diff --git a/crates/bds-core/src/engine/script.rs b/crates/bds-core/src/engine/script.rs index ce40a3b..b0fd1cc 100644 --- a/crates/bds-core/src/engine/script.rs +++ b/crates/bds-core/src/engine/script.rs @@ -32,13 +32,17 @@ pub fn create_script( let now = now_unix_ms(); let id = Uuid::new_v4().to_string(); + let entrypoint = entrypoint.unwrap_or(match &kind { + ScriptKind::Macro => "render", + ScriptKind::Utility | ScriptKind::Transform => "main", + }); let script = Script { id, project_id: project_id.to_string(), slug, title: title.to_string(), kind, - entrypoint: entrypoint.unwrap_or("render").to_string(), + entrypoint: entrypoint.to_string(), enabled: true, version: 1, file_path: String::new(), @@ -127,9 +131,7 @@ pub fn save_script(conn: &Connection, script_id: &str, content: &str) -> EngineR /// Validate Lua script syntax. Returns Ok(()) or a parse error message. pub fn validate_script_syntax(content: &str) -> Result<(), String> { - // Basic validation: balanced block structures - validate_lua_blocks(content)?; - Ok(()) + crate::scripting::validate(content) } /// Discover entrypoint function names from Lua source. @@ -262,41 +264,6 @@ fn script_kind_to_frontmatter(kind: &ScriptKind) -> String { } } -fn validate_lua_blocks(content: &str) -> Result<(), String> { - // Track block-level nesting for function/end, if/end, for/end, while/end, do/end - let mut depth: i32 = 0; - - for line in content.lines() { - let trimmed = line.trim(); - // Skip comments - if trimmed.starts_with("--") { - continue; - } - - // Count block openers - for word in trimmed.split_whitespace() { - match word { - "function" | "if" | "for" | "while" | "do" => depth += 1, - "end" | "end," | "end;" => { - depth -= 1; - if depth < 0 { - return Err("unexpected 'end' without matching block opener".to_string()); - } - } - _ => {} - } - } - - // Handle "then" on if lines (don't double-count) - // Handle inline "function() ... end" - } - - if depth > 0 { - return Err(format!("{depth} unclosed block(s) — missing 'end'")); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/bds-core/src/engine/script_rebuild.rs b/crates/bds-core/src/engine/script_rebuild.rs index 75bf84a..270f9ad 100644 --- a/crates/bds-core/src/engine/script_rebuild.rs +++ b/crates/bds-core/src/engine/script_rebuild.rs @@ -30,7 +30,7 @@ fn parse_script_kind(s: &str) -> Result { /// Rebuild scripts from the filesystem into the database. /// -/// Walks the `scripts/` directory for `*.lua` and `*.py` files, parses each +/// Walks the `scripts/` directory for `*.lua` files, parses each /// via frontmatter, and either creates or updates the corresponding DB row. /// Published scripts (those present on disk) have `content = None` in the DB. pub fn rebuild_scripts_from_filesystem( @@ -54,7 +54,7 @@ pub fn rebuild_scripts_from_filesystem( continue; } let ext = path.extension().and_then(|e| e.to_str()); - if ext != Some("lua") && ext != Some("py") { + if ext != Some("lua") { continue; } @@ -75,9 +75,9 @@ pub fn rebuild_scripts_from_filesystem( Ok(report) } -/// Rebuild a single script from a `.lua` or `.py` file. +/// Rebuild a single script from a `.lua` file. /// Returns `true` if created, `false` if updated. -fn rebuild_single_script( +pub(crate) fn rebuild_single_script( conn: &Connection, data_dir: &Path, project_id: &str, @@ -197,46 +197,6 @@ end assert_eq!(script.file_path, "scripts/my-macro.lua"); } - #[test] - fn rebuild_creates_script_from_py() { - let (db, dir) = setup(); - let scripts_dir = dir.path().join("scripts"); - fs::create_dir_all(&scripts_dir).unwrap(); - - let content = "\"\"\"\n\ ---- -id: \"py-script-1\" -slug: \"my-utility\" -title: \"My Utility\" -kind: \"utility\" -entrypoint: \"main\" -enabled: true -version: 1 -createdAt: \"2024-01-01T00:00:00.000Z\" -updatedAt: \"2024-01-01T00:00:00.000Z\" ---- -\"\"\" -def main(): - print(\"hello\") -"; - fs::write(scripts_dir.join("my-utility.py"), content).unwrap(); - - let report = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap(); - - assert_eq!(report.created, 1); - assert_eq!(report.updated, 0); - assert!(report.errors.is_empty(), "errors: {:?}", report.errors); - - let script = qs::get_script_by_id(db.conn(), "py-script-1").unwrap(); - assert_eq!(script.slug, "my-utility"); - assert_eq!(script.title, "My Utility"); - assert_eq!(script.kind, ScriptKind::Utility); - assert_eq!(script.entrypoint, "main"); - assert_eq!(script.status, ScriptStatus::Published); - assert!(script.content.is_none()); - assert_eq!(script.file_path, "scripts/my-utility.py"); - } - #[test] fn rebuild_updates_existing() { let (db, dir) = setup(); diff --git a/crates/bds-core/src/engine/template_rebuild.rs b/crates/bds-core/src/engine/template_rebuild.rs index ad77d8b..19956d5 100644 --- a/crates/bds-core/src/engine/template_rebuild.rs +++ b/crates/bds-core/src/engine/template_rebuild.rs @@ -78,7 +78,7 @@ pub fn rebuild_templates_from_filesystem( /// Rebuild a single template from a `.liquid` file. /// Returns `true` if created, `false` if updated. -fn rebuild_single_template( +pub(crate) fn rebuild_single_template( conn: &Connection, data_dir: &Path, project_id: &str, diff --git a/crates/bds-core/src/engine/validate_content.rs b/crates/bds-core/src/engine/validate_content.rs deleted file mode 100644 index d22c9d2..0000000 --- a/crates/bds-core/src/engine/validate_content.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! Validation functions for template (Liquid) and script (Lua/Python) content. -//! Per template.allium and script.allium, these are pre-publish gates. -//! -//! Current implementation: basic structural checks. -//! When a full Liquid parser crate is added, upgrade `validate_liquid` to -//! attempt a real parse. Similarly for `validate_script` with a Lua parser. - -/// Result of a validation check. -#[derive(Debug, Clone)] -pub struct ValidationResult { - pub valid: bool, - pub errors: Vec, -} - -impl ValidationResult { - pub fn ok() -> Self { - Self { - valid: true, - errors: Vec::new(), - } - } - - pub fn fail(errors: Vec) -> Self { - Self { - valid: false, - errors, - } - } -} - -/// Validate Liquid template content. -/// Per template.allium: "LiquidJS parser must accept the template". -/// Currently performs structural tag-matching. Upgrade to full liquid crate parse later. -pub fn validate_liquid(content: &str) -> ValidationResult { - let mut errors = Vec::new(); - - // Check for unmatched Liquid block tags - let block_tags = [ - "if", "unless", "for", "case", "capture", "comment", "raw", "paginate", "tablerow", - "block", "schema", - ]; - - for tag in &block_tags { - let open_pattern = format!("{{% {tag}"); - let close_pattern = format!("{{% end{tag}"); - let opens = content.matches(&open_pattern).count(); - let closes = content.matches(&close_pattern).count(); - if opens != closes { - errors.push(format!( - "Unmatched {{% {tag} %}}: {opens} opens, {closes} closes" - )); - } - } - - // Check for unclosed {{ }} - let double_opens = content.matches("{{").count(); - let double_closes = content.matches("}}").count(); - if double_opens != double_closes { - errors.push(format!( - "Unmatched {{}}: {double_opens} opens, {double_closes} closes" - )); - } - - // Check for unclosed {% %} - let tag_opens = content.matches("{%").count(); - let tag_closes = content.matches("%}").count(); - if tag_opens != tag_closes { - errors.push(format!( - "Unmatched {{% %}}: {tag_opens} opens, {tag_closes} closes" - )); - } - - if errors.is_empty() { - ValidationResult::ok() - } else { - ValidationResult::fail(errors) - } -} - -/// Validate script content (Lua syntax). -/// Per script.allium: "AST parsing must succeed". -/// Currently performs basic bracket-matching. Upgrade to full Lua parser later. -pub fn validate_script(content: &str) -> ValidationResult { - let mut errors = Vec::new(); - - // Basic bracket matching - let mut paren_depth: i32 = 0; - let mut brace_depth: i32 = 0; - let mut bracket_depth: i32 = 0; - - for (line_num, line) in content.lines().enumerate() { - // Skip comment lines - let trimmed = line.trim(); - if trimmed.starts_with("--") { - continue; - } - for ch in line.chars() { - match ch { - '(' => paren_depth += 1, - ')' => paren_depth -= 1, - '{' => brace_depth += 1, - '}' => brace_depth -= 1, - '[' => bracket_depth += 1, - ']' => bracket_depth -= 1, - _ => {} - } - if paren_depth < 0 { - errors.push(format!("Unexpected ')' at line {}", line_num + 1)); - paren_depth = 0; - } - if brace_depth < 0 { - errors.push(format!("Unexpected '}}' at line {}", line_num + 1)); - brace_depth = 0; - } - if bracket_depth < 0 { - errors.push(format!("Unexpected ']' at line {}", line_num + 1)); - bracket_depth = 0; - } - } - } - - if paren_depth != 0 { - errors.push(format!("Unclosed parentheses: depth {paren_depth}")); - } - if brace_depth != 0 { - errors.push(format!("Unclosed braces: depth {brace_depth}")); - } - if bracket_depth != 0 { - errors.push(format!("Unclosed brackets: depth {bracket_depth}")); - } - - // Check for unmatched Lua block keywords. - // In Lua, `for ... do ... end` and `while ... do ... end` each form a - // single block, so `do` on such lines must NOT be counted as a separate - // opener. - let mut block_opens: usize = 0; - let mut block_closes: usize = 0; - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("--") { - continue; - } - let words: Vec<&str> = trimmed.split_whitespace().collect(); - let has_for_or_while = words.iter().any(|w| matches!(*w, "for" | "while")); - for w in &words { - match *w { - "function" | "if" | "for" | "while" | "repeat" => block_opens += 1, - "do" if !has_for_or_while => block_opens += 1, - "end" | "until" => block_closes += 1, - _ => {} - } - } - } - - if block_opens > block_closes { - errors.push(format!( - "Possible unclosed blocks: {block_opens} openers, {block_closes} closers" - )); - } - - if errors.is_empty() { - ValidationResult::ok() - } else { - ValidationResult::fail(errors) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn valid_liquid_template() { - let content = r#" - -{% if user %} -

{{ user.name }}

-{% endif %} -"#; - let result = validate_liquid(content); - assert!(result.valid, "Errors: {:?}", result.errors); - } - - #[test] - fn unmatched_liquid_if() { - let content = "{% if true %}

Hello

"; - let result = validate_liquid(content); - assert!(!result.valid); - assert!(result.errors.iter().any(|e| e.contains("if"))); - } - - #[test] - fn unmatched_liquid_output() { - let content = "{{ user.name"; - let result = validate_liquid(content); - assert!(!result.valid); - } - - #[test] - fn valid_lua_script() { - let content = r#" -function render(ctx) - local result = {} - for i = 1, 10 do - result[i] = i * 2 - end - return result -end -"#; - let result = validate_script(content); - assert!(result.valid, "Errors: {:?}", result.errors); - } - - #[test] - fn unmatched_lua_parens() { - let content = "function test(\n local x = 1\nend"; - let result = validate_script(content); - assert!(!result.valid); - } -} diff --git a/crates/bds-core/src/engine/validate_translations.rs b/crates/bds-core/src/engine/validate_translations.rs index 2d82677..219ef5c 100644 --- a/crates/bds-core/src/engine/validate_translations.rs +++ b/crates/bds-core/src/engine/validate_translations.rs @@ -44,7 +44,7 @@ pub enum TranslationIssueKind { } /// Result of translation validation. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct TranslationValidationReport { pub db_issues: Vec, pub fs_issues: Vec, diff --git a/crates/bds-core/src/model/metadata.rs b/crates/bds-core/src/model/metadata.rs index 7158e4a..43f7da4 100644 --- a/crates/bds-core/src/model/metadata.rs +++ b/crates/bds-core/src/model/metadata.rs @@ -4,6 +4,23 @@ fn default_max_posts() -> i32 { 50 } +fn default_image_import_concurrency() -> i32 { + 4 +} + +fn deserialize_image_import_concurrency<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = serde_json::Value::deserialize(deserializer)?; + let parsed = value + .as_i64() + .map(|value| value as i32) + .or_else(|| value.as_str().and_then(|value| value.parse::().ok())) + .unwrap_or_else(default_image_import_concurrency); + Ok(parsed.clamp(1, 8)) +} + fn default_true() -> bool { true } @@ -22,6 +39,11 @@ pub struct ProjectMetadata { pub default_author: Option, #[serde(default = "default_max_posts")] pub max_posts_per_page: i32, + #[serde( + default = "default_image_import_concurrency", + deserialize_with = "deserialize_image_import_concurrency" + )] + pub image_import_concurrency: i32, #[serde(skip_serializing_if = "Option::is_none")] pub blogmark_category: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -41,6 +63,12 @@ impl ProjectMetadata { self.max_posts_per_page )); } + if !(1..=8).contains(&self.image_import_concurrency) { + return Err(format!( + "imageImportConcurrency must be 1..8, got {}", + self.image_import_concurrency + )); + } Ok(()) } } @@ -80,6 +108,7 @@ mod tests { main_language: Some("en".into()), default_author: None, max_posts_per_page: 50, + image_import_concurrency: 4, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, @@ -101,6 +130,7 @@ mod tests { let json = r#"{"name": "Minimal"}"#; let meta: ProjectMetadata = serde_json::from_str(json).unwrap(); assert_eq!(meta.max_posts_per_page, 50); + assert_eq!(meta.image_import_concurrency, 4); assert!(!meta.semantic_similarity_enabled); assert!(meta.blog_languages.is_empty()); } @@ -159,6 +189,7 @@ mod tests { main_language: None, default_author: None, max_posts_per_page: 50, + image_import_concurrency: 4, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, diff --git a/crates/bds-core/src/render/macros.rs b/crates/bds-core/src/render/macros.rs index 116464d..a25d1e6 100644 --- a/crates/bds-core/src/render/macros.rs +++ b/crates/bds-core/src/render/macros.rs @@ -52,7 +52,44 @@ fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option Some(render_vimeo(&args)), "photo_archive" => Some(render_photo_archive(&args, context)), "tag_cloud" => Some(render_tag_cloud(&args, context)), - _ => None, + _ => render_script_macro(name, &args, context), + } +} + +fn render_script_macro( + name: &str, + args: &HashMap, + context: &MacroRenderContext, +) -> Option { + let definition = context.roots.get("macro_scripts")?.get(name)?; + let source = definition.get("source")?.as_str()?; + let entrypoint = definition + .get("entrypoint") + .and_then(JsonValue::as_str) + .unwrap_or("render"); + let env = serde_json::json!({ + "isPreview": context.roots.get("is_preview").and_then(JsonValue::as_bool).unwrap_or(false), + "mainLanguage": context.roots.get("main_language").and_then(JsonValue::as_str).unwrap_or("en"), + "language": context.roots.get("language").and_then(JsonValue::as_str).unwrap_or("en"), + "languagePrefix": context.roots.get("language_prefix").and_then(JsonValue::as_str).unwrap_or(""), + "hook": "markdown", + "source": { "kind": if context.post_id.is_some() { "post" } else { "page" } }, + "translations": context.roots.get("translations").cloned().unwrap_or(JsonValue::Array(Vec::new())), + }); + let params = serde_json::to_value(args).ok()?; + match crate::scripting::execute_many( + source, + entrypoint, + &[params, env], + crate::scripting::ExecutionKind::Macro, + &crate::scripting::ExecutionControl::default(), + ) { + Ok(result) => Some(match result.value { + JsonValue::Null => String::new(), + JsonValue::String(value) => value, + value => value.to_string(), + }), + Err(_) => Some(String::new()), } } @@ -499,4 +536,27 @@ mod tests { markdown ); } + + #[test] + fn script_macro_receives_named_params_and_environment() { + let mut roots = serde_json::Map::new(); + roots.insert( + "macro_scripts".into(), + serde_json::json!({ + "notice": { + "source": "function render(params, env) return '' end", + "entrypoint": "render" + } + }), + ); + roots.insert("language".into(), serde_json::Value::String("de".into())); + let rendered = expand_builtin_macros( + "[[notice text=Hallo]]", + &MacroRenderContext { + roots, + post_id: Some("post-1".into()), + }, + ); + assert_eq!(rendered, ""); + } } diff --git a/crates/bds-core/src/render/page_renderer.rs b/crates/bds-core/src/render/page_renderer.rs index 227a0fb..de836ca 100644 --- a/crates/bds-core/src/render/page_renderer.rs +++ b/crates/bds-core/src/render/page_renderer.rs @@ -172,7 +172,19 @@ impl Filter for MarkdownFilter { fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap { let mut roots = JsonMap::new(); - for key in ["post", "post_tags", "tag_color_by_name", "project", "Tags"] { + for key in [ + "post", + "post_tags", + "tag_color_by_name", + "project", + "Tags", + "macro_scripts", + "language", + "language_prefix", + "main_language", + "is_preview", + "translations", + ] { if let Some(value) = runtime.try_get(&[ScalarCow::new(key)]) { roots.insert(key.to_string(), liquid_value_to_json(value.as_view())); } diff --git a/crates/bds-core/src/render/routes.rs b/crates/bds-core/src/render/routes.rs index 62290e9..11f4a11 100644 --- a/crates/bds-core/src/render/routes.rs +++ b/crates/bds-core/src/render/routes.rs @@ -492,6 +492,7 @@ mod tests { main_language: Some("en".into()), default_author: None, max_posts_per_page: 50, + image_import_concurrency: 4, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index 7264317..cced03c 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -11,13 +11,14 @@ 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, + CategorySettings, Media, Post, ProjectMetadata, ScriptKind, 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}; +use crate::util::frontmatter::{read_script_file, read_template_file, read_translation_file}; use crate::util::slugify; const STARTER_SINGLE_POST_TEMPLATE: &str = @@ -69,6 +70,7 @@ struct TemplateBundle { default_list_template: String, not_found_template: String, partials: HashMap, + macro_scripts: HashMap, } #[derive(Debug, Clone)] @@ -97,6 +99,24 @@ pub fn build_site_render_artifacts( project_id: &str, metadata: &ProjectMetadata, published_posts: &[(Post, String)], +) -> Result> { + build_site_render_artifacts_with_mode( + conn, + data_dir, + project_id, + metadata, + published_posts, + false, + ) +} + +fn build_site_render_artifacts_with_mode( + conn: &Connection, + data_dir: &Path, + project_id: &str, + metadata: &ProjectMetadata, + published_posts: &[(Post, String)], + is_preview: bool, ) -> Result> { let bundle = load_template_bundle(conn, data_dir, project_id)?; let main_language = main_language(metadata).to_string(); @@ -136,6 +156,7 @@ pub fn build_site_render_artifacts( &menu_items, &post_data_json_by_id, &bundle, + is_preview, ) .map(|html| SitePage { language: language.clone(), @@ -173,6 +194,7 @@ pub fn build_site_render_artifacts( &menu_items, &post_data_json_by_id, &bundle, + is_preview, )?; let canonical_path = build_canonical_post_path(&record.post, &language, &main_language); let relative_path = format!("{}/index.html", canonical_path.trim_start_matches('/')); @@ -202,8 +224,14 @@ pub fn build_preview_response( published_posts: &[(Post, String)], requested_path: &str, ) -> Result> { - let artifacts = - build_site_render_artifacts(conn, data_dir, project_id, metadata, published_posts)?; + let artifacts = build_site_render_artifacts_with_mode( + conn, + data_dir, + project_id, + metadata, + published_posts, + true, + )?; let normalized = normalize_request_path(requested_path); if let Some(page) = artifacts .pages @@ -239,6 +267,30 @@ fn load_template_bundle( let mut partials = starter_partials(); let mut default_list_template = STARTER_POST_LIST_TEMPLATE.to_string(); let mut not_found_template = STARTER_NOT_FOUND_TEMPLATE.to_string(); + let mut macro_scripts = HashMap::new(); + + for script in queries::script::list_scripts_by_project(conn, project_id).unwrap_or_default() { + if script.kind != ScriptKind::Macro + || !script.enabled + || script.entrypoint.trim().is_empty() + { + continue; + } + let source = if let Some(content) = script.content { + content + } else if script.file_path.is_empty() { + String::new() + } else { + fs::read_to_string(data_dir.join(&script.file_path)) + .ok() + .and_then(|raw| read_script_file(&raw).ok().map(|(_, body)| body)) + .unwrap_or_default() + }; + macro_scripts.insert( + script.slug, + json!({ "source": source, "entrypoint": script.entrypoint }), + ); + } for template in templates { if !template.enabled { @@ -315,6 +367,7 @@ fn load_template_bundle( default_list_template, not_found_template, partials, + macro_scripts, }) } @@ -557,6 +610,7 @@ fn render_list_route( menu_items: &[Value], post_data_json_by_id: &HashMap, bundle: &TemplateBundle, + is_preview: bool, ) -> Result> { let main_language = main_language(metadata); let canonical_map = canonical_post_path_by_slug(posts, language, main_language); @@ -569,6 +623,9 @@ fn render_list_route( let context = json!({ "language": language, "language_prefix": language_prefix(language, main_language), + "main_language": main_language, + "is_preview": is_preview, + "macro_scripts": bundle.macro_scripts, "html_theme_attribute": serde_json::Value::Null, "page_title": route.page_title, "pico_stylesheet_href": pico_stylesheet_href(metadata), @@ -626,6 +683,7 @@ fn render_post_route( menu_items: &[Value], post_data_json_by_id: &HashMap, bundle: &TemplateBundle, + is_preview: bool, ) -> Result> { let render_categories = category_settings .iter() @@ -683,6 +741,9 @@ fn render_post_route( let context = json!({ "language": language, "language_prefix": language_prefix(language, main_language), + "main_language": main_language, + "is_preview": is_preview, + "macro_scripts": bundle.macro_scripts, "page_title": record.post.title, "pico_stylesheet_href": pico_stylesheet_href(metadata), "html_theme_attribute": serde_json::Value::Null, diff --git a/crates/bds-core/src/scripting/mod.rs b/crates/bds-core/src/scripting/mod.rs index 3db0079..269b47e 100644 --- a/crates/bds-core/src/scripting/mod.rs +++ b/crates/bds-core/src/scripting/mod.rs @@ -1 +1,349 @@ -// Scripting (Lua) — stubs for M0, implemented in M5/Wave 6. +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use mlua::{ + Function, HookTriggers, Lua, LuaOptions, LuaSerdeExt, MultiValue, StdLib, Value, VmState, +}; +use serde_json::Value as JsonValue; + +const MACRO_TIMEOUT: Duration = Duration::from_secs(10); +const MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024; +const MAX_TOASTS_PER_SCRIPT: usize = 5; +const MAX_TOAST_LENGTH: usize = 300; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExecutionKind { + Macro, + Utility, + Transform, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ScriptProgress { + pub current: f64, + pub total: Option, + pub message: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ScriptExecution { + pub value: JsonValue, + pub output: Vec, + pub progress: Vec, + pub toasts: Vec, +} + +#[derive(Clone)] +pub struct ExecutionControl { + cancelled: Arc, +} + +impl Default for ExecutionControl { + fn default() -> Self { + Self { + cancelled: Arc::new(AtomicBool::new(false)), + } + } +} + +impl ExecutionControl { + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + } +} + +/// Parse Lua source with the same runtime used for execution. +pub fn validate(source: &str) -> Result<(), String> { + let lua = sandboxed_lua()?; + lua.load(source) + .set_name("script") + .into_function() + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +/// Execute a named Lua entrypoint with one JSON-compatible argument. +pub fn execute( + source: &str, + entrypoint: &str, + args: &JsonValue, + kind: ExecutionKind, + control: &ExecutionControl, +) -> Result { + execute_many( + source, + entrypoint, + std::slice::from_ref(args), + kind, + control, + ) +} + +/// Execute a named Lua entrypoint with positional JSON-compatible arguments. +pub fn execute_many( + source: &str, + entrypoint: &str, + args: &[JsonValue], + kind: ExecutionKind, + control: &ExecutionControl, +) -> Result { + if entrypoint.trim().is_empty() { + return Err("script entrypoint is empty".into()); + } + + let lua = sandboxed_lua()?; + let output = Arc::new(Mutex::new(Vec::::new())); + let progress = Arc::new(Mutex::new(Vec::::new())); + let toasts = Arc::new(Mutex::new(Vec::::new())); + install_host_api(&lua, &output, &progress, &toasts)?; + + let started = Instant::now(); + let cancelled = Arc::clone(&control.cancelled); + lua.set_hook( + HookTriggers::new().every_nth_instruction(5_000), + move |_lua, _debug| { + if cancelled.load(Ordering::Acquire) { + return Err(mlua::Error::RuntimeError("script cancelled".into())); + } + if kind == ExecutionKind::Macro && started.elapsed() >= MACRO_TIMEOUT { + return Err(mlua::Error::RuntimeError( + "macro exceeded 10 second timeout".into(), + )); + } + Ok(VmState::Continue) + }, + ) + .map_err(|error| error.to_string())?; + + lua.load(source) + .set_name("script") + .exec() + .map_err(|error| error.to_string())?; + let function: Function = lua + .globals() + .get(entrypoint) + .map_err(|_| format!("entrypoint '{entrypoint}' was not found"))?; + let arguments = args + .iter() + .map(|argument| lua.to_value(argument)) + .collect::>() + .map_err(|error| error.to_string())?; + let value: Value = function + .call(arguments) + .map_err(|error| error.to_string())?; + let value = lua.from_value(value).map_err(|error| error.to_string())?; + + Ok(ScriptExecution { + value, + output: output.lock().map_err(|_| "output sink poisoned")?.clone(), + progress: progress + .lock() + .map_err(|_| "progress sink poisoned")? + .clone(), + toasts: toasts.lock().map_err(|_| "toast sink poisoned")?.clone(), + }) +} + +fn sandboxed_lua() -> Result { + let libraries = StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8; + let lua = Lua::new_with(libraries, LuaOptions::default()).map_err(|error| error.to_string())?; + lua.set_memory_limit(MEMORY_LIMIT_BYTES) + .map_err(|error| error.to_string())?; + let globals = lua.globals(); + for dangerous in [ + "collectgarbage", + "debug", + "dofile", + "io", + "load", + "loadfile", + "os", + "package", + "require", + ] { + globals + .set(dangerous, Value::Nil) + .map_err(|error| error.to_string())?; + } + drop(globals); + Ok(lua) +} + +fn install_host_api( + lua: &Lua, + output: &Arc>>, + progress: &Arc>>, + toasts: &Arc>>, +) -> Result<(), String> { + let globals = lua.globals(); + + let print_output = Arc::clone(output); + globals + .set( + "print", + lua.create_function(move |_lua, values: MultiValue| { + let line = values + .iter() + .map(lua_value_text) + .collect::>() + .join("\t"); + print_output.lock().unwrap().push(line); + Ok(()) + }) + .map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + + let bds = lua.create_table().map_err(|error| error.to_string())?; + let app = lua.create_table().map_err(|error| error.to_string())?; + + let log_output = Arc::clone(output); + app.set( + "log", + lua.create_function(move |_lua, values: MultiValue| { + let line = values + .iter() + .map(lua_value_text) + .collect::>() + .join("\t"); + log_output.lock().unwrap().push(line); + Ok(()) + }) + .map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + + let progress_sink = Arc::clone(progress); + app.set( + "progress", + lua.create_function( + move |_lua, (current, total, message): (f64, Option, Option)| { + progress_sink.lock().unwrap().push(ScriptProgress { + current, + total, + message, + }); + Ok(()) + }, + ) + .map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + + let toast_sink = Arc::clone(toasts); + app.set( + "toast", + lua.create_function(move |_lua, message: String| { + let mut sink = toast_sink.lock().unwrap(); + if sink.len() < MAX_TOASTS_PER_SCRIPT { + sink.push(message.chars().take(MAX_TOAST_LENGTH).collect()); + } + Ok(()) + }) + .map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + + bds.set("app", app).map_err(|error| error.to_string())?; + globals.set("bds", bds).map_err(|error| error.to_string())?; + Ok(()) +} + +fn lua_value_text(value: &Value) -> String { + match value { + Value::Nil => "nil".into(), + Value::Boolean(value) => value.to_string(), + Value::Integer(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => value.to_string_lossy(), + other => format!("{other:?}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn executes_entrypoint_and_routes_output_and_progress() { + let result = execute( + r#" + function main(input) + print("starting", input.name) + bds.app.log("working") + bds.app.progress(1, 2, "half") + return { title = input.name, complete = true } + end + "#, + "main", + &json!({"name": "Example"}), + ExecutionKind::Utility, + &ExecutionControl::default(), + ) + .unwrap(); + + assert_eq!(result.value["title"], "Example"); + assert_eq!(result.value["complete"], true); + assert_eq!(result.output, vec!["starting\tExample", "working"]); + assert_eq!(result.progress[0].message.as_deref(), Some("half")); + } + + #[test] + fn sandbox_hides_ambient_host_capabilities() { + let result = execute( + "function main(_) return { io = io, os = os, package = package, load = load } end", + "main", + &JsonValue::Null, + ExecutionKind::Utility, + &ExecutionControl::default(), + ) + .unwrap(); + assert_eq!(result.value, serde_json::json!({})); + } + + #[test] + fn cancellation_stops_managed_execution() { + let control = ExecutionControl::default(); + control.cancel(); + let error = execute( + "function main(_) while true do end end", + "main", + &JsonValue::Null, + ExecutionKind::Utility, + &control, + ) + .unwrap_err(); + assert!(error.contains("cancelled")); + } + + #[test] + fn validates_with_real_lua_parser() { + assert!(validate("function main() return 1 end").is_ok()); + assert!(validate("function main( return 1 end").is_err()); + } + + #[test] + fn enforces_per_script_toast_budget_and_length() { + let result = execute( + r#" + function main(_) + for i = 1, 8 do bds.app.toast(string.rep("x", 400)) end + end + "#, + "main", + &JsonValue::Null, + ExecutionKind::Transform, + &ExecutionControl::default(), + ) + .unwrap(); + assert_eq!(result.toasts.len(), 5); + assert!( + result + .toasts + .iter() + .all(|toast| toast.chars().count() == 300) + ); + } +} diff --git a/crates/bds-core/src/util/frontmatter.rs b/crates/bds-core/src/util/frontmatter.rs index 8643b97..d201082 100644 --- a/crates/bds-core/src/util/frontmatter.rs +++ b/crates/bds-core/src/util/frontmatter.rs @@ -17,26 +17,6 @@ pub fn split_frontmatter(input: &str) -> Option<(&str, &str)> { Some((yaml, body)) } -/// Split content at Python docstring `"""` delimiters (for legacy .py scripts). -/// The YAML frontmatter is inside: `"""\n---\n{yaml}\n---\n"""` -pub fn split_docstring_frontmatter(input: &str) -> Option<(&str, &str)> { - let trimmed = input.trim_start_matches('\u{feff}'); - if !trimmed.starts_with("\"\"\"") { - return None; - } - let after_open = &trimmed[3..]; - let after_open = after_open.strip_prefix('\n').unwrap_or(after_open); - // Find closing """ - let close_pos = after_open.find("\"\"\"")?; - let inside = &after_open[..close_pos].trim(); - // Inside should be ---\n{yaml}\n--- - let yaml_content = split_frontmatter(inside)?; - let body_start = close_pos + 3; - let rest = &after_open[body_start..]; - let body = rest.strip_prefix('\n').unwrap_or(rest); - Some((yaml_content.0, body)) -} - /// Format frontmatter + body into a complete file string. pub fn format_frontmatter(yaml: &str, body: &str) -> String { format!("---\n{yaml}\n---\n{body}") @@ -485,14 +465,8 @@ impl ScriptFrontmatter { } } -/// Read a script file. Supports both `---` (Lua) and `"""` (Python) delimiters. +/// Read a Lua script file with standard YAML frontmatter. pub fn read_script_file(content: &str) -> Result<(ScriptFrontmatter, String), String> { - // Try docstring format first (Python scripts) - if let Some((yaml, body)) = split_docstring_frontmatter(content) { - let fm = ScriptFrontmatter::from_yaml(yaml)?; - return Ok((fm, body.to_string())); - } - // Fall back to standard --- format (Lua scripts) let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?; let fm = ScriptFrontmatter::from_yaml(yaml)?; Ok((fm, body.to_string())) @@ -577,14 +551,6 @@ mod tests { assert!(split_frontmatter("no frontmatter here").is_none()); } - #[test] - fn split_docstring() { - let input = "\"\"\"\n---\nfoo: bar\n---\n\"\"\"\nbody\n"; - let (yaml, body) = split_docstring_frontmatter(input).unwrap(); - assert_eq!(yaml, "foo: bar"); - assert_eq!(body, "body\n"); - } - #[test] fn parse_esmeralda() { let path = fixture_dir().join("posts/2005/11/esmeralda.md"); @@ -822,34 +788,6 @@ mod tests { ); } - // --- Script frontmatter tests --- - - #[test] - fn parse_fixture_script_bgg() { - let path = fixture_dir().join("scripts/bgg_link.py"); - let content = fs::read_to_string(path).unwrap(); - let (fm, body) = read_script_file(&content).unwrap(); - assert_eq!(fm.id, "2b393cae-84b9-426f-b4cf-4902aea79d7d"); - assert_eq!(fm.slug, "bgg_link"); - assert_eq!(fm.title, "bgg link"); - assert_eq!(fm.kind, "transform"); - assert_eq!(fm.entrypoint, "normalize_blogmark"); - assert!(fm.enabled); - assert_eq!(fm.version, 12); - assert!(body.contains("def normalize_blogmark")); - } - - #[test] - fn parse_fixture_script_test() { - let path = fixture_dir().join("scripts/test_script.py"); - let content = fs::read_to_string(path).unwrap(); - let (fm, body) = read_script_file(&content).unwrap(); - assert_eq!(fm.slug, "test_script"); - assert_eq!(fm.kind, "utility"); - assert_eq!(fm.entrypoint, "main"); - assert!(body.contains("print")); - } - #[test] fn template_frontmatter_roundtrip() { let fm = TemplateFrontmatter { diff --git a/crates/bds-core/tests/m4_generation_engine.rs b/crates/bds-core/tests/m4_generation_engine.rs index 3bfd913..1026201 100644 --- a/crates/bds-core/tests/m4_generation_engine.rs +++ b/crates/bds-core/tests/m4_generation_engine.rs @@ -36,6 +36,7 @@ fn make_metadata() -> ProjectMetadata { main_language: Some("en".into()), default_author: None, max_posts_per_page: 50, + image_import_concurrency: 4, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, diff --git a/crates/bds-editor/src/buffer.rs b/crates/bds-editor/src/buffer.rs index bbf0278..6cfd845 100644 --- a/crates/bds-editor/src/buffer.rs +++ b/crates/bds-editor/src/buffer.rs @@ -156,6 +156,57 @@ impl EditorBuffer { } } + /// Select the next exact match after the current selection/cursor, wrapping once. + pub fn find_next(&mut self, query: &str) -> bool { + if query.is_empty() { + return false; + } + let text = self.text(); + let start_char = self + .selection + .as_ref() + .map(|selection| { + let (_, end) = selection.ordered(); + self.pos_to_char_idx(end.0, end.1) + }) + .unwrap_or_else(|| self.cursor_char_idx()); + let start_byte = text + .char_indices() + .nth(start_char) + .map(|(index, _)| index) + .unwrap_or(text.len()); + let match_byte = text[start_byte..] + .find(query) + .map(|offset| start_byte + offset) + .or_else(|| text[..start_byte].find(query)); + let Some(match_byte) = match_byte else { + return false; + }; + let start = text[..match_byte].chars().count(); + let end = start + query.chars().count(); + let start_pos = self.char_idx_to_pos(start); + let end_pos = self.char_idx_to_pos(end); + self.set_selection(start_pos.0, start_pos.1, end_pos.0, end_pos.1); + self.cursor_line = end_pos.0; + self.cursor_col = end_pos.1; + true + } + + /// Replace every exact match and return the replacement count. + pub fn replace_all(&mut self, query: &str, replacement: &str) -> usize { + if query.is_empty() { + return 0; + } + let count = self.text().matches(query).count(); + if count == 0 { + return 0; + } + self.select_all(); + let replaced = self.text().replace(query, replacement); + self.insert(&replaced); + count + } + /// Delete the selected text and place cursor at start of selection. /// Returns the deleted text. pub fn delete_selection(&mut self) -> String { @@ -965,6 +1016,25 @@ mod tests { assert_eq!(buf.text(), "hi world"); } + #[test] + fn find_next_selects_and_wraps_matches() { + let mut buffer = EditorBuffer::new("one two one"); + assert!(buffer.find_next("one")); + assert_eq!(buffer.selected_text(), "one"); + assert!(buffer.find_next("one")); + assert_eq!(buffer.selection().unwrap().ordered(), ((0, 8), (0, 11))); + assert!(buffer.find_next("one")); + assert_eq!(buffer.selection().unwrap().ordered(), ((0, 0), (0, 3))); + } + + #[test] + fn replace_all_updates_buffer_and_reports_count() { + let mut buffer = EditorBuffer::new("one two one"); + assert_eq!(buffer.replace_all("one", "three"), 2); + assert_eq!(buffer.text(), "three two three"); + assert!(buffer.is_dirty()); + } + #[test] fn dirty_tracking() { let mut buf = EditorBuffer::new("hello"); diff --git a/crates/bds-ui/Cargo.toml b/crates/bds-ui/Cargo.toml index c06b010..a327c51 100644 --- a/crates/bds-ui/Cargo.toml +++ b/crates/bds-ui/Cargo.toml @@ -18,6 +18,7 @@ open = { workspace = true } anyhow = { workspace = true } tokio = { workspace = true } uuid = { workspace = true } +rayon = { workspace = true } wry = "0.55" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index ecd6491..129f2ca 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -6,6 +6,7 @@ use base64::Engine as _; use bds_core::db::DbQueryError as SqlError; use chrono::Datelike; use iced::{Element, Subscription, Task, window}; +use rayon::prelude::*; use serde_json::json; use uuid::Uuid; @@ -34,6 +35,7 @@ use crate::views::{ DashboardTimelineMonth, }, media_editor::{LinkedPostItem, MediaEditorMsg, MediaEditorState}, + metadata_diff::MetadataDiffState, modal, post_editor::{LinkedMediaItem, PostEditorMsg, PostEditorState, ResolvedPostLink}, script_editor::{ScriptEditorMsg, ScriptEditorState}, @@ -78,6 +80,7 @@ pub enum Message { SwitchProject(String), ProjectSwitched(Result), RequestCreateProject, + RequestOpenProject, CreateProject { name: String, data_path: Option, @@ -88,7 +91,20 @@ pub enum Message { // Dialogs FolderPicked(Option), + ProjectFolderPicked(Option), MediaFilesPicked(Option>), + MediaImportFinished { + imported: Vec, + errors: Vec, + }, + MediaReplacementPicked { + media_id: String, + path: Option, + }, + MediaReplacementFinished { + media_id: String, + result: Result, String>, + }, // Tasks TaskTick, @@ -96,6 +112,7 @@ pub enum Message { // macOS lifecycle FileOpenRequested(PathBuf), UrlOpenRequested(String), + BlogmarkImported(Result), MainWindowLoaded(Option), EmbeddedPreviewReady(Result<(), String>), @@ -136,6 +153,11 @@ pub enum Message { ShowModal(modal::ModalState), DismissModal, ConfirmModal(modal::ConfirmAction), + FindQueryChanged(String), + ReplaceQueryChanged(String), + FindNext, + ReplaceCurrent, + ReplaceAll, ToggleAiSuggestionField(usize, bool), ApplyAiSuggestions(modal::AiEntityTarget, Vec), @@ -144,9 +166,18 @@ pub enum Message { ReindexText, RegenerateCalendar, ValidateTranslations, + TranslationValidationLoaded( + Result, + ), ValidateMedia, GenerateSite, RunMetadataDiff, + MetadataDiffLoaded(Result), + RepairMetadataDiffItem { + index: usize, + direction: engine::metadata_diff::RepairDirection, + }, + MetadataDiffItemRepaired(Result<(), String>), RunSiteValidation, ApplySiteValidation, EngineTaskDone { @@ -485,6 +516,22 @@ fn default_post_editor_mode(settings_state: Option<&SettingsViewState>) -> &str .unwrap_or("markdown") } +fn replace_current_in_buffer( + buffer: &mut bds_editor::EditorBuffer, + query: &str, + replacement: &str, +) -> bool { + if query.is_empty() { + return false; + } + if buffer.selected_text() != query && !buffer.find_next(query) { + return false; + } + buffer.insert(replacement); + let _ = buffer.find_next(query); + true +} + fn referenced_media_ids(content: &str) -> Vec { let mut ids = Vec::new(); let mut rest = content; @@ -728,6 +775,8 @@ pub struct BdsApp { settings_state: Option, dashboard_state: Option, site_validation_state: SiteValidationState, + metadata_diff_state: MetadataDiffState, + translation_validation_state: crate::views::translation_validation::TranslationValidationState, } // ─────────────────────────────────────────────────────────── @@ -777,10 +826,9 @@ impl BdsApp { // If no projects exist, ensure the default project per spec let init_task = if projects.is_empty() { if let Some(ref db) = db { - let default_data = dirs::data_dir() + let default_data = dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("bds") - .join("projects") .join("my-blog"); match engine::project::ensure_default_project(db.conn(), Some(&default_data)) { Ok(project) => Task::done(Message::ProjectsLoaded(vec![project])), @@ -863,6 +911,8 @@ impl BdsApp { settings_state: None, dashboard_state: None, site_validation_state: SiteValidationState::default(), + metadata_diff_state: MetadataDiffState::default(), + translation_validation_state: Default::default(), }, init_task, ) @@ -924,6 +974,8 @@ impl BdsApp { settings_state: None, dashboard_state: None, site_validation_state: SiteValidationState::default(), + metadata_diff_state: MetadataDiffState::default(), + translation_validation_state: Default::default(), } } @@ -1150,6 +1202,10 @@ impl BdsApp { Message::RequestCreateProject => { crate::platform::dialog::pick_folder(t(self.ui_locale, "dialog.selectFolder")) } + Message::RequestOpenProject => crate::platform::dialog::pick_project_folder(t( + self.ui_locale, + "dialog.openProject", + )), Message::CreateProject { name, data_path } => { if let Some(ref db) = self.db { let path_str = data_path.as_ref().map(|p| p.to_string_lossy().to_string()); @@ -1239,10 +1295,188 @@ impl BdsApp { } Task::none() } - Message::MediaFilesPicked(_paths) => { - // Media import will be expanded in later milestones + Message::ProjectFolderPicked(path) => { + if let (Some(path), Some(db)) = (path, self.db.as_ref()) { + match engine::project::open_project(db.conn(), &path) { + Ok(project) => { + let _ = engine::project::set_active_project(db.conn(), &project.id); + self.projects = + engine::project::list_projects(db.conn()).unwrap_or_default(); + self.active_project = Some(project.clone()); + self.data_dir = project.data_path.as_ref().map(PathBuf::from); + self.preview_session = None; + self.hide_embedded_preview(); + self.notify( + ToastLevel::Success, + &tw( + self.ui_locale, + "projectSelector.toast.opened", + &[("name", &project.name)], + ), + ); + self.sync_menu_state(); + return self.refresh_counts(); + } + Err(error) => self.notify( + ToastLevel::Error, + &tw( + self.ui_locale, + "projectSelector.toast.openFailed", + &[("error", &error.to_string())], + ), + ), + } + } Task::none() } + Message::MediaFilesPicked(paths) => { + let (Some(paths), Some(project), Some(data_dir)) = + (paths, self.active_project.as_ref(), self.data_dir.as_ref()) + else { + return Task::none(); + }; + let db_path = self.db_path.clone(); + let project_id = project.id.clone(); + let data_dir = data_dir.clone(); + let language = self.content_language.clone(); + let concurrency = engine::meta::read_project_json(&data_dir) + .map(|metadata| metadata.image_import_concurrency.clamp(1, 8) as usize) + .unwrap_or(4); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let pool = match rayon::ThreadPoolBuilder::new() + .num_threads(concurrency) + .build() + { + Ok(pool) => pool, + Err(error) => return (Vec::new(), vec![error.to_string()]), + }; + let results = pool.install(|| { + paths + .into_par_iter() + .map(|path| { + let original_name = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "image".to_string()); + let db = Database::open(&db_path).map_err(|error| { + format!("{}: {error}", path.display()) + })?; + engine::media::import_media( + db.conn(), + &data_dir, + &project_id, + &path, + &original_name, + None, + None, + None, + None, + Some(&language), + Vec::new(), + ) + .map_err(|error| format!("{}: {error}", path.display())) + }) + .collect::>>() + }); + let mut imported = Vec::new(); + let mut errors = Vec::new(); + for result in results { + match result { + Ok(media) => imported.push(media), + Err(error) => errors.push(error), + } + } + (imported, errors) + }) + .await + .unwrap_or_else(|error| (Vec::new(), vec![error.to_string()])) + }, + |(imported, errors)| Message::MediaImportFinished { imported, errors }, + ) + } + Message::MediaImportFinished { imported, errors } => { + if !imported.is_empty() { + self.sidebar_view = SidebarView::Media; + self.notify( + ToastLevel::Success, + &tw( + self.ui_locale, + "media.toast.imported", + &[("count", &imported.len().to_string())], + ), + ); + } + if !errors.is_empty() { + self.notify( + ToastLevel::Error, + &tw( + self.ui_locale, + "media.toast.importFailed", + &[("error", &errors.join("; "))], + ), + ); + } + self.refresh_sidebar_media() + } + Message::MediaReplacementPicked { media_id, path } => { + let (Some(path), Some(data_dir)) = (path, self.data_dir.as_ref()) else { + return Task::none(); + }; + let db_path = self.db_path.clone(); + let data_dir = data_dir.clone(); + let result_media_id = media_id.clone(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + engine::media::replace_media_file( + db.conn(), + &data_dir, + &media_id, + &path, + ) + .map_err(|error| error.to_string()) + }) + .await + .map_err(|error| error.to_string())? + }, + move |result| Message::MediaReplacementFinished { + media_id: result_media_id.clone(), + result, + }, + ) + } + Message::MediaReplacementFinished { media_id, result } => { + match result { + Ok(Some(media)) => { + if let Some(state) = self.media_editors.get_mut(&media_id) { + state.size = media.size; + state.width = media.width; + state.height = media.height; + state.updated_at = media.updated_at; + } + self.notify( + ToastLevel::Success, + &t(self.ui_locale, "media.toast.replaced"), + ); + } + Ok(None) => self.notify( + ToastLevel::Info, + &t(self.ui_locale, "media.toast.unchanged"), + ), + Err(error) => self.notify( + ToastLevel::Error, + &tw( + self.ui_locale, + "media.toast.replaceFailed", + &[("error", &error)], + ), + ), + } + self.refresh_sidebar_media() + } Message::MainWindowLoaded(window_id) => { self.main_window_id = window_id; if self.active_post_uses_embedded_preview() { @@ -1280,14 +1514,135 @@ impl BdsApp { } // ── macOS lifecycle ── - Message::FileOpenRequested(_path) => { - // File open handling deferred to later milestones - Task::none() + Message::FileOpenRequested(path) => { + let folder = if path.is_dir() { + Some(path) + } else if path.file_name().is_some_and(|name| name == "project.json") + && path.parent().is_some_and(|parent| parent.ends_with("meta")) + { + path.parent().and_then(Path::parent).map(PathBuf::from) + } else { + None + }; + folder.map_or_else(Task::none, |path| { + Task::done(Message::ProjectFolderPicked(Some(path))) + }) } Message::UrlOpenRequested(url) => { - let _ = open::that(url); - Task::none() + let candidate = match engine::blogmark::parse_deep_link(&url) { + Ok(candidate) => candidate, + Err(error) => { + self.notify( + ToastLevel::Error, + &tw( + self.ui_locale, + "blogmark.invalid", + &[("error", &error.to_string())], + ), + ); + return Task::none(); + } + }; + let target = if let Some(project_id) = candidate.project_id.as_deref() { + let Some(project) = self + .projects + .iter() + .find(|project| project.id == project_id) + .cloned() + else { + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "blogmark.unknownProject"), + ); + return Task::none(); + }; + project + } else if let Some(project) = self.active_project.clone() { + project + } else { + self.notify( + ToastLevel::Warning, + &t(self.ui_locale, "blogmark.noProject"), + ); + return Task::none(); + }; + let Some(data_dir) = target.data_path.as_ref().map(PathBuf::from) else { + self.notify(ToastLevel::Error, &t(self.ui_locale, "blogmark.noProject")); + return Task::none(); + }; + if self.active_project.as_ref().map(|project| &project.id) != Some(&target.id) { + if let Some(db) = self.db.as_ref() + && let Err(error) = + engine::project::set_active_project(db.conn(), &target.id) + { + self.notify(ToastLevel::Error, &error.to_string()); + return Task::none(); + } + self.active_project = Some(target.clone()); + self.data_dir = Some(data_dir.clone()); + self.preview_session = None; + self.hide_embedded_preview(); + if let Ok(meta) = engine::meta::read_project_json(&data_dir) { + let main_language = meta.main_language.unwrap_or_else(|| "en".into()); + self.content_language = main_language.clone(); + self.blog_languages = meta.blog_languages; + if !self.blog_languages.contains(&main_language) { + self.blog_languages.insert(0, main_language); + } + } + self.sync_menu_state(); + } + let db_path = self.db_path.clone(); + let project_id = target.id; + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + engine::blogmark::receive_deep_link( + db.conn(), + &data_dir, + &project_id, + &url, + ) + .map_err(|error| error.to_string()) + }) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) + }, + Message::BlogmarkImported, + ) } + Message::BlogmarkImported(result) => match result { + Ok(result) => { + for message in result.toasts { + self.notify(ToastLevel::Info, &message); + } + for error in result.transform_errors { + self.add_output(&error); + } + let tab = Tab { + id: result.post.id.clone(), + tab_type: TabType::Post, + title: result.post.title.clone(), + is_transient: false, + is_dirty: false, + }; + let index = tabs::open_tab(&mut self.tabs, tab); + self.active_tab = self.tabs.get(index).map(|tab| tab.id.clone()); + if let Some(tab) = self.tabs.get(index).cloned() { + self.load_editor_for_tab(&tab); + } + self.notify(ToastLevel::Success, &t(self.ui_locale, "blogmark.imported")); + self.refresh_counts() + } + Err(error) => { + self.notify( + ToastLevel::Error, + &tw(self.ui_locale, "blogmark.failed", &[("error", &error)]), + ); + Task::none() + } + }, // ── Panel ── Message::SetPanelTab(tab) => { @@ -1373,39 +1728,28 @@ impl BdsApp { ) } Message::ValidateTranslations => { - let locale = self.ui_locale; self.open_singleton_tab( TabType::TranslationValidation, "tabBar.translationValidation", ); - self.spawn_engine_task( - "engine.validateTranslationsStarted", - move |db_path, project_id, data_dir, tm, tid| { - let db = Database::open(&db_path).map_err(|e| e.to_string())?; - let meta = engine::meta::read_project_json(&data_dir) - .map_err(|e| e.to_string())?; - let main_lang = meta.main_language.as_deref().unwrap_or("en"); - let blog_langs = meta.blog_languages.clone(); - let tm2 = Arc::clone(&tm); - let on_item: engine::validate_translations::ItemProgressFn = - Box::new(move |current, total, name| { - let pct = if total > 0 { - current as f32 / total as f32 - } else { - 1.0 - }; - let msg = tw( - locale, - "engine.checkingItem", - &[ - ("current", ¤t.to_string()), - ("total", &total.to_string()), - ("name", name), - ], - ); - tm2.report_progress(tid, Some(pct), Some(msg)); - }); - let report = + let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else { + return Task::none(); + }; + self.translation_validation_state.is_running = true; + self.translation_validation_state.error_message = None; + let db_path = self.db_path.clone(); + let project_id = project.id.clone(); + let data_dir = data_dir.clone(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + let meta = engine::meta::read_project_json(&data_dir) + .map_err(|e| e.to_string())?; + let main_lang = meta.main_language.as_deref().unwrap_or("en"); + let blog_langs = meta.blog_languages.clone(); + let on_item: engine::validate_translations::ItemProgressFn = + Box::new(move |_current, _total, _name| {}); engine::validate_translations::validate_translations_with_progress( db.conn(), &data_dir, @@ -1414,15 +1758,25 @@ impl BdsApp { main_lang, Some(on_item), ) - .map_err(|e| e.to_string())?; - Ok(format!( - "db_issues={}, fs_issues={}", - report.db_issues.len(), - report.fs_issues.len() - )) + .map_err(|e| e.to_string()) + }) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) }, + Message::TranslationValidationLoaded, ) } + Message::TranslationValidationLoaded(result) => { + self.translation_validation_state.is_running = false; + match result { + Ok(report) => { + self.translation_validation_state.report = Some(report); + self.translation_validation_state.error_message = None; + } + Err(error) => self.translation_validation_state.error_message = Some(error), + } + Task::none() + } Message::ValidateMedia => { let locale = self.ui_locale; self.spawn_engine_task( @@ -1516,13 +1870,36 @@ impl BdsApp { Some(0.85), Some(t(locale, "engine.writingGeneratedFiles")), ); - let report = engine::generation::generate_starter_site( + let progress_start = 0.70_f32; + let progress_span = 0.28_f32; + let task_manager = Arc::clone(&tm); + let report = engine::generation::generate_starter_site_with_progress( db.conn(), &output_dir, &project_id, &metadata, &sources, &main_language, + move |current, total, path| { + let fraction = if total == 0 { + 1.0 + } else { + current as f32 / total as f32 + }; + task_manager.report_progress( + tid, + Some(progress_start + fraction * progress_span), + Some(tw( + locale, + "engine.generatedPage", + &[ + ("url", path), + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ], + )), + ); + }, ) .map_err(|e| e.to_string())?; tm.report_progress( @@ -1541,8 +1918,73 @@ impl BdsApp { } Message::RunMetadataDiff => { self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff"); + self.start_metadata_diff() + } + Message::MetadataDiffLoaded(result) => { + self.metadata_diff_state.is_running = false; + match result { + Ok(report) => { + self.metadata_diff_state.report = Some(report); + self.metadata_diff_state.error_message = None; + } + Err(error) => self.metadata_diff_state.error_message = Some(error), + } Task::none() } + Message::RepairMetadataDiffItem { index, direction } => { + let Some(item) = self + .metadata_diff_state + .report + .as_ref() + .and_then(|report| report.diffs.get(index)) + .cloned() + else { + return Task::none(); + }; + let (Some(project), Some(data_dir)) = + (self.active_project.as_ref(), self.data_dir.as_ref()) + else { + return Task::none(); + }; + self.metadata_diff_state.is_repairing = true; + let db_path = self.db_path.clone(); + let project_id = project.id.clone(); + let data_dir = data_dir.clone(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + engine::metadata_diff::repair_metadata_diff_item( + db.conn(), + &data_dir, + &project_id, + direction, + &item, + ) + .map_err(|error| error.to_string()) + }) + .await + .map_err(|error| error.to_string())? + }, + Message::MetadataDiffItemRepaired, + ) + } + Message::MetadataDiffItemRepaired(result) => { + self.metadata_diff_state.is_repairing = false; + match result { + Ok(()) => { + self.notify( + ToastLevel::Success, + &t(self.ui_locale, "metadataDiff.repaired"), + ); + self.start_metadata_diff() + } + Err(error) => { + self.notify(ToastLevel::Error, &error); + Task::none() + } + } + } Message::RunSiteValidation => self.start_site_validation(), Message::ApplySiteValidation => self.apply_site_validation(), Message::EngineTaskDone { @@ -1848,6 +2290,60 @@ impl BdsApp { } } } + Message::FindQueryChanged(value) => { + if let Some(modal::ModalState::FindReplace { query, .. }) = + self.active_modal.as_mut() + { + *query = value; + } + Task::none() + } + Message::ReplaceQueryChanged(value) => { + if let Some(modal::ModalState::FindReplace { replacement, .. }) = + self.active_modal.as_mut() + { + *replacement = value; + } + Task::none() + } + Message::FindNext => { + let query = match &self.active_modal { + Some(modal::ModalState::FindReplace { query, .. }) => query.clone(), + _ => return Task::none(), + }; + if !self.find_next_in_active_editor(&query) && !query.is_empty() { + self.notify(ToastLevel::Info, &t(self.ui_locale, "find.noMatches")); + } + Task::none() + } + Message::ReplaceCurrent => { + let (query, replacement) = match &self.active_modal { + Some(modal::ModalState::FindReplace { + query, replacement, .. + }) => (query.clone(), replacement.clone()), + _ => return Task::none(), + }; + self.replace_current_in_active_editor(&query, &replacement); + Task::none() + } + Message::ReplaceAll => { + let (query, replacement) = match &self.active_modal { + Some(modal::ModalState::FindReplace { + query, replacement, .. + }) => (query.clone(), replacement.clone()), + _ => return Task::none(), + }; + let count = self.replace_all_in_active_editor(&query, &replacement); + self.notify( + ToastLevel::Info, + &tw( + self.ui_locale, + "find.replacedCount", + &[("count", &count.to_string())], + ), + ); + Task::none() + } Message::ToggleAiSuggestionField(index, accepted) => { if let Some(modal::ModalState::AISuggestions { fields, .. }) = self.active_modal.as_mut() @@ -2290,6 +2786,7 @@ impl BdsApp { post_id: String, }, Save(String), + Replace(String), Delete { tab_id: String, name: String, @@ -2383,6 +2880,9 @@ impl BdsApp { MediaEditorMsg::Save => { deferred = DeferredMediaAction::Save(tab_id.clone()); } + MediaEditorMsg::ReplaceFile => { + deferred = DeferredMediaAction::Replace(tab_id.clone()); + } MediaEditorMsg::Delete => { deferred = DeferredMediaAction::Delete { tab_id: tab_id.clone(), @@ -2442,6 +2942,13 @@ impl BdsApp { self.unlink_media_from_post(&media_id, &post_id) } DeferredMediaAction::Save(tab_id) => self.save_media_editor(&tab_id), + DeferredMediaAction::Replace(media_id) => { + crate::platform::dialog::pick_media_replacement( + media_id, + t(self.ui_locale, "dialog.replaceMedia"), + t(self.ui_locale, "dialog.imageFilter"), + ) + } DeferredMediaAction::Delete { tab_id, name } => { Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete { entity_name: name, @@ -2504,6 +3011,7 @@ impl BdsApp { Task::none() } Message::ScriptEditor(msg) => { + let mut run_request = None; if let Some(tab_id) = self.active_tab.clone() && let Some(state) = self.script_editors.get_mut(&tab_id) { @@ -2550,10 +3058,11 @@ impl BdsApp { } } ScriptEditorMsg::Run => { - self.notify( - ToastLevel::Info, - &t(self.ui_locale, "editor.scriptRunNotYet"), - ); + run_request = Some(( + state.content.clone(), + state.entrypoint.clone(), + state.kind.clone(), + )); } ScriptEditorMsg::Delete => { let name = state.title.clone(); @@ -2572,6 +3081,56 @@ impl BdsApp { tab.is_dirty = st.is_dirty; } } + if let Some((source, entrypoint, kind)) = run_request { + return self.spawn_engine_task( + "engine.runScript", + move |_db_path, _project_id, _data_dir, task_manager, task_id| { + let execution_kind = match kind { + bds_core::model::ScriptKind::Macro => { + bds_core::scripting::ExecutionKind::Macro + } + bds_core::model::ScriptKind::Utility => { + bds_core::scripting::ExecutionKind::Utility + } + bds_core::model::ScriptKind::Transform => { + bds_core::scripting::ExecutionKind::Transform + } + }; + let result = bds_core::scripting::execute( + &source, + &entrypoint, + &serde_json::json!({}), + execution_kind, + &bds_core::scripting::ExecutionControl::default(), + )?; + for progress in &result.progress { + let percent = progress.total.and_then(|total| { + (total > 0.0).then_some((progress.current / total) as f32) + }); + task_manager.report_progress( + task_id, + percent, + progress.message.clone(), + ); + } + let mut lines = result.output; + lines.extend( + result + .toasts + .into_iter() + .map(|message| format!("toast: {message}")), + ); + if result.value != serde_json::Value::Null { + lines.push(result.value.to_string()); + } + Ok(if lines.is_empty() { + "completed".to_string() + } else { + lines.join("\n") + }) + }, + ); + } Task::none() } Message::Tags(msg) => self.handle_tags_msg(msg), @@ -2731,6 +3290,8 @@ impl BdsApp { self.settings_state.as_ref(), self.dashboard_state.as_ref(), &self.site_validation_state, + &self.metadata_diff_state, + &self.translation_validation_state, ) } @@ -2777,7 +3338,15 @@ impl BdsApp { t(self.ui_locale, "dialog.importMedia"), t(self.ui_locale, "dialog.imageFilter"), ), - MenuAction::Save => Task::none(), // Disabled in M2 + MenuAction::Save => match self.active_tab_type() { + Some(TabType::Post) => Task::done(Message::PostEditor(PostEditorMsg::Save)), + Some(TabType::Media) => Task::done(Message::MediaEditor(MediaEditorMsg::Save)), + Some(TabType::Templates) => { + Task::done(Message::TemplateEditor(TemplateEditorMsg::Save)) + } + Some(TabType::Scripts) => Task::done(Message::ScriptEditor(ScriptEditorMsg::Save)), + _ => Task::none(), + }, MenuAction::OpenInBrowser => self.preview_active_post(), MenuAction::OpenDataFolder => { if let Some(ref dir) = self.data_dir { @@ -2786,8 +3355,22 @@ impl BdsApp { Task::none() } // Edit - MenuAction::Find => Task::none(), - MenuAction::Replace => Task::none(), + MenuAction::Find => { + self.active_modal = Some(modal::ModalState::FindReplace { + query: String::new(), + replacement: String::new(), + show_replace: false, + }); + Task::none() + } + MenuAction::Replace => { + self.active_modal = Some(modal::ModalState::FindReplace { + query: String::new(), + replacement: String::new(), + show_replace: true, + }); + Task::none() + } MenuAction::EditPreferences => { self.open_singleton_tab(TabType::Settings, "common.settings"); Task::none() @@ -2798,7 +3381,10 @@ impl BdsApp { MenuAction::ToggleSidebar => Task::done(Message::ToggleSidebar), MenuAction::TogglePanel => Task::done(Message::TogglePanel), // Blog - MenuAction::PublishSelected => Task::none(), // Disabled in M2 + MenuAction::PublishSelected => match self.active_tab_type() { + Some(TabType::Post) => Task::done(Message::PostEditor(PostEditorMsg::Publish)), + _ => Task::none(), + }, MenuAction::PreviewPost => self.preview_active_post(), MenuAction::EditMenu => { self.open_singleton_tab(TabType::MenuEditor, "tabBar.menuEditor"); @@ -2810,18 +3396,43 @@ impl BdsApp { MenuAction::RegenerateCalendar => Task::done(Message::RegenerateCalendar), MenuAction::ValidateTranslations => Task::done(Message::ValidateTranslations), MenuAction::FillMissingTranslations => { - if self.offline_mode { - self.notify( - ToastLevel::Warning, - &t(self.ui_locale, "engine.fillMissingTranslationsOffline"), - ); - } else { - self.notify( - ToastLevel::Warning, - &t(self.ui_locale, "engine.fillMissingTranslationsNoAi"), - ); - } - Task::none() + let offline_mode = self.offline_mode; + let locale = self.ui_locale; + self.spawn_engine_task( + "engine.fillMissingTranslationsStarted", + move |db_path, project_id, data_dir, task_manager, task_id| { + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + let meta = engine::meta::read_project_json(&data_dir) + .map_err(|error| error.to_string())?; + let main_language = meta.main_language.as_deref().unwrap_or("en"); + let task_manager_for_progress = Arc::clone(&task_manager); + let report = engine::auto_translation::fill_missing_translations( + db.conn(), + &data_dir, + &project_id, + main_language, + &meta.blog_languages, + offline_mode, + move |progress, _message| { + task_manager_for_progress.report_progress( + task_id, + Some(progress), + Some(t(locale, "engine.translatingContent")), + ); + }, + ) + .map_err(|error| error.to_string())?; + Ok(tw( + locale, + "engine.fillMissingTranslationsComplete", + &[ + ("posts", &report.translated_posts.to_string()), + ("media", &report.translated_media.to_string()), + ("failed", &report.failed_count.to_string()), + ], + )) + }, + ) } MenuAction::GenerateSitemap => Task::done(Message::GenerateSite), MenuAction::ValidateSite => { @@ -2834,6 +3445,7 @@ impl BdsApp { ToastLevel::Warning, &t(self.ui_locale, "engine.uploadOffline"), ); + return Task::none(); } else if let Some(data_dir) = &self.data_dir { let pub_prefs = engine::meta::read_publishing_json(data_dir).ok(); let has_creds = pub_prefs @@ -2848,11 +3460,45 @@ impl BdsApp { ToastLevel::Warning, &t(self.ui_locale, "engine.uploadMissingCredentials"), ); - } else { - self.notify(ToastLevel::Info, &t(self.ui_locale, "engine.uploadStarted")); + return Task::none(); } } - Task::none() + let locale = self.ui_locale; + self.spawn_engine_task( + "engine.uploadStarted", + move |db_path, _project_id, data_dir, task_manager, task_id| { + let preferences = engine::meta::read_publishing_json(&data_dir) + .map_err(|error| error.to_string())?; + let cache_dir = db_path.parent().map(PathBuf::from).ok_or_else(|| { + "private application directory unavailable".to_string() + })?; + let job = engine::publishing::upload_site( + &data_dir, + &cache_dir, + &preferences, + |current, total, kind| { + let target = match kind { + engine::publishing::UploadTargetKind::Html => "html", + engine::publishing::UploadTargetKind::Thumbnails => { + "thumbnails" + } + engine::publishing::UploadTargetKind::Media => "media", + }; + task_manager.report_progress( + task_id, + Some(current as f32 / total.max(1) as f32), + Some(tw( + locale, + "engine.uploadingTarget", + &[("target", target)], + )), + ); + }, + ) + .map_err(|error| error.to_string())?; + Ok(format!("{} targets uploaded", job.completed_targets.len())) + }, + ) } // Help MenuAction::About => { @@ -3148,6 +3794,33 @@ impl BdsApp { ) } + fn start_metadata_diff(&mut self) -> Task { + let (Some(project), Some(data_dir)) = + (self.active_project.as_ref(), self.data_dir.as_ref()) + else { + self.metadata_diff_state.error_message = + Some(t(self.ui_locale, "engine.generateSiteNoProject")); + return Task::none(); + }; + self.metadata_diff_state.is_running = true; + self.metadata_diff_state.error_message = None; + let db_path = self.db_path.clone(); + let project_id = project.id.clone(); + let data_dir = data_dir.clone(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + engine::metadata_diff::compute_metadata_diff(db.conn(), &data_dir, &project_id) + .map_err(|error| error.to_string()) + }) + .await + .map_err(|error| error.to_string())? + }, + Message::MetadataDiffLoaded, + ) + } + fn apply_site_validation(&mut self) -> Task { if self.site_validation_state.is_running || self.site_validation_state.is_applying { return Task::none(); @@ -3907,6 +4580,106 @@ impl BdsApp { } } + fn active_tab_type(&self) -> Option { + self.active_tab.as_ref().and_then(|id| { + self.tabs + .iter() + .find(|tab| tab.id == *id) + .map(|tab| tab.tab_type.clone()) + }) + } + + fn find_next_in_active_editor(&mut self, query: &str) -> bool { + let Some(id) = self.active_tab.clone() else { + return false; + }; + match self.active_tab_type() { + Some(TabType::Post) => self + .post_editors + .get_mut(&id) + .is_some_and(|state| state.editor_buffer.borrow_mut().find_next(query)), + Some(TabType::Templates) => self + .template_editors + .get_mut(&id) + .is_some_and(|state| state.editor_buffer.borrow_mut().find_next(query)), + Some(TabType::Scripts) => self + .script_editors + .get_mut(&id) + .is_some_and(|state| state.editor_buffer.borrow_mut().find_next(query)), + _ => false, + } + } + + fn replace_current_in_active_editor(&mut self, query: &str, replacement: &str) -> bool { + let Some(id) = self.active_tab.clone() else { + return false; + }; + let replaced = match self.active_tab_type() { + Some(TabType::Post) => self.post_editors.get_mut(&id).is_some_and(|state| { + let mut buffer = state.editor_buffer.borrow_mut(); + let replaced = replace_current_in_buffer(&mut buffer, query, replacement); + state.content = buffer.text(); + state.is_dirty |= replaced; + replaced + }), + Some(TabType::Templates) => self.template_editors.get_mut(&id).is_some_and(|state| { + let mut buffer = state.editor_buffer.borrow_mut(); + let replaced = replace_current_in_buffer(&mut buffer, query, replacement); + state.content = buffer.text(); + state.is_dirty |= replaced; + replaced + }), + Some(TabType::Scripts) => self.script_editors.get_mut(&id).is_some_and(|state| { + let mut buffer = state.editor_buffer.borrow_mut(); + let replaced = replace_current_in_buffer(&mut buffer, query, replacement); + state.content = buffer.text(); + state.is_dirty |= replaced; + replaced + }), + _ => false, + }; + if replaced && let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) { + tab.is_dirty = true; + } + replaced + } + + fn replace_all_in_active_editor(&mut self, query: &str, replacement: &str) -> usize { + let Some(id) = self.active_tab.clone() else { + return 0; + }; + let count = match self.active_tab_type() { + Some(TabType::Post) => self.post_editors.get_mut(&id).map_or(0, |state| { + let mut buffer = state.editor_buffer.borrow_mut(); + let count = buffer.replace_all(query, replacement); + state.content = buffer.text(); + state.is_dirty |= count > 0; + count + }), + Some(TabType::Templates) => self.template_editors.get_mut(&id).map_or(0, |state| { + let mut buffer = state.editor_buffer.borrow_mut(); + let count = buffer.replace_all(query, replacement); + state.content = buffer.text(); + state.is_dirty |= count > 0; + count + }), + Some(TabType::Scripts) => self.script_editors.get_mut(&id).map_or(0, |state| { + let mut buffer = state.editor_buffer.borrow_mut(); + let count = buffer.replace_all(query, replacement); + state.content = buffer.text(); + state.is_dirty |= count > 0; + count + }), + _ => 0, + }; + if count > 0 + && let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) + { + tab.is_dirty = true; + } + count + } + /// Synchronise menu enabled/disabled state with current app state. /// /// Called after state-changing operations (project switch, tab open/close, @@ -3918,27 +4691,36 @@ impl BdsApp { .active_tab .as_ref() .and_then(|id| self.tabs.iter().find(|t| t.id == *id).map(|t| &t.tab_type)); - let has_tab = active_tab_type.is_some() && interactions_enabled; let has_post_tab = active_tab_type == Some(&TabType::Post); + let has_savable_tab = matches!( + active_tab_type, + Some(TabType::Post | TabType::Media | TabType::Templates | TabType::Scripts) + ); // File group: need active project for most, need open tab for Save self.menu_registry .set_enabled(MenuAction::NewPost, has_project); self.menu_registry .set_enabled(MenuAction::ImportMedia, has_project); - self.menu_registry.set_enabled(MenuAction::Save, has_tab); + self.menu_registry + .set_enabled(MenuAction::Save, has_savable_tab && interactions_enabled); self.menu_registry .set_enabled(MenuAction::OpenInBrowser, has_project && has_post_tab); self.menu_registry .set_enabled(MenuAction::OpenDataFolder, has_project); - // Edit: Find/Replace need an open tab - self.menu_registry.set_enabled(MenuAction::Find, has_tab); - self.menu_registry.set_enabled(MenuAction::Replace, has_tab); + let has_text_editor = matches!( + active_tab_type, + Some(TabType::Post | TabType::Templates | TabType::Scripts) + ); + self.menu_registry + .set_enabled(MenuAction::Find, has_text_editor && interactions_enabled); + self.menu_registry + .set_enabled(MenuAction::Replace, has_text_editor && interactions_enabled); // Blog group: need active project self.menu_registry - .set_enabled(MenuAction::PublishSelected, has_project && has_tab); + .set_enabled(MenuAction::PublishSelected, has_project && has_post_tab); self.menu_registry .set_enabled(MenuAction::PreviewPost, has_project && has_post_tab); self.menu_registry @@ -3953,10 +4735,8 @@ impl BdsApp { .set_enabled(MenuAction::RegenerateCalendar, has_project); self.menu_registry .set_enabled(MenuAction::ValidateTranslations, has_project); - self.menu_registry.set_enabled( - MenuAction::FillMissingTranslations, - has_project && !self.offline_mode, - ); + self.menu_registry + .set_enabled(MenuAction::FillMissingTranslations, has_project); self.menu_registry .set_enabled(MenuAction::GenerateSitemap, has_project); self.menu_registry @@ -3969,7 +4749,10 @@ impl BdsApp { fn save_post_editor(&mut self, post_id: &str) -> Task { match self.persist_post_editor_state(post_id) { - Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")), + Ok(()) => { + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + return self.schedule_post_auto_translation(post_id); + } Err(e) => self.notify_operation_failed("common.save", e), } Task::none() @@ -3999,6 +4782,7 @@ impl BdsApp { } } self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.published")); + return self.schedule_post_auto_translation(post_id); } Err(e) => { self.notify_operation_failed("editor.publish", e); @@ -4007,6 +4791,40 @@ impl BdsApp { Task::none() } + fn schedule_post_auto_translation(&mut self, post_id: &str) -> Task { + let Some(db) = self.db.as_ref() else { + return Task::none(); + }; + if engine::ai::active_endpoint(db.conn(), self.offline_mode).is_err() { + return Task::none(); + } + let post_id = post_id.to_string(); + let offline_mode = self.offline_mode; + let locale = self.ui_locale; + self.spawn_engine_task( + "engine.autoTranslationStarted", + move |db_path, _project_id, data_dir, _task_manager, _task_id| { + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + let meta = engine::meta::read_project_json(&data_dir) + .map_err(|error| error.to_string())?; + let report = engine::auto_translation::translate_missing_for_post( + db.conn(), + &data_dir, + &post_id, + meta.main_language.as_deref().unwrap_or("en"), + &meta.blog_languages, + offline_mode, + ) + .map_err(|error| error.to_string())?; + Ok(tw( + locale, + "engine.autoTranslationComplete", + &[("count", &report.translated_posts.to_string())], + )) + }, + ) + } + fn insert_link_modal(&mut self, post_id: &str) -> Task { let state = match self.post_editors.get(post_id) { Some(s) => s.clone(), @@ -5010,6 +5828,7 @@ impl BdsApp { .unwrap_or_else(|| self.content_language.clone()); state.default_author = meta.default_author.unwrap_or_default(); state.max_posts_per_page = meta.max_posts_per_page.to_string(); + state.image_import_concurrency = meta.image_import_concurrency.to_string(); state.blogmark_category = meta.blogmark_category.unwrap_or_default(); state.blog_languages = if meta.blog_languages.is_empty() { vec![state.main_language.clone()] @@ -5429,6 +6248,9 @@ impl BdsApp { SettingsMsg::MaxPostsPerPageChanged(s) => { state.max_posts_per_page = s; } + SettingsMsg::ImageImportConcurrencyChanged(s) => { + state.image_import_concurrency = s; + } SettingsMsg::BlogmarkCategoryChanged(s) => { state.blogmark_category = s; } @@ -5446,6 +6268,17 @@ impl BdsApp { return Task::none(); } }; + let image_import_concurrency = + match state.image_import_concurrency.trim().parse::() { + Ok(value) => value.clamp(1, 8), + Err(_) => { + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "settings.imageImportConcurrencyInvalid"), + ); + return Task::none(); + } + }; let mut meta = engine::meta::read_project_json(data_dir).unwrap_or( bds_core::model::metadata::ProjectMetadata { name: state.project_name.clone(), @@ -5454,6 +6287,7 @@ impl BdsApp { main_language: None, default_author: None, max_posts_per_page: 50, + image_import_concurrency: 4, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, @@ -5485,6 +6319,7 @@ impl BdsApp { Some(state.default_author.clone()) }; meta.max_posts_per_page = max_posts; + meta.image_import_concurrency = image_import_concurrency; meta.blogmark_category = if state.blogmark_category.trim().is_empty() { None } else { @@ -6229,6 +7064,7 @@ impl BdsApp { state.public_url = meta.public_url.unwrap_or_default(); state.default_author = meta.default_author.unwrap_or_default(); state.max_posts_per_page = meta.max_posts_per_page.to_string(); + state.image_import_concurrency = meta.image_import_concurrency.to_string(); } if let Ok(pub_prefs) = engine::meta::read_publishing_json(data_dir) { state.ssh_host = pub_prefs.ssh_host.unwrap_or_default(); diff --git a/crates/bds-ui/src/platform/dialog.rs b/crates/bds-ui/src/platform/dialog.rs index 19a3b96..f46fc5e 100644 --- a/crates/bds-ui/src/platform/dialog.rs +++ b/crates/bds-ui/src/platform/dialog.rs @@ -16,6 +16,20 @@ pub fn pick_folder(title: String) -> Task { ) } +/// Pick an existing portable project folder. +pub fn pick_project_folder(title: String) -> Task { + Task::perform( + async move { + rfd::AsyncFileDialog::new() + .set_title(&title) + .pick_folder() + .await + .map(|h| h.path().to_path_buf()) + }, + Message::ProjectFolderPicked, + ) +} + /// Pick one or more image/media files using the native file dialog. pub fn pick_media_files(title: String, filter_label: String) -> Task { Task::perform( @@ -33,3 +47,26 @@ pub fn pick_media_files(title: String, filter_label: String) -> Task { Message::MediaFilesPicked, ) } + +/// Pick a replacement image for an existing media item. +pub fn pick_media_replacement( + media_id: String, + title: String, + filter_label: String, +) -> Task { + Task::perform( + async move { + let path = rfd::AsyncFileDialog::new() + .set_title(&title) + .add_filter( + &filter_label, + &["jpg", "jpeg", "png", "gif", "webp", "tiff", "bmp"], + ) + .pick_file() + .await + .map(|handle| handle.path().to_path_buf()); + (media_id, path) + }, + |(media_id, path)| Message::MediaReplacementPicked { media_id, path }, + ) +} diff --git a/crates/bds-ui/src/views/media_editor.rs b/crates/bds-ui/src/views/media_editor.rs index 9fd9f2f..b93c2e4 100644 --- a/crates/bds-ui/src/views/media_editor.rs +++ b/crates/bds-ui/src/views/media_editor.rs @@ -220,6 +220,7 @@ pub enum MediaEditorMsg { OpenLinkedPost(String), UnlinkPost(String), Save, + ReplaceFile, Delete, } @@ -263,6 +264,11 @@ pub fn view<'a>( .style(inputs::primary_button) .padding([6, 16]) .into(), + button(text(t(locale, "editor.replaceFile")).size(13)) + .on_press(Message::MediaEditor(MediaEditorMsg::ReplaceFile)) + .style(inputs::secondary_button) + .padding([6, 16]) + .into(), button(text(t(locale, "modal.confirmDelete.delete")).size(13)) .on_press(Message::MediaEditor(MediaEditorMsg::Delete)) .style(inputs::danger_button) diff --git a/crates/bds-ui/src/views/metadata_diff.rs b/crates/bds-ui/src/views/metadata_diff.rs new file mode 100644 index 0000000..1885ade --- /dev/null +++ b/crates/bds-ui/src/views/metadata_diff.rs @@ -0,0 +1,153 @@ +use iced::widget::text::Shaping; +use iced::widget::{Space, button, column, container, row, scrollable, text}; +use iced::{Color, Element, Length}; + +use bds_core::engine::metadata_diff::{DiffReport, RepairDirection}; +use bds_core::i18n::UiLocale; + +use crate::app::Message; +use crate::components::inputs; +use crate::i18n::{t, tw}; + +#[derive(Debug, Clone, Default)] +pub struct MetadataDiffState { + pub is_running: bool, + pub is_repairing: bool, + pub report: Option, + pub error_message: Option, +} + +pub fn view<'a>(state: &'a MetadataDiffState, locale: UiLocale) -> Element<'a, Message> { + let run = button(text(t(locale, "metadataDiff.run")).size(13)) + .on_press_maybe( + (!state.is_running && !state.is_repairing).then_some(Message::RunMetadataDiff), + ) + .style(inputs::primary_button) + .padding([6, 16]); + let mut content = column![ + row![ + text(t(locale, "tabBar.metadataDiff")) + .size(24) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.88, 0.88, 0.92)), + Space::with_width(Length::Fill), + run, + ] + .align_y(iced::Alignment::Center), + ] + .spacing(16); + + if state.is_running { + content = content.push(message_card(t(locale, "metadataDiff.running"))); + } else if let Some(error) = &state.error_message { + content = content.push(message_card(error.clone())); + } else if let Some(report) = &state.report { + if report.diffs.is_empty() && report.orphans.is_empty() && report.errors.is_empty() { + content = content.push(message_card(t(locale, "metadataDiff.clean"))); + } + for (index, item) in report.diffs.iter().enumerate() { + let fields = item + .fields + .iter() + .fold(column!().spacing(4), |column, field| { + column.push( + text(tw( + locale, + "metadataDiff.field", + &[ + ("field", &field.field_name), + ("database", &field.db_value), + ("file", &field.file_value), + ], + )) + .size(12) + .color(Color::from_rgb(0.72, 0.72, 0.78)), + ) + }); + let actions = row![ + button(text(t(locale, "metadataDiff.fileToDb")).size(12)) + .on_press_maybe((!state.is_repairing).then_some( + Message::RepairMetadataDiffItem { + index, + direction: RepairDirection::FileToDatabase, + }, + )) + .style(inputs::secondary_button) + .padding([5, 10]), + button(text(t(locale, "metadataDiff.dbToFile")).size(12)) + .on_press_maybe((!state.is_repairing).then_some( + Message::RepairMetadataDiffItem { + index, + direction: RepairDirection::DatabaseToFile, + }, + )) + .style(inputs::secondary_button) + .padding([5, 10]), + ] + .spacing(8); + content = content.push(inputs::card( + column![ + text(tw( + locale, + "metadataDiff.entity", + &[("entity", &item.entity_type), ("path", &item.file_path)], + )) + .size(15) + .color(Color::from_rgb(0.86, 0.86, 0.92)), + fields, + actions, + ] + .spacing(10), + )); + } + if !report.orphans.is_empty() { + let rows = report + .orphans + .iter() + .fold(column!().spacing(4), |column, orphan| { + column.push( + text(tw( + locale, + "metadataDiff.orphan", + &[("reason", &orphan.reason), ("path", &orphan.file_path)], + )) + .size(12), + ) + }); + content = content.push(inputs::card( + column![text(t(locale, "metadataDiff.orphans")).size(16), rows].spacing(8), + )); + } + if !report.errors.is_empty() { + content = content.push(inputs::card( + column![ + text(t(locale, "metadataDiff.errors")).size(16), + report + .errors + .iter() + .fold(column!().spacing(4), |column, error| { + column.push(text(error.clone()).size(12)) + }), + ] + .spacing(8), + )); + } + } else { + content = content.push(message_card(t(locale, "metadataDiff.idle"))); + } + + container(scrollable(content)) + .width(Length::Fill) + .height(Length::Fill) + .padding(24) + .into() +} + +fn message_card(value: String) -> Element<'static, Message> { + inputs::card( + text(value) + .size(14) + .color(Color::from_rgb(0.66, 0.66, 0.72)), + ) + .into() +} diff --git a/crates/bds-ui/src/views/mod.rs b/crates/bds-ui/src/views/mod.rs index 311e304..e401ec0 100644 --- a/crates/bds-ui/src/views/mod.rs +++ b/crates/bds-ui/src/views/mod.rs @@ -1,6 +1,7 @@ pub mod activity_bar; pub mod dashboard; pub mod media_editor; +pub mod metadata_diff; pub mod modal; pub mod panel; pub mod post_editor; @@ -14,5 +15,6 @@ pub mod tab_bar; pub mod tags_view; pub mod template_editor; pub mod toast; +pub mod translation_validation; pub mod welcome; pub mod workspace; diff --git a/crates/bds-ui/src/views/modal.rs b/crates/bds-ui/src/views/modal.rs index 913cf14..d4323da 100644 --- a/crates/bds-ui/src/views/modal.rs +++ b/crates/bds-ui/src/views/modal.rs @@ -65,6 +65,11 @@ pub enum ModalState { }, SearchIndexRepair, SearchIndexRebuilding, + FindReplace { + query: String, + replacement: String, + show_replace: bool, + }, /// Per modals.allium InsertPostLinkModal: Ctrl+K link insertion. PostInsertLink { post_id: String, @@ -369,6 +374,60 @@ pub fn view( .style(modal_box_style) .into() } + ModalState::FindReplace { + query, + replacement, + show_replace, + } => { + let find = text_input(&t(locale, "find.query"), &query) + .on_input(Message::FindQueryChanged) + .on_submit(Message::FindNext) + .style(inputs::field_style); + let mut fields = column![find].spacing(8); + if show_replace { + fields = fields.push( + text_input(&t(locale, "find.replacement"), &replacement) + .on_input(Message::ReplaceQueryChanged) + .style(inputs::field_style), + ); + } + let mut actions = row![ + button(text(t(locale, "find.next"))) + .on_press(Message::FindNext) + .style(inputs::primary_button), + ] + .spacing(8); + if show_replace { + actions = actions + .push( + button(text(t(locale, "find.replace"))) + .on_press(Message::ReplaceCurrent) + .style(inputs::secondary_button), + ) + .push( + button(text(t(locale, "find.replaceAll"))) + .on_press(Message::ReplaceAll) + .style(inputs::secondary_button), + ); + } + container( + column![ + text(if show_replace { + t(locale, "find.titleReplace") + } else { + t(locale, "find.titleFind") + }) + .size(16), + fields, + actions, + ] + .spacing(12), + ) + .width(Length::Fixed(440.0)) + .padding(20) + .style(modal_box_style) + .into() + } ModalState::SearchIndexRebuilding => container( column![ diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index b32035a..a0a8cd4 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -1200,7 +1200,7 @@ mod tests { fn unsupported_default_mode_falls_back_to_markdown() { let mut state = sample_state(); - state.set_editor_mode("wysiwyg"); + state.set_editor_mode("visual"); assert_eq!(state.editor_mode, "markdown"); state.set_editor_mode("preview"); diff --git a/crates/bds-ui/src/views/project_selector.rs b/crates/bds-ui/src/views/project_selector.rs index 3a2535f..b59106f 100644 --- a/crates/bds-ui/src/views/project_selector.rs +++ b/crates/bds-ui/src/views/project_selector.rs @@ -168,7 +168,27 @@ pub fn view( .into(), ); - // New project button + // Open and create project actions + items.push( + button( + row![ + text("↗") + .size(14) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.55, 0.75, 0.95)), + text(t(locale, "projectSelector.openProject")) + .size(12) + .shaping(Shaping::Advanced), + ] + .spacing(6), + ) + .on_press(Message::RequestOpenProject) + .padding([4, 8]) + .width(Length::Fill) + .style(new_project_btn) + .into(), + ); + items.push( button( row![ diff --git a/crates/bds-ui/src/views/script_editor.rs b/crates/bds-ui/src/views/script_editor.rs index 4984771..26ed4de 100644 --- a/crates/bds-ui/src/views/script_editor.rs +++ b/crates/bds-ui/src/views/script_editor.rs @@ -205,12 +205,8 @@ pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, M .align_y(iced::Alignment::End) .width(Length::Fill); - // Content editor (CodeEditor with syntax highlighting based on file extension) - let syntax_ext = if state.file_path.ends_with(".py") { - "py" - } else { - "lua" - }; + // Content editor (Lua is the only scripting runtime). + let syntax_ext = "lua"; let content_section: Element<'a, Message> = inputs::card( column![ inputs::section_header(&t(locale, "editor.content")), diff --git a/crates/bds-ui/src/views/settings_view.rs b/crates/bds-ui/src/views/settings_view.rs index 9da539f..c1ed5a4 100644 --- a/crates/bds-ui/src/views/settings_view.rs +++ b/crates/bds-ui/src/views/settings_view.rs @@ -111,6 +111,7 @@ pub struct SettingsViewState { pub available_languages: Vec, pub default_author: String, pub max_posts_per_page: String, + pub image_import_concurrency: String, pub blogmark_category: String, // Editor pub default_mode: String, @@ -167,6 +168,7 @@ impl Clone for SettingsViewState { available_languages: self.available_languages.clone(), default_author: self.default_author.clone(), max_posts_per_page: self.max_posts_per_page.clone(), + image_import_concurrency: self.image_import_concurrency.clone(), blogmark_category: self.blogmark_category.clone(), default_mode: self.default_mode.clone(), diff_view_style: self.diff_view_style.clone(), @@ -218,6 +220,7 @@ impl Default for SettingsViewState { ], default_author: String::new(), max_posts_per_page: "50".to_string(), + image_import_concurrency: "4".to_string(), blogmark_category: String::new(), default_mode: "markdown".to_string(), diff_view_style: "inline".to_string(), @@ -287,6 +290,7 @@ pub enum SettingsMsg { ToggleBlogLanguage(String), DefaultAuthorChanged(String), MaxPostsPerPageChanged(String), + ImageImportConcurrencyChanged(String), BlogmarkCategoryChanged(String), SaveProject, // Editor @@ -524,6 +528,12 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen &state.max_posts_per_page, |s| Message::Settings(SettingsMsg::MaxPostsPerPageChanged(s)), ); + let image_import_concurrency = inputs::labeled_input( + &t(locale, "settings.imageImportConcurrency"), + "4", + &state.image_import_concurrency, + |s| Message::Settings(SettingsMsg::ImageImportConcurrencyChanged(s)), + ); let blogmark_category = inputs::labeled_select( &t(locale, "settings.blogmarkCategory"), &state.categories, @@ -554,6 +564,7 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen .spacing(4), author, max_posts, + image_import_concurrency, blogmark_category, save, ] @@ -563,11 +574,7 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen } fn section_editor<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { - let mode_options = vec![ - "wysiwyg".to_string(), - "markdown".to_string(), - "preview".to_string(), - ]; + let mode_options = vec!["markdown".to_string(), "preview".to_string()]; let diff_options = vec!["inline".to_string(), "side-by-side".to_string()]; let mode = inputs::labeled_select( &t(locale, "settings.defaultMode"), diff --git a/crates/bds-ui/src/views/translation_validation.rs b/crates/bds-ui/src/views/translation_validation.rs new file mode 100644 index 0000000..19f59cf --- /dev/null +++ b/crates/bds-ui/src/views/translation_validation.rs @@ -0,0 +1,114 @@ +use iced::widget::text::Shaping; +use iced::widget::{Space, button, column, container, row, scrollable, text}; +use iced::{Color, Element, Length}; + +use bds_core::engine::validate_translations::{ + TranslationIssue, TranslationIssueKind, TranslationValidationReport, +}; +use bds_core::i18n::UiLocale; + +use crate::app::Message; +use crate::components::inputs; +use crate::i18n::t; + +#[derive(Debug, Clone, Default)] +pub struct TranslationValidationState { + pub is_running: bool, + pub report: Option, + pub error_message: Option, +} + +pub fn view<'a>(state: &'a TranslationValidationState, locale: UiLocale) -> Element<'a, Message> { + let run = button(text(t(locale, "translationValidation.run")).size(13)) + .on_press_maybe((!state.is_running).then_some(Message::ValidateTranslations)) + .style(inputs::primary_button) + .padding([6, 16]); + let mut content = column![ + row![ + text(t(locale, "tabBar.translationValidation")) + .size(24) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.88, 0.88, 0.92)), + Space::with_width(Length::Fill), + run, + ] + .align_y(iced::Alignment::Center), + ] + .spacing(16); + + if state.is_running { + content = content.push(message_card(t(locale, "translationValidation.running"))); + } else if let Some(error) = &state.error_message { + content = content.push(message_card(error.clone())); + } else if let Some(report) = &state.report { + if report.db_issues.is_empty() && report.fs_issues.is_empty() { + content = content.push(message_card(t(locale, "translationValidation.clean"))); + } else { + content = content.push(issue_section( + t(locale, "translationValidation.databaseIssues"), + &report.db_issues, + locale, + )); + content = content.push(issue_section( + t(locale, "translationValidation.filesystemIssues"), + &report.fs_issues, + locale, + )); + } + } else { + content = content.push(message_card(t(locale, "translationValidation.idle"))); + } + + container(scrollable(content)) + .width(Length::Fill) + .height(Length::Fill) + .padding(24) + .into() +} + +fn issue_section<'a>( + title: String, + issues: &'a [TranslationIssue], + locale: UiLocale, +) -> Element<'a, Message> { + if issues.is_empty() { + return Space::new(0, 0).into(); + } + let rows = issues.iter().fold(column!().spacing(6), |column, issue| { + let location = issue.file_path.as_deref().unwrap_or(issue.post_id.as_str()); + column.push( + text(format!( + "{} · {} · {}", + issue.language, + location, + issue_kind(locale, &issue.kind) + )) + .size(12), + ) + }); + inputs::card(column![text(title).size(16), rows].spacing(8)).into() +} + +fn issue_kind(locale: UiLocale, kind: &TranslationIssueKind) -> String { + let key = match kind { + TranslationIssueKind::MissingSourcePost => "translationValidation.missingSourcePost", + TranslationIssueKind::SameLanguageAsCanonical => { + "translationValidation.sameLanguageAsCanonical" + } + TranslationIssueKind::DoNotTranslateHasTranslations => { + "translationValidation.doNotTranslateHasTranslations" + } + TranslationIssueKind::ContentInDatabase => "translationValidation.contentInDatabase", + TranslationIssueKind::MissingTranslation => "translationValidation.missingTranslation", + }; + t(locale, key) +} + +fn message_card<'a>(message: String) -> Element<'a, Message> { + inputs::card( + text(message) + .size(13) + .color(Color::from_rgb(0.72, 0.72, 0.78)), + ) + .into() +} diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index 42702d6..5439190 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -17,6 +17,7 @@ use crate::views::{ activity_bar, dashboard::DashboardState, media_editor::{self, MediaEditorState}, + metadata_diff::{self, MetadataDiffState}, modal, panel, post_editor::{self, PostEditorState}, project_selector, @@ -27,7 +28,9 @@ use crate::views::{ status_bar, tab_bar, tags_view::{self, TagsViewState}, template_editor::{self, TemplateEditorState}, - toast, welcome, + toast, + translation_validation::{self, TranslationValidationState}, + welcome, }; /// Main content area background. @@ -117,6 +120,8 @@ pub fn view<'a>( settings_state: Option<&'a SettingsViewState>, dashboard_state: Option<&'a DashboardState>, site_validation_state: &'a SiteValidationState, + metadata_diff_state: &'a MetadataDiffState, + translation_validation_state: &'a TranslationValidationState, ) -> Element<'a, Message> { // Activity bar (leftmost column) let activity = activity_bar::view(sidebar_view, sidebar_visible, locale); @@ -140,6 +145,8 @@ pub fn view<'a>( settings_state, dashboard_state, site_validation_state, + metadata_diff_state, + translation_validation_state, ); // Right column: tab bar + content + panel @@ -368,6 +375,8 @@ fn route_content_area<'a>( settings_state: Option<&'a SettingsViewState>, dashboard_state: Option<&'a DashboardState>, site_validation_state: &'a SiteValidationState, + metadata_diff_state: &'a MetadataDiffState, + translation_validation_state: &'a TranslationValidationState, ) -> Element<'a, Message> { match route_kind( tabs, @@ -439,6 +448,10 @@ fn route_content_area<'a>( } } ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale), + ContentRoute::MetadataDiff => metadata_diff::view(metadata_diff_state, locale), + ContentRoute::TranslationValidation => { + translation_validation::view(translation_validation_state, locale) + } ContentRoute::Placeholder(title) => welcome::tab_placeholder(locale, title, None), } } @@ -455,6 +468,8 @@ enum ContentRoute<'a> { Tags, Settings, SiteValidation, + MetadataDiff, + TranslationValidation, Placeholder(&'a str), } @@ -529,16 +544,16 @@ fn route_kind<'a>( } } TabType::SiteValidation => ContentRoute::SiteValidation, + TabType::MetadataDiff => ContentRoute::MetadataDiff, TabType::Style | TabType::Chat | TabType::Import | TabType::MenuEditor - | TabType::MetadataDiff | TabType::GitDiff | TabType::Documentation | TabType::ApiDocumentation - | TabType::TranslationValidation | TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title), + TabType::TranslationValidation => ContentRoute::TranslationValidation, } } @@ -598,11 +613,9 @@ mod tests { TabType::Chat, TabType::Import, TabType::MenuEditor, - TabType::MetadataDiff, TabType::GitDiff, TabType::Documentation, TabType::ApiDocumentation, - TabType::TranslationValidation, TabType::FindDuplicates, ]; @@ -627,6 +640,44 @@ mod tests { } } + #[test] + fn validation_tabs_route_to_real_views() { + 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(); + + for (tab_type, expected) in [ + (TabType::MetadataDiff, "metadata diff"), + (TabType::TranslationValidation, "translation validation"), + ] { + let tabs = vec![tab("tool", tab_type, "Tool")]; + let route = route_kind( + &tabs, + Some("tool"), + &empty_posts, + &empty_media, + &empty_templates, + &empty_scripts, + None, + None, + None, + &site_validation_state, + ); + + let matches_expected = matches!( + (route, expected), + (ContentRoute::MetadataDiff, "metadata diff") + | ( + ContentRoute::TranslationValidation, + "translation validation" + ) + ); + assert!(matches_expected, "expected {expected} route"); + } + } + #[test] fn dashboard_is_used_when_available_and_no_tab_is_active() { let dashboard = DashboardState::new("Test Project".to_string()); diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 3cfc49b..a417c11 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -73,6 +73,7 @@ statusBar-offlineModeActive = Flugmodus (aktiv) projectSelector-switchProject = Projekt wechseln projectSelector-projectsHeader = Projekte projectSelector-newProject = Neues Projekt +projectSelector-openProject = Projekt öffnen… projectSelector-createProject = Projekt erstellen projectSelector-projectName = Projektname projectSelector-projectNamePlaceholder = Mein Blog @@ -82,6 +83,8 @@ projectSelector-deleteProject = Projekt löschen projectSelector-toast-switched = Zu { $name } gewechselt projectSelector-toast-switchFailed = Projektwechsel fehlgeschlagen projectSelector-toast-created = Projekt "{ $name }" erstellt +projectSelector-toast-opened = Projekt "{ $name }" geöffnet +projectSelector-toast-openFailed = Projekt konnte nicht geöffnet werden: { $error } projectSelector-toast-createFailed = Projekt konnte nicht erstellt werden projectSelector-toast-deleteFailed = Projekt konnte nicht gelöscht werden tabBar-closeHint = Schließen @@ -149,6 +152,7 @@ engine-validateTranslationsFailed = Übersetzungsvalidierung fehlgeschlagen: { $ engine-fillMissingTranslationsOffline = Übersetzungen können nicht ergänzt werden: KI im Offline-Modus nicht verfügbar engine-fillMissingTranslationsNoAi = Übersetzungen können nicht ergänzt werden: KI noch nicht verfügbar engine-uploadStarted = Seite wird hochgeladen... +engine-uploadingTarget = { $target } wird hochgeladen… engine-uploadMissingCredentials = Veröffentlichungsdaten unvollständig. In Projekteinstellungen konfigurieren. engine-uploadOffline = Hochladen nicht möglich: Flugmodus ist aktiv engine-generateSiteStarted = Seite wird generiert... @@ -156,8 +160,16 @@ engine-generateSiteNoProject = Kein aktives Projekt engine-previewDataDirUnavailable = Projekt-Datenverzeichnis nicht verfügbar engine-previewPortInUse = Der Port des Vorschau-Servers wird bereits verwendet dialog-selectFolder = Ordner auswählen +dialog-openProject = Projektordner öffnen dialog-importMedia = Medien importieren +dialog-replaceMedia = Mediendatei ersetzen dialog-imageFilter = Bilder +editor-replaceFile = Datei ersetzen… +media-toast-imported = { $count } Mediendatei(en) importiert +media-toast-importFailed = Einige Mediendateien konnten nicht importiert werden: { $error } +media-toast-replaced = Mediendatei ersetzt +media-toast-unchanged = Die ausgewählte Datei ist identisch; keine Änderung +media-toast-replaceFailed = Mediendatei konnte nicht ersetzt werden: { $error } settings-nav-project = Projekt settings-nav-editor = Editor settings-nav-content = Inhalt @@ -410,4 +422,50 @@ engine-checkingItem = Prüfung: { $current }/{ $total } — { $name } engine-renderingPost = { $post } wird gerendert engine-writingGeneratedFiles = Generierte Dateien werden geschrieben engine-generationComplete = Website-Erstellung abgeschlossen +engine-runScript = Skript wird ausgeführt… engine-regeneratingItem = Neuerstellung: { $name } +metadataDiff-run = Vergleich starten +metadataDiff-running = Datenbank und Dateisystem werden verglichen… +metadataDiff-idle = Metadatenvergleich starten, um portable Dateien mit der Datenbank zu vergleichen. +metadataDiff-clean = Datenbank- und Dateisystem-Metadaten sind synchron. +metadataDiff-fileToDb = Datei verwenden +metadataDiff-dbToFile = Datenbank verwenden +metadataDiff-orphans = Verwaiste Dateien +metadataDiff-errors = Fehler +metadataDiff-repaired = Metadatenabweichung repariert +metadataDiff-field = { $field }: DB = { $database } · Datei = { $file } +metadataDiff-entity = { $entity } · { $path } +metadataDiff-orphan = { $reason } · { $path } +engine-fillMissingTranslationsStarted = Fehlende Übersetzungen werden ergänzt… +engine-translatingContent = Inhalte werden übersetzt… +engine-fillMissingTranslationsComplete = Übersetzung abgeschlossen: { $posts } Beiträge, { $media } Medien, { $failed } fehlgeschlagen +engine-autoTranslationStarted = Fehlende Übersetzungen werden erstellt… +engine-autoTranslationComplete = { $count } Übersetzungsentwürfe erstellt +translationValidation-run = Prüfen +translationValidation-running = Übersetzungen werden geprüft… +translationValidation-idle = Prüfung starten, um Übersetzungen in Datenbank und Dateisystem zu kontrollieren. +translationValidation-clean = Alle Übersetzungen sind konsistent. +translationValidation-databaseIssues = Datenbankprobleme +translationValidation-filesystemIssues = Dateisystemprobleme +translationValidation-missingSourcePost = Quellbeitrag fehlt +translationValidation-sameLanguageAsCanonical = Sprache entspricht dem Quellbeitrag +translationValidation-doNotTranslateHasTranslations = Beitrag ist als nicht zu übersetzen markiert +translationValidation-contentInDatabase = Veröffentlichter Inhalt verbleibt in der Datenbank +translationValidation-missingTranslation = Konfigurierte Übersetzung fehlt +blogmark-invalid = Ungültiges Blogmark: { $error } +blogmark-unknownProject = Das Zielprojekt dieses Blogmarks ist hier nicht vorhanden. +blogmark-noProject = Öffnen Sie vor dem Import eines Blogmarks ein Projekt. +blogmark-imported = Blogmark importiert +blogmark-failed = Blogmark-Import fehlgeschlagen: { $error } +engine-generatedPage = Erstellt { $url } ({ $current }/{ $total }) +settings-imageImportConcurrency = Gleichzeitige Bildimporte (1–8) +settings-imageImportConcurrencyInvalid = Die Anzahl gleichzeitiger Bildimporte muss zwischen 1 und 8 liegen +find-titleFind = Suchen +find-titleReplace = Suchen und Ersetzen +find-query = Suchtext +find-replacement = Ersetzung +find-next = Weitersuchen +find-replace = Ersetzen +find-replaceAll = Alle ersetzen +find-noMatches = Keine Treffer gefunden +find-replacedCount = { $count } Treffer ersetzt diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index f69a578..f4d221d 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -73,6 +73,7 @@ statusBar-offlineModeActive = Airplane Mode (active) projectSelector-switchProject = Switch project projectSelector-projectsHeader = Projects projectSelector-newProject = New Project +projectSelector-openProject = Open Project… projectSelector-createProject = Create Project projectSelector-projectName = Project name projectSelector-projectNamePlaceholder = My Blog @@ -82,6 +83,8 @@ projectSelector-deleteProject = Delete Project projectSelector-toast-switched = Switched to { $name } projectSelector-toast-switchFailed = Project switch failed projectSelector-toast-created = Project "{ $name }" created +projectSelector-toast-opened = Project "{ $name }" opened +projectSelector-toast-openFailed = Failed to open project: { $error } projectSelector-toast-createFailed = Failed to create project projectSelector-toast-deleteFailed = Failed to delete project tabBar-closeHint = Close @@ -149,6 +152,7 @@ engine-validateTranslationsFailed = Translation validation failed: { $error } engine-fillMissingTranslationsOffline = Cannot fill translations: AI is unavailable in offline mode engine-fillMissingTranslationsNoAi = Cannot fill translations: AI not yet available engine-uploadStarted = Uploading site... +engine-uploadingTarget = Uploading { $target }… engine-uploadMissingCredentials = Publishing credentials incomplete. Configure in project settings. engine-uploadOffline = Cannot upload: airplane mode is active engine-generateSiteStarted = Generating site... @@ -156,8 +160,16 @@ engine-generateSiteNoProject = No active project engine-previewDataDirUnavailable = Project data directory unavailable engine-previewPortInUse = Preview server port is already in use dialog-selectFolder = Select Folder +dialog-openProject = Open Project Folder dialog-importMedia = Import Media +dialog-replaceMedia = Replace Media File dialog-imageFilter = Images +editor-replaceFile = Replace File… +media-toast-imported = Imported { $count } media file(s) +media-toast-importFailed = Some media files could not be imported: { $error } +media-toast-replaced = Media file replaced +media-toast-unchanged = The selected file is identical; nothing changed +media-toast-replaceFailed = Failed to replace media file: { $error } settings-nav-project = Project settings-nav-editor = Editor settings-nav-content = Content @@ -410,4 +422,50 @@ engine-checkingItem = Checking: { $current }/{ $total } — { $name } engine-renderingPost = Rendering { $post } engine-writingGeneratedFiles = Writing generated files engine-generationComplete = Site generation complete +engine-runScript = Running script… engine-regeneratingItem = Regenerating: { $name } +metadataDiff-run = Run Diff +metadataDiff-running = Comparing database and filesystem… +metadataDiff-idle = Run the metadata diff to compare portable files with the database. +metadataDiff-clean = Database and filesystem metadata are in sync. +metadataDiff-fileToDb = Use File +metadataDiff-dbToFile = Use Database +metadataDiff-orphans = Orphan files +metadataDiff-errors = Errors +metadataDiff-repaired = Metadata difference repaired +metadataDiff-field = { $field }: DB = { $database } · File = { $file } +metadataDiff-entity = { $entity } · { $path } +metadataDiff-orphan = { $reason } · { $path } +engine-fillMissingTranslationsStarted = Filling missing translations… +engine-translatingContent = Translating content… +engine-fillMissingTranslationsComplete = Translation batch complete: { $posts } posts, { $media } media, { $failed } failed +engine-autoTranslationStarted = Creating missing translations… +engine-autoTranslationComplete = Created { $count } draft translations +translationValidation-run = Validate +translationValidation-running = Validating translations… +translationValidation-idle = Run validation to check translations in the database and filesystem. +translationValidation-clean = All translations are consistent. +translationValidation-databaseIssues = Database issues +translationValidation-filesystemIssues = Filesystem issues +translationValidation-missingSourcePost = Source post is missing +translationValidation-sameLanguageAsCanonical = Language matches the source post +translationValidation-doNotTranslateHasTranslations = Post is marked do not translate +translationValidation-contentInDatabase = Published content remains in the database +translationValidation-missingTranslation = Configured translation is missing +blogmark-invalid = Invalid blogmark: { $error } +blogmark-unknownProject = The project targeted by this blogmark does not exist here. +blogmark-noProject = Open a project before importing a blogmark. +blogmark-imported = Blogmark imported +blogmark-failed = Blogmark import failed: { $error } +engine-generatedPage = Generated { $url } ({ $current }/{ $total }) +settings-imageImportConcurrency = Image Import Concurrency (1–8) +settings-imageImportConcurrencyInvalid = Image import concurrency must be a number from 1 to 8 +find-titleFind = Find +find-titleReplace = Find and Replace +find-query = Find text +find-replacement = Replacement +find-next = Find Next +find-replace = Replace +find-replaceAll = Replace All +find-noMatches = No matches found +find-replacedCount = Replaced { $count } matches diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index b0a9886..07001fa 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -73,6 +73,7 @@ statusBar-offlineModeActive = Modo avión (activo) projectSelector-switchProject = Cambiar proyecto projectSelector-projectsHeader = Proyectos projectSelector-newProject = Nuevo proyecto +projectSelector-openProject = Abrir proyecto… projectSelector-createProject = Crear proyecto projectSelector-projectName = Nombre del proyecto projectSelector-projectNamePlaceholder = Mi blog @@ -82,6 +83,8 @@ projectSelector-deleteProject = Eliminar proyecto projectSelector-toast-switched = Cambiado a { $name } projectSelector-toast-switchFailed = No se pudo cambiar de proyecto projectSelector-toast-created = Proyecto "{ $name }" creado +projectSelector-toast-opened = Proyecto "{ $name }" abierto +projectSelector-toast-openFailed = No se pudo abrir el proyecto: { $error } projectSelector-toast-createFailed = No se pudo crear el proyecto projectSelector-toast-deleteFailed = No se pudo eliminar el proyecto tabBar-closeHint = Cerrar @@ -149,6 +152,7 @@ engine-validateTranslationsFailed = La validación de traducciones falló: { $er engine-fillMissingTranslationsOffline = No se pueden completar las traducciones: la IA no está disponible en modo avión engine-fillMissingTranslationsNoAi = No se pueden completar las traducciones: la IA aún no está disponible engine-uploadStarted = Subiendo sitio... +engine-uploadingTarget = Subiendo { $target }… engine-uploadMissingCredentials = Credenciales de publicación incompletas. Configúralas en los ajustes del proyecto. engine-uploadOffline = No se puede subir: el modo avión está activo engine-generateSiteStarted = Generando sitio... @@ -156,8 +160,16 @@ engine-generateSiteNoProject = No hay proyecto activo engine-previewDataDirUnavailable = El directorio de datos del proyecto no está disponible engine-previewPortInUse = El puerto del servidor de vista previa ya está en uso dialog-selectFolder = Seleccionar carpeta +dialog-openProject = Abrir carpeta del proyecto dialog-importMedia = Importar medios +dialog-replaceMedia = Reemplazar archivo multimedia dialog-imageFilter = Imágenes +editor-replaceFile = Reemplazar archivo… +media-toast-imported = Se importaron { $count } archivo(s) multimedia +media-toast-importFailed = No se pudieron importar algunos archivos multimedia: { $error } +media-toast-replaced = Archivo multimedia reemplazado +media-toast-unchanged = El archivo seleccionado es idéntico; no hubo cambios +media-toast-replaceFailed = No se pudo reemplazar el archivo multimedia: { $error } settings-nav-project = Proyecto settings-nav-editor = Editor settings-nav-content = Contenido @@ -410,4 +422,50 @@ engine-checkingItem = Comprobando: { $current }/{ $total } — { $name } engine-renderingPost = Renderizando { $post } engine-writingGeneratedFiles = Escribiendo archivos generados engine-generationComplete = Generación del sitio completada +engine-runScript = Ejecutando script… engine-regeneratingItem = Regenerando: { $name } +metadataDiff-run = Ejecutar comparación +metadataDiff-running = Comparando la base de datos y el sistema de archivos… +metadataDiff-idle = Ejecuta la comparación de metadatos entre archivos portátiles y la base de datos. +metadataDiff-clean = Los metadatos de la base de datos y los archivos están sincronizados. +metadataDiff-fileToDb = Usar archivo +metadataDiff-dbToFile = Usar base de datos +metadataDiff-orphans = Archivos huérfanos +metadataDiff-errors = Errores +metadataDiff-repaired = Diferencia de metadatos reparada +metadataDiff-field = { $field }: BD = { $database } · Archivo = { $file } +metadataDiff-entity = { $entity } · { $path } +metadataDiff-orphan = { $reason } · { $path } +engine-fillMissingTranslationsStarted = Creando las traducciones que faltan… +engine-translatingContent = Traduciendo el contenido… +engine-fillMissingTranslationsComplete = Traducción completada: { $posts } entradas, { $media } medios, { $failed } fallos +engine-autoTranslationStarted = Creando las traducciones que faltan… +engine-autoTranslationComplete = Se crearon { $count } borradores de traducción +translationValidation-run = Validar +translationValidation-running = Validando traducciones… +translationValidation-idle = Ejecuta la validación de las traducciones de la base de datos y el sistema de archivos. +translationValidation-clean = Todas las traducciones son coherentes. +translationValidation-databaseIssues = Problemas de la base de datos +translationValidation-filesystemIssues = Problemas del sistema de archivos +translationValidation-missingSourcePost = Falta la entrada de origen +translationValidation-sameLanguageAsCanonical = El idioma coincide con la entrada de origen +translationValidation-doNotTranslateHasTranslations = La entrada está marcada para no traducir +translationValidation-contentInDatabase = El contenido publicado permanece en la base de datos +translationValidation-missingTranslation = Falta una traducción configurada +blogmark-invalid = Blogmark no válido: { $error } +blogmark-unknownProject = El proyecto de destino de este blogmark no existe aquí. +blogmark-noProject = Abre un proyecto antes de importar un blogmark. +blogmark-imported = Blogmark importado +blogmark-failed = Error al importar el blogmark: { $error } +engine-generatedPage = Generado { $url } ({ $current }/{ $total }) +settings-imageImportConcurrency = Importaciones de imágenes simultáneas (1–8) +settings-imageImportConcurrencyInvalid = El número de importaciones simultáneas debe estar entre 1 y 8 +find-titleFind = Buscar +find-titleReplace = Buscar y reemplazar +find-query = Texto que buscar +find-replacement = Reemplazo +find-next = Buscar siguiente +find-replace = Reemplazar +find-replaceAll = Reemplazar todo +find-noMatches = No se encontraron coincidencias +find-replacedCount = Se reemplazaron { $count } coincidencias diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index ea6bc05..0fcffd4 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -73,6 +73,7 @@ statusBar-offlineModeActive = Mode avion (actif) projectSelector-switchProject = Changer de projet projectSelector-projectsHeader = Projets projectSelector-newProject = Nouveau projet +projectSelector-openProject = Ouvrir un projet… projectSelector-createProject = Créer le projet projectSelector-projectName = Nom du projet projectSelector-projectNamePlaceholder = Mon blog @@ -82,6 +83,8 @@ projectSelector-deleteProject = Supprimer le projet projectSelector-toast-switched = Projet actif : { $name } projectSelector-toast-switchFailed = Échec du changement de projet projectSelector-toast-created = Projet "{ $name }" créé +projectSelector-toast-opened = Projet "{ $name }" ouvert +projectSelector-toast-openFailed = Impossible d’ouvrir le projet : { $error } projectSelector-toast-createFailed = Impossible de créer le projet projectSelector-toast-deleteFailed = Impossible de supprimer le projet tabBar-closeHint = Fermer @@ -149,6 +152,7 @@ engine-validateTranslationsFailed = Échec de la validation des traductions : { engine-fillMissingTranslationsOffline = Impossible de compléter les traductions : l'IA n'est pas disponible en mode avion engine-fillMissingTranslationsNoAi = Impossible de compléter les traductions : l'IA n'est pas encore disponible engine-uploadStarted = Publication du site... +engine-uploadingTarget = Publication de { $target }… engine-uploadMissingCredentials = Identifiants de publication incomplets. Configurez-les dans les paramètres du projet. engine-uploadOffline = Impossible de publier : le mode avion est actif engine-generateSiteStarted = Génération du site... @@ -156,8 +160,16 @@ engine-generateSiteNoProject = Aucun projet actif engine-previewDataDirUnavailable = Le répertoire de données du projet est indisponible engine-previewPortInUse = Le port du serveur d'aperçu est déjà utilisé dialog-selectFolder = Sélectionner un dossier +dialog-openProject = Ouvrir le dossier du projet dialog-importMedia = Importer des médias +dialog-replaceMedia = Remplacer le fichier média dialog-imageFilter = Images +editor-replaceFile = Remplacer le fichier… +media-toast-imported = { $count } fichier(s) média importé(s) +media-toast-importFailed = Certains médias n’ont pas pu être importés : { $error } +media-toast-replaced = Fichier média remplacé +media-toast-unchanged = Le fichier sélectionné est identique ; aucun changement +media-toast-replaceFailed = Impossible de remplacer le fichier média : { $error } settings-nav-project = Projet settings-nav-editor = Éditeur settings-nav-content = Contenu @@ -410,4 +422,50 @@ engine-checkingItem = Vérification : { $current }/{ $total } — { $name } engine-renderingPost = Rendu de { $post } engine-writingGeneratedFiles = Écriture des fichiers générés engine-generationComplete = Génération du site terminée +engine-runScript = Exécution du script… engine-regeneratingItem = Régénération : { $name } +metadataDiff-run = Comparer +metadataDiff-running = Comparaison de la base de données et du système de fichiers… +metadataDiff-idle = Lancez la comparaison des métadonnées entre les fichiers portables et la base. +metadataDiff-clean = Les métadonnées de la base et des fichiers sont synchronisées. +metadataDiff-fileToDb = Utiliser le fichier +metadataDiff-dbToFile = Utiliser la base +metadataDiff-orphans = Fichiers orphelins +metadataDiff-errors = Erreurs +metadataDiff-repaired = Différence de métadonnées réparée +metadataDiff-field = { $field } : base = { $database } · Fichier = { $file } +metadataDiff-entity = { $entity } · { $path } +metadataDiff-orphan = { $reason } · { $path } +engine-fillMissingTranslationsStarted = Création des traductions manquantes… +engine-translatingContent = Traduction du contenu… +engine-fillMissingTranslationsComplete = Traduction terminée : { $posts } articles, { $media } médias, { $failed } échecs +engine-autoTranslationStarted = Création des traductions manquantes… +engine-autoTranslationComplete = { $count } brouillons de traduction créés +translationValidation-run = Valider +translationValidation-running = Validation des traductions… +translationValidation-idle = Lancez la validation des traductions de la base et du système de fichiers. +translationValidation-clean = Toutes les traductions sont cohérentes. +translationValidation-databaseIssues = Problèmes de base de données +translationValidation-filesystemIssues = Problèmes du système de fichiers +translationValidation-missingSourcePost = L’article source est absent +translationValidation-sameLanguageAsCanonical = La langue correspond à l’article source +translationValidation-doNotTranslateHasTranslations = L’article est marqué comme non traduisible +translationValidation-contentInDatabase = Le contenu publié reste dans la base de données +translationValidation-missingTranslation = Une traduction configurée est absente +blogmark-invalid = Blogmark invalide : { $error } +blogmark-unknownProject = Le projet ciblé par ce blogmark n’existe pas ici. +blogmark-noProject = Ouvrez un projet avant d’importer un blogmark. +blogmark-imported = Blogmark importé +blogmark-failed = Échec de l’import du blogmark : { $error } +engine-generatedPage = Généré { $url } ({ $current }/{ $total }) +settings-imageImportConcurrency = Imports d’images simultanés (1–8) +settings-imageImportConcurrencyInvalid = Le nombre d’imports simultanés doit être compris entre 1 et 8 +find-titleFind = Rechercher +find-titleReplace = Rechercher et remplacer +find-query = Texte recherché +find-replacement = Remplacement +find-next = Suivant +find-replace = Remplacer +find-replaceAll = Tout remplacer +find-noMatches = Aucun résultat +find-replacedCount = { $count } résultats remplacés diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index f303022..224aa0e 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -73,6 +73,7 @@ statusBar-offlineModeActive = Modalità aereo (attiva) projectSelector-switchProject = Cambia progetto projectSelector-projectsHeader = Progetti projectSelector-newProject = Nuovo progetto +projectSelector-openProject = Apri progetto… projectSelector-createProject = Crea progetto projectSelector-projectName = Nome progetto projectSelector-projectNamePlaceholder = Il mio blog @@ -82,6 +83,8 @@ projectSelector-deleteProject = Elimina progetto projectSelector-toast-switched = Passato a { $name } projectSelector-toast-switchFailed = Cambio progetto non riuscito projectSelector-toast-created = Progetto "{ $name }" creato +projectSelector-toast-opened = Progetto "{ $name }" aperto +projectSelector-toast-openFailed = Impossibile aprire il progetto: { $error } projectSelector-toast-createFailed = Creazione progetto non riuscita projectSelector-toast-deleteFailed = Eliminazione progetto non riuscita tabBar-closeHint = Chiudi @@ -149,6 +152,7 @@ engine-validateTranslationsFailed = Validazione delle traduzioni non riuscita: { engine-fillMissingTranslationsOffline = Impossibile completare le traduzioni: l'IA non è disponibile in modalità aereo engine-fillMissingTranslationsNoAi = Impossibile completare le traduzioni: l'IA non è ancora disponibile engine-uploadStarted = Caricamento del sito... +engine-uploadingTarget = Caricamento di { $target }… engine-uploadMissingCredentials = Credenziali di pubblicazione incomplete. Configurale nelle impostazioni del progetto. engine-uploadOffline = Impossibile caricare: la modalità aereo è attiva engine-generateSiteStarted = Generazione del sito... @@ -156,8 +160,16 @@ engine-generateSiteNoProject = Nessun progetto attivo engine-previewDataDirUnavailable = Directory dati del progetto non disponibile engine-previewPortInUse = La porta del server di anteprima è già in uso dialog-selectFolder = Seleziona cartella +dialog-openProject = Apri cartella progetto dialog-importMedia = Importa media +dialog-replaceMedia = Sostituisci file multimediale dialog-imageFilter = Immagini +editor-replaceFile = Sostituisci file… +media-toast-imported = Importati { $count } file multimediali +media-toast-importFailed = Impossibile importare alcuni file multimediali: { $error } +media-toast-replaced = File multimediale sostituito +media-toast-unchanged = Il file selezionato è identico; nessuna modifica +media-toast-replaceFailed = Impossibile sostituire il file multimediale: { $error } settings-nav-project = Progetto settings-nav-editor = Editor settings-nav-content = Contenuto @@ -410,4 +422,50 @@ engine-checkingItem = Verifica: { $current }/{ $total } — { $name } engine-renderingPost = Rendering di { $post } engine-writingGeneratedFiles = Scrittura dei file generati engine-generationComplete = Generazione del sito completata +engine-runScript = Esecuzione dello script… engine-regeneratingItem = Rigenerazione: { $name } +metadataDiff-run = Esegui confronto +metadataDiff-running = Confronto tra database e file system… +metadataDiff-idle = Esegui il confronto dei metadati tra file portatili e database. +metadataDiff-clean = I metadati del database e del file system sono sincronizzati. +metadataDiff-fileToDb = Usa file +metadataDiff-dbToFile = Usa database +metadataDiff-orphans = File orfani +metadataDiff-errors = Errori +metadataDiff-repaired = Differenza nei metadati riparata +metadataDiff-field = { $field }: DB = { $database } · File = { $file } +metadataDiff-entity = { $entity } · { $path } +metadataDiff-orphan = { $reason } · { $path } +engine-fillMissingTranslationsStarted = Creazione delle traduzioni mancanti… +engine-translatingContent = Traduzione dei contenuti… +engine-fillMissingTranslationsComplete = Traduzione completata: { $posts } articoli, { $media } media, { $failed } errori +engine-autoTranslationStarted = Creazione delle traduzioni mancanti… +engine-autoTranslationComplete = Create { $count } bozze di traduzione +translationValidation-run = Verifica +translationValidation-running = Verifica delle traduzioni… +translationValidation-idle = Avvia la verifica delle traduzioni nel database e nel filesystem. +translationValidation-clean = Tutte le traduzioni sono coerenti. +translationValidation-databaseIssues = Problemi del database +translationValidation-filesystemIssues = Problemi del filesystem +translationValidation-missingSourcePost = Manca l’articolo sorgente +translationValidation-sameLanguageAsCanonical = La lingua corrisponde all’articolo sorgente +translationValidation-doNotTranslateHasTranslations = L’articolo è contrassegnato come non traducibile +translationValidation-contentInDatabase = Il contenuto pubblicato rimane nel database +translationValidation-missingTranslation = Manca una traduzione configurata +blogmark-invalid = Blogmark non valido: { $error } +blogmark-unknownProject = Il progetto indicato dal blogmark non esiste qui. +blogmark-noProject = Apri un progetto prima di importare un blogmark. +blogmark-imported = Blogmark importato +blogmark-failed = Importazione del blogmark non riuscita: { $error } +engine-generatedPage = Generato { $url } ({ $current }/{ $total }) +settings-imageImportConcurrency = Importazioni immagini simultanee (1–8) +settings-imageImportConcurrencyInvalid = Il numero di importazioni simultanee deve essere compreso tra 1 e 8 +find-titleFind = Trova +find-titleReplace = Trova e sostituisci +find-query = Testo da trovare +find-replacement = Sostituzione +find-next = Trova successivo +find-replace = Sostituisci +find-replaceAll = Sostituisci tutto +find-noMatches = Nessuna corrispondenza +find-replacedCount = Sostituite { $count } corrispondenze