feat: completed base feature set for the app
This commit is contained in:
109
Cargo.lock
generated
109
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"
|
||||
|
||||
441
crates/bds-core/src/engine/auto_translation.rs
Normal file
441
crates/bds-core/src/engine/auto_translation.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
pub fn configured_languages(main_language: &str, blog_languages: &[String]) -> Vec<String> {
|
||||
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<Vec<String>> {
|
||||
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::<HashSet<_>>();
|
||||
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<FillMissingTranslationsReport> {
|
||||
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<TranslationResult>,
|
||||
media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult<MediaTranslationResult>,
|
||||
on_progress: &mut dyn FnMut(f32, &str),
|
||||
) -> EngineResult<FillMissingTranslationsReport> {
|
||||
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<FillMissingTranslationsReport> {
|
||||
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<TranslationResult>,
|
||||
media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult<MediaTranslationResult>,
|
||||
) -> EngineResult<usize> {
|
||||
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<String> {
|
||||
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<TranslationResult> {
|
||||
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<MediaTranslationResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
343
crates/bds-core/src/engine/blogmark.rs
Normal file
343
crates/bds-core/src/engine/blogmark.rs
Normal file
@@ -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<String>,
|
||||
pub content: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub categories: Vec<String>,
|
||||
pub project_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlogmarkImportResult {
|
||||
pub post: Post,
|
||||
pub toasts: Vec<String>,
|
||||
pub transform_errors: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn parse_deep_link(raw: &str) -> EngineResult<BlogmarkCandidate> {
|
||||
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::<std::collections::HashMap<_, _>>();
|
||||
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<BlogmarkImportResult> {
|
||||
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<String>, Vec<String>)> {
|
||||
let mut transforms = script_queries::list_scripts_by_project(conn, project_id)?
|
||||
.into_iter()
|
||||
.filter(|script| script.kind == ScriptKind::Transform && script.enabled)
|
||||
.collect::<Vec<_>>();
|
||||
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<String>) {
|
||||
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<String>, source: impl IntoIterator<Item = String>) {
|
||||
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<String> {
|
||||
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::<String>();
|
||||
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<String> {
|
||||
let cleaned = value
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_control())
|
||||
.collect::<String>();
|
||||
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<String> {
|
||||
value
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn list_param(value: Option<&String>) -> Vec<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,26 @@ pub fn generate_starter_site(
|
||||
metadata: &ProjectMetadata,
|
||||
posts: &[PublishedPostSource],
|
||||
_language: &str,
|
||||
) -> EngineResult<GenerationReport> {
|
||||
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<GenerationReport> {
|
||||
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)?;
|
||||
|
||||
@@ -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<Option<Media>> {
|
||||
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<String> = 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();
|
||||
|
||||
@@ -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::<i32>().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,
|
||||
|
||||
@@ -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<EntityDiff>,
|
||||
pub orphans: Vec<OrphanFile>,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<DiffReport> {
|
||||
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<Option<EntityDiff>> {
|
||||
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<T>(entity_type: &str) -> EngineResult<T> {
|
||||
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::<Vec<_>>();
|
||||
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>) -> String {
|
||||
@@ -361,6 +642,58 @@ fn diff_media(data_dir: &Path, media: &Media) -> EngineResult<Option<EntityDiff>
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_media_translation(
|
||||
data_dir: &Path,
|
||||
media: &Media,
|
||||
translation: &MediaTranslation,
|
||||
) -> EngineResult<Option<EntityDiff>> {
|
||||
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<Option<EntityDiff>> {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PostTranslation> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Project> {
|
||||
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();
|
||||
|
||||
462
crates/bds-core/src/engine/publishing.rs
Normal file
462
crates/bds-core/src/engine/publishing.rs
Normal file
@@ -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<UploadTargetKind>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
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<Self> {
|
||||
let required = |value: &Option<String>, 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<String, u64>);
|
||||
|
||||
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<PublishJob> {
|
||||
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<PublishJob> {
|
||||
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<UploadTarget> {
|
||||
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<Vec<PathBuf>> {
|
||||
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::<Vec<_>>();
|
||||
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<u64> {
|
||||
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<String>)>::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<String>)>::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::<String>::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"));
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -30,7 +30,7 @@ fn parse_script_kind(s: &str) -> Result<ScriptKind, String> {
|
||||
|
||||
/// 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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl ValidationResult {
|
||||
pub fn ok() -> Self {
|
||||
Self {
|
||||
valid: true,
|
||||
errors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fail(errors: Vec<String>) -> 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#"
|
||||
<html>
|
||||
{% if user %}
|
||||
<p>{{ user.name }}</p>
|
||||
{% endif %}
|
||||
</html>"#;
|
||||
let result = validate_liquid(content);
|
||||
assert!(result.valid, "Errors: {:?}", result.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_liquid_if() {
|
||||
let content = "{% if true %}<p>Hello</p>";
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ pub enum TranslationIssueKind {
|
||||
}
|
||||
|
||||
/// Result of translation validation.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TranslationValidationReport {
|
||||
pub db_issues: Vec<TranslationIssue>,
|
||||
pub fs_issues: Vec<TranslationIssue>,
|
||||
|
||||
@@ -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<i32, D::Error>
|
||||
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::<i32>().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<String>,
|
||||
#[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<String>,
|
||||
#[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,
|
||||
|
||||
@@ -52,7 +52,44 @@ fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option<String
|
||||
"vimeo" => 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<String, JsonValue>,
|
||||
context: &MacroRenderContext,
|
||||
) -> Option<String> {
|
||||
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 '<aside>' .. params.text .. ':' .. env.language .. '</aside>' 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, "<aside>Hallo:de</aside>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,19 @@ impl Filter for MarkdownFilter {
|
||||
fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
|
||||
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()));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String, String>,
|
||||
macro_scripts: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -97,6 +99,24 @@ pub fn build_site_render_artifacts(
|
||||
project_id: &str,
|
||||
metadata: &ProjectMetadata,
|
||||
published_posts: &[(Post, String)],
|
||||
) -> Result<SiteRenderArtifacts, Box<dyn Error + Send + Sync>> {
|
||||
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<SiteRenderArtifacts, Box<dyn Error + Send + Sync>> {
|
||||
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<PreviewRenderResult, Box<dyn Error + Send + Sync>> {
|
||||
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<String, Value>,
|
||||
bundle: &TemplateBundle,
|
||||
is_preview: bool,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
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<String, Value>,
|
||||
bundle: &TemplateBundle,
|
||||
is_preview: bool,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
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,
|
||||
|
||||
@@ -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<f64>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ScriptExecution {
|
||||
pub value: JsonValue,
|
||||
pub output: Vec<String>,
|
||||
pub progress: Vec<ScriptProgress>,
|
||||
pub toasts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ExecutionControl {
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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<ScriptExecution, String> {
|
||||
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<ScriptExecution, String> {
|
||||
if entrypoint.trim().is_empty() {
|
||||
return Err("script entrypoint is empty".into());
|
||||
}
|
||||
|
||||
let lua = sandboxed_lua()?;
|
||||
let output = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let progress = Arc::new(Mutex::new(Vec::<ScriptProgress>::new()));
|
||||
let toasts = Arc::new(Mutex::new(Vec::<String>::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::<Result<MultiValue, _>>()
|
||||
.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<Lua, String> {
|
||||
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<Mutex<Vec<String>>>,
|
||||
progress: &Arc<Mutex<Vec<ScriptProgress>>>,
|
||||
toasts: &Arc<Mutex<Vec<String>>>,
|
||||
) -> 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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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<f64>, Option<String>)| {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,20 @@ pub fn pick_folder(title: String) -> Task<Message> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Pick an existing portable project folder.
|
||||
pub fn pick_project_folder(title: String) -> Task<Message> {
|
||||
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<Message> {
|
||||
Task::perform(
|
||||
@@ -33,3 +47,26 @@ pub fn pick_media_files(title: String, filter_label: String) -> Task<Message> {
|
||||
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<Message> {
|
||||
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 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
153
crates/bds-ui/src/views/metadata_diff.rs
Normal file
153
crates/bds-ui/src/views/metadata_diff.rs
Normal file
@@ -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<DiffReport>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -111,6 +111,7 @@ pub struct SettingsViewState {
|
||||
pub available_languages: Vec<String>,
|
||||
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"),
|
||||
|
||||
114
crates/bds-ui/src/views/translation_validation.rs
Normal file
114
crates/bds-ui/src/views/translation_validation.rs
Normal file
@@ -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<TranslationValidationReport>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user