chore: source formatting and spec allignment

This commit is contained in:
2026-07-18 14:20:23 +02:00
parent a594b99e90
commit 16a210c0ad
119 changed files with 8868 additions and 5250 deletions

View File

@@ -4,7 +4,7 @@ use keyring::Entry;
use reqwest::blocking::Client;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use serde_json::{Value, json};
use crate::db::queries::setting;
use crate::engine::{EngineError, EngineResult};
@@ -167,17 +167,37 @@ pub fn load_ai_settings(conn: &Connection, offline_mode: bool) -> EngineResult<A
pub fn save_endpoint(conn: &Connection, endpoint: &AiEndpointConfig) -> EngineResult<()> {
validate_endpoint_config(endpoint)?;
let checked_at = now_unix_ms();
set_setting(conn, &endpoint_setting_key(endpoint.kind, "url"), endpoint.url.trim(), checked_at)?;
set_setting(conn, &endpoint_setting_key(endpoint.kind, "model"), endpoint.model.trim(), checked_at)?;
set_setting(
conn,
&endpoint_setting_key(endpoint.kind, "url"),
endpoint.url.trim(),
checked_at,
)?;
set_setting(
conn,
&endpoint_setting_key(endpoint.kind, "model"),
endpoint.model.trim(),
checked_at,
)?;
if endpoint.kind == AiEndpointKind::Online {
let entry = endpoint_keyring_entry(endpoint.kind)?;
if let Some(api_key) = &endpoint.api_key {
if api_key.trim().is_empty() {
entry.delete_credential().ok();
set_setting(conn, &endpoint_setting_key(endpoint.kind, "api_key_configured"), "false", checked_at)?;
set_setting(
conn,
&endpoint_setting_key(endpoint.kind, "api_key_configured"),
"false",
checked_at,
)?;
} else {
entry.set_password(api_key.trim()).map_err(keyring_error)?;
set_setting(conn, &endpoint_setting_key(endpoint.kind, "api_key_configured"), "true", checked_at)?;
set_setting(
conn,
&endpoint_setting_key(endpoint.kind, "api_key_configured"),
"true",
checked_at,
)?;
}
}
}
@@ -200,7 +220,11 @@ pub fn save_model_preferences(
}
pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<AiEndpointConfig> {
let kind = if offline_mode { AiEndpointKind::Airplane } else { AiEndpointKind::Online };
let kind = if offline_mode {
AiEndpointKind::Airplane
} else {
AiEndpointKind::Online
};
let stored = load_endpoint(conn, kind)?;
if stored.url.trim().is_empty() {
return Err(EngineError::Validation(format!(
@@ -243,14 +267,11 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<Ai
validate_endpoint_config(endpoint)?;
let client = build_http_client()?;
let request = client.get(models_url(&endpoint.url));
let response = with_auth(request, endpoint)
.send()?
.error_for_status()?;
let response = with_auth(request, endpoint).send()?.error_for_status()?;
let body: Value = response.json()?;
let models = body
.get("data")
.and_then(Value::as_array)
.ok_or_else(|| EngineError::Parse("model catalog response missing data array".to_string()))?;
let models = body.get("data").and_then(Value::as_array).ok_or_else(|| {
EngineError::Parse("model catalog response missing data array".to_string())
})?;
let mut result = Vec::new();
for model in models {
@@ -275,7 +296,11 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<Ai
let supports_vision = model
.get("modalities")
.and_then(Value::as_array)
.map(|modalities| modalities.iter().any(|value| value.as_str() == Some("vision")))
.map(|modalities| {
modalities
.iter()
.any(|value| value.as_str() == Some("vision"))
})
.unwrap_or(false);
result.push(AiModelInfo {
id,
@@ -326,9 +351,14 @@ pub fn run_one_shot(
}
});
let client = build_http_client()?;
let response = with_auth(client.post(chat_completions_url(&endpoint.url)).json(&payload), &endpoint)
.send()?
.error_for_status()?;
let response = with_auth(
client
.post(chat_completions_url(&endpoint.url))
.json(&payload),
&endpoint,
)
.send()?
.error_for_status()?;
let body: Value = response.json()?;
let content = body
.get("choices")
@@ -337,7 +367,9 @@ pub fn run_one_shot(
.and_then(|choice| choice.get("message"))
.and_then(|message| message.get("content"))
.and_then(Value::as_str)
.ok_or_else(|| EngineError::Parse("chat completions response missing message content".to_string()))?;
.ok_or_else(|| {
EngineError::Parse("chat completions response missing message content".to_string())
})?;
parse_one_shot_response(request, content)
}
@@ -347,10 +379,12 @@ fn build_http_client() -> EngineResult<Client> {
fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<StoredAiEndpointConfig> {
let url = get_optional_setting(conn, &endpoint_setting_key(kind, "url"))?.unwrap_or_default();
let model = get_optional_setting(conn, &endpoint_setting_key(kind, "model"))?.unwrap_or_default();
let api_key_configured = get_optional_setting(conn, &endpoint_setting_key(kind, "api_key_configured"))?
.map(|value| value == "true")
.unwrap_or(false);
let model =
get_optional_setting(conn, &endpoint_setting_key(kind, "model"))?.unwrap_or_default();
let api_key_configured =
get_optional_setting(conn, &endpoint_setting_key(kind, "api_key_configured"))?
.map(|value| value == "true")
.unwrap_or(false);
Ok(StoredAiEndpointConfig {
kind,
url,
@@ -361,48 +395,81 @@ fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<Stored
fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> {
if endpoint.url.trim().is_empty() {
return Err(EngineError::Validation("endpoint url is required".to_string()));
return Err(EngineError::Validation(
"endpoint url is required".to_string(),
));
}
if endpoint.model.trim().is_empty() {
return Err(EngineError::Validation("endpoint model is required".to_string()));
return Err(EngineError::Validation(
"endpoint model is required".to_string(),
));
}
if endpoint.kind == AiEndpointKind::Online
&& endpoint.api_key.as_ref().map(|value| value.trim().is_empty()).unwrap_or(true)
&& endpoint
.api_key
.as_ref()
.map(|value| value.trim().is_empty())
.unwrap_or(true)
{
return Err(EngineError::Validation("online endpoint api key is required".to_string()));
return Err(EngineError::Validation(
"online endpoint api key is required".to_string(),
));
}
Ok(())
}
fn select_model(settings: &AiSettings, endpoint: &AiEndpointConfig, operation: &OneShotOperation) -> EngineResult<String> {
fn select_model(
settings: &AiSettings,
endpoint: &AiEndpointConfig,
operation: &OneShotOperation,
) -> EngineResult<String> {
let selected = match operation {
OneShotOperation::AnalyzeImage => settings.image_model.as_ref(),
OneShotOperation::AnalyzeTaxonomy
| OneShotOperation::AnalyzePost
| OneShotOperation::DetectLanguage
| OneShotOperation::TranslatePost { .. }
| OneShotOperation::TranslateMedia { .. } => settings.title_model.as_ref().or(settings.default_model.as_ref()),
| OneShotOperation::TranslateMedia { .. } => settings
.title_model
.as_ref()
.or(settings.default_model.as_ref()),
}
.filter(|model| !model.trim().is_empty())
.cloned()
.unwrap_or_else(|| endpoint.model.clone());
if selected.trim().is_empty() {
return Err(EngineError::Validation("AI unavailable - configure model in Settings".to_string()));
return Err(EngineError::Validation(
"AI unavailable - configure model in Settings".to_string(),
));
}
Ok(selected)
}
fn build_system_prompt(base_prompt: &str, operation: &OneShotOperation) -> String {
let operation_prompt = match operation {
OneShotOperation::AnalyzeTaxonomy => "Return only JSON with tags and categories for the post.",
OneShotOperation::AnalyzePost => "Return only JSON with title, excerpt, and slug suggestions for the post.",
OneShotOperation::AnalyzeTaxonomy => {
"Return only JSON with tags and categories for the post."
}
OneShotOperation::AnalyzePost => {
"Return only JSON with title, excerpt, and slug suggestions for the post."
}
OneShotOperation::DetectLanguage => "Return only JSON with the detected language_code.",
OneShotOperation::TranslatePost { target_language } => {
return format!("{} Translate the post into {} and return only JSON with title, excerpt, and content.", base_prompt.trim(), target_language);
return format!(
"{} Translate the post into {} and return only JSON with title, excerpt, and content.",
base_prompt.trim(),
target_language
);
}
OneShotOperation::AnalyzeImage => {
"Return only JSON with title, alt, and caption suggestions for the image."
}
OneShotOperation::AnalyzeImage => "Return only JSON with title, alt, and caption suggestions for the image.",
OneShotOperation::TranslateMedia { target_language } => {
return format!("{} Translate the media metadata into {} and return only JSON with title, alt, and caption.", base_prompt.trim(), target_language);
return format!(
"{} Translate the media metadata into {} and return only JSON with title, alt, and caption.",
base_prompt.trim(),
target_language
);
}
};
if base_prompt.trim().is_empty() {
@@ -417,26 +484,31 @@ fn build_one_shot_user_content(request: &OneShotRequest) -> EngineResult<Value>
OneShotOperation::AnalyzeTaxonomy => Ok(format!(
"Suggest tags and categories for this post: {}",
serde_json::to_string(&request.content)?
).into()),
)
.into()),
OneShotOperation::AnalyzePost => Ok(format!(
"Analyze this post and suggest title, excerpt, and slug: {}",
serde_json::to_string(&request.content)?
).into()),
)
.into()),
OneShotOperation::DetectLanguage => Ok(format!(
"Detect the language of this text: {}",
serde_json::to_string(&request.content)?
).into()),
)
.into()),
OneShotOperation::TranslatePost { target_language } => Ok(format!(
"Translate this post to {}: {}",
target_language,
serde_json::to_string(&request.content)?
).into()),
)
.into()),
OneShotOperation::AnalyzeImage => build_image_analysis_user_content(&request.content),
OneShotOperation::TranslateMedia { target_language } => Ok(format!(
"Translate this media metadata to {}: {}",
target_language,
serde_json::to_string(&request.content)?
).into()),
)
.into()),
}
}
@@ -549,14 +621,29 @@ fn response_schema(operation: &OneShotOperation) -> (&'static str, Value) {
}
}
fn parse_one_shot_response(request: &OneShotRequest, content: &str) -> EngineResult<OneShotResponse> {
fn parse_one_shot_response(
request: &OneShotRequest,
content: &str,
) -> EngineResult<OneShotResponse> {
Ok(match request.operation {
OneShotOperation::AnalyzeTaxonomy => OneShotResponse::Taxonomy(serde_json::from_str(content)?),
OneShotOperation::AnalyzePost => OneShotResponse::PostAnalysis(serde_json::from_str(content)?),
OneShotOperation::DetectLanguage => OneShotResponse::LanguageDetection(serde_json::from_str(content)?),
OneShotOperation::TranslatePost { .. } => OneShotResponse::Translation(serde_json::from_str(content)?),
OneShotOperation::AnalyzeImage => OneShotResponse::ImageAnalysis(serde_json::from_str(content)?),
OneShotOperation::TranslateMedia { .. } => OneShotResponse::MediaTranslation(serde_json::from_str(content)?),
OneShotOperation::AnalyzeTaxonomy => {
OneShotResponse::Taxonomy(serde_json::from_str(content)?)
}
OneShotOperation::AnalyzePost => {
OneShotResponse::PostAnalysis(serde_json::from_str(content)?)
}
OneShotOperation::DetectLanguage => {
OneShotResponse::LanguageDetection(serde_json::from_str(content)?)
}
OneShotOperation::TranslatePost { .. } => {
OneShotResponse::Translation(serde_json::from_str(content)?)
}
OneShotOperation::AnalyzeImage => {
OneShotResponse::ImageAnalysis(serde_json::from_str(content)?)
}
OneShotOperation::TranslateMedia { .. } => {
OneShotResponse::MediaTranslation(serde_json::from_str(content)?)
}
})
}
@@ -565,7 +652,11 @@ fn endpoint_setting_key(kind: AiEndpointKind, suffix: &str) -> String {
}
fn endpoint_keyring_entry(kind: AiEndpointKind) -> EngineResult<Entry> {
Entry::new(KEYRING_SERVICE, &format!("{}.{}", KEYRING_SETTING_PREFIX, kind.as_str())).map_err(keyring_error)
Entry::new(
KEYRING_SERVICE,
&format!("{}.{}", KEYRING_SETTING_PREFIX, kind.as_str()),
)
.map_err(keyring_error)
}
fn keyring_error(error: keyring::Error) -> EngineError {
@@ -577,7 +668,12 @@ fn set_setting(conn: &Connection, key: &str, value: &str, updated_at: i64) -> En
Ok(())
}
fn set_optional_setting(conn: &Connection, key: &str, value: Option<&str>, updated_at: i64) -> EngineResult<()> {
fn set_optional_setting(
conn: &Connection,
key: &str,
value: Option<&str>,
updated_at: i64,
) -> EngineResult<()> {
set_setting(conn, key, value.unwrap_or(""), updated_at)
}
@@ -691,12 +787,15 @@ mod tests {
#[test]
fn refresh_model_catalog_parses_openai_models_shape() {
let server = spawn_test_server(|request| {
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
http_ok(
r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#,
)
}, 1);
let server = spawn_test_server(
|request| {
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
http_ok(
r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#,
)
},
1,
);
let models = refresh_model_catalog(&AiEndpointConfig {
kind: AiEndpointKind::Airplane,
url: server,
@@ -713,16 +812,22 @@ mod tests {
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
fn run_one_shot_uses_active_endpoint_and_parses_response() {
clear_keyring(AiEndpointKind::Online);
let server = spawn_test_server(|request| {
if request.starts_with("GET /v1/models HTTP/1.1") {
return http_ok(r#"{"data":[{"id":"gpt-4.1-mini"}]}"#);
}
assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1"));
assert!(request.contains("authorization: Bearer secret-token") || request.contains("Authorization: Bearer secret-token"));
http_ok(
r#"{"choices":[{"message":{"content":"{\"title\":\"Better title\",\"excerpt\":\"Short summary\",\"slug\":\"better-title\"}"}}]}"#,
)
}, 1);
let server = spawn_test_server(
|request| {
if request.starts_with("GET /v1/models HTTP/1.1") {
return http_ok(r#"{"data":[{"id":"gpt-4.1-mini"}]}"#);
}
assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1"));
assert!(
request.contains("authorization: Bearer secret-token")
|| request.contains("Authorization: Bearer secret-token")
);
http_ok(
r#"{"choices":[{"message":{"content":"{\"title\":\"Better title\",\"excerpt\":\"Short summary\",\"slug\":\"better-title\"}"}}]}"#,
)
},
1,
);
let db = setup();
save_endpoint(
@@ -769,9 +874,17 @@ mod tests {
let parts = content.as_array().unwrap();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0]["type"], "text");
assert!(parts[0]["text"].as_str().unwrap().contains("Existing title"));
assert!(
parts[0]["text"]
.as_str()
.unwrap()
.contains("Existing title")
);
assert_eq!(parts[1]["type"], "image_url");
assert_eq!(parts[1]["image_url"]["url"], "data:image/jpeg;base64,abc123");
assert_eq!(
parts[1]["image_url"]["url"],
"data:image/jpeg;base64,abc123"
);
}
#[test]
@@ -940,7 +1053,10 @@ mod tests {
run_one_shot(db.conn(), true, &request).unwrap()
}
fn spawn_test_server(handler: impl Fn(String) -> String + Send + 'static, request_count: usize) -> String {
fn spawn_test_server(
handler: impl Fn(String) -> String + Send + 'static,
request_count: usize,
) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
thread::spawn(move || {
@@ -963,4 +1079,4 @@ mod tests {
body
)
}
}
}

View File

@@ -69,18 +69,17 @@ mod tests {
let _ = db.migrate();
let tmp = tempfile::tempdir().unwrap();
let p = engine::project::create_project(
db.conn(), "Test", Some(tmp.path().to_str().unwrap()),
).unwrap();
let p =
engine::project::create_project(db.conn(), "Test", Some(tmp.path().to_str().unwrap()))
.unwrap();
regenerate_calendar(db.conn(), tmp.path(), &p.id).unwrap();
let cal_path = tmp.path().join("html").join("calendar.json");
assert!(cal_path.exists());
let data: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(&cal_path).unwrap()
).unwrap();
let data: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&cal_path).unwrap()).unwrap();
assert!(data["years"].as_object().unwrap().is_empty());
}
}

View File

@@ -72,10 +72,26 @@ mod tests {
#[test]
fn display_variants() {
assert!(EngineError::Parse("bad yaml".into()).to_string().contains("parse error"));
assert!(EngineError::NotFound("post 123".into()).to_string().contains("not found"));
assert!(EngineError::Conflict("slug taken".into()).to_string().contains("conflict"));
assert!(EngineError::Validation("title empty".into()).to_string().contains("validation"));
assert!(
EngineError::Parse("bad yaml".into())
.to_string()
.contains("parse error")
);
assert!(
EngineError::NotFound("post 123".into())
.to_string()
.contains("not found")
);
assert!(
EngineError::Conflict("slug taken".into())
.to_string()
.contains("conflict")
);
assert!(
EngineError::Validation("title empty".into())
.to_string()
.contains("validation")
);
}
#[test]

View File

@@ -24,6 +24,31 @@ pub struct PublishedPostSource {
pub body_markdown: String,
}
/// Whether a post has a published snapshot eligible for site generation.
pub fn has_published_snapshot(post: &Post) -> bool {
matches!(
post.status,
crate::model::PostStatus::Published | crate::model::PostStatus::Draft
) && !post.file_path.trim().is_empty()
}
/// Load the last-published body from disk, never from draft database content.
pub fn load_published_post_source(
data_dir: &Path,
post: Post,
) -> EngineResult<Option<PublishedPostSource>> {
if !has_published_snapshot(&post) {
return Ok(None);
}
let raw = std::fs::read_to_string(data_dir.join(&post.file_path))?;
let (_, body_markdown) =
crate::util::frontmatter::read_post_file(&raw).map_err(EngineError::Parse)?;
Ok(Some(PublishedPostSource {
post,
body_markdown,
}))
}
#[derive(Debug, Default, Clone)]
pub struct GenerationReport {
pub written_paths: Vec<String>,
@@ -56,11 +81,19 @@ pub fn generate_starter_site(
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>();
let artifacts = build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let artifacts =
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 {
write_out(conn, output_dir, project_id, &page.relative_path, &page.html, &mut report)?;
write_out(
conn,
output_dir,
project_id,
&page.relative_path,
&page.html,
&mut report,
)?;
}
write_bundled_site_assets(conn, output_dir, project_id, &mut report)?;
@@ -70,13 +103,24 @@ pub fn generate_starter_site(
output_dir,
project_id,
"calendar.json",
&build_calendar_json(&list_posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
&build_calendar_json(
&list_posts
.iter()
.map(|source| source.post.clone())
.collect::<Vec<_>>(),
)?,
&mut report,
)?;
for render_language in render_languages(metadata) {
let localized_posts = localized_sources(conn, &data_dir, &list_posts, &render_language, metadata)?;
let prefix = if render_language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
let localized_posts =
localized_sources(conn, &data_dir, &list_posts, &render_language, metadata)?;
let prefix = if render_language
== metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string())
{
String::new()
} else {
format!("{}/", render_language)
@@ -85,19 +129,44 @@ pub fn generate_starter_site(
if prefix.is_empty() {
write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?;
}
write_out(conn, output_dir, project_id, &format!("{prefix}feed.xml"), &rss, &mut report)?;
write_out(conn, output_dir, project_id, &format!("{prefix}atom.xml"), &build_atom_xml(metadata, &localized_posts, &render_language), &mut report)?;
write_out(
conn,
output_dir,
project_id,
&format!("{prefix}feed.xml"),
&rss,
&mut report,
)?;
write_out(
conn,
output_dir,
project_id,
&format!("{prefix}atom.xml"),
&build_atom_xml(metadata, &localized_posts, &render_language),
&mut report,
)?;
write_out(
conn,
output_dir,
project_id,
&format!("{prefix}sitemap.xml"),
&build_sitemap_xml(metadata, &artifacts.pages, &localized_posts, &render_language),
&build_sitemap_xml(
metadata,
&artifacts.pages,
&localized_posts,
&render_language,
),
&mut report,
)?;
}
write_pagefind_indexes(conn, output_dir, project_id, &artifacts.pagefind_documents, &mut report)?;
write_pagefind_indexes(
conn,
output_dir,
project_id,
&artifacts.pagefind_documents,
&mut report,
)?;
Ok(report)
}
@@ -151,20 +220,22 @@ pub fn apply_validation_sections(
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>();
let artifacts = build_site_render_artifacts(
conn,
&data_dir,
project_id,
metadata,
&input_posts,
)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let artifacts =
build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let mut report = GenerationReport::default();
let expected_paths = expected_paths_for_sections(metadata, &artifacts.pages, &section_set);
for page in &artifacts.pages {
if path_matches_sections(&page.relative_path, &section_set) {
write_out(conn, output_dir, project_id, &page.relative_path, &page.html, &mut report)?;
write_out(
conn,
output_dir,
project_id,
&page.relative_path,
&page.html,
&mut report,
)?;
}
}
@@ -175,19 +246,24 @@ pub fn apply_validation_sections(
output_dir,
project_id,
"calendar.json",
&build_calendar_json(&list_posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
&build_calendar_json(
&list_posts
.iter()
.map(|source| source.post.clone())
.collect::<Vec<_>>(),
)?,
&mut report,
)?;
for render_language in render_languages(metadata) {
let localized_posts = localized_sources(
conn,
&data_dir,
&list_posts,
&render_language,
metadata,
)?;
let prefix = if render_language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
let localized_posts =
localized_sources(conn, &data_dir, &list_posts, &render_language, metadata)?;
let prefix = if render_language
== metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string())
{
String::new()
} else {
format!("{}/", render_language)
@@ -196,7 +272,14 @@ pub fn apply_validation_sections(
if prefix.is_empty() {
write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?;
}
write_out(conn, output_dir, project_id, &format!("{prefix}feed.xml"), &rss, &mut report)?;
write_out(
conn,
output_dir,
project_id,
&format!("{prefix}feed.xml"),
&rss,
&mut report,
)?;
write_out(
conn,
output_dir,
@@ -210,40 +293,29 @@ pub fn apply_validation_sections(
output_dir,
project_id,
&format!("{prefix}sitemap.xml"),
&build_sitemap_xml(metadata, &artifacts.pages, &localized_posts, &render_language),
&build_sitemap_xml(
metadata,
&artifacts.pages,
&localized_posts,
&render_language,
),
&mut report,
)?;
}
}
remove_extra_section_paths(output_dir, &expected_paths, &section_set, &mut report)?;
write_pagefind_indexes(conn, output_dir, project_id, &artifacts.pagefind_documents, &mut report)?;
write_pagefind_indexes(
conn,
output_dir,
project_id,
&artifacts.pagefind_documents,
&mut report,
)?;
Ok(report)
}
fn build_media_rewrite_map(
conn: &Connection,
project_id: &str,
) -> EngineResult<HashMap<String, String>> {
let media_items = queries::media::list_media_by_project(conn, project_id)?;
let mut map = HashMap::new();
for media in media_items {
let canonical_path = if media.file_path.starts_with('/') {
media.file_path.clone()
} else {
format!("/{}", media.file_path.trim_start_matches('/'))
};
map.insert(format!("bds-media://{}", media.id), canonical_path.clone());
let relative_key = media.file_path.trim_start_matches('/').to_lowercase();
map.insert(relative_key, canonical_path);
}
Ok(map)
}
fn write_out(
conn: &Connection,
output_dir: &Path,
@@ -256,7 +328,9 @@ fn write_out(
.map_err(|error| EngineError::Parse(error.to_string()))?
{
GeneratedWriteOutcome::Written => report.written_paths.push(relative_path.to_string()),
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(relative_path.to_string()),
GeneratedWriteOutcome::SkippedUnchanged => {
report.skipped_paths.push(relative_path.to_string())
}
}
Ok(())
}
@@ -273,10 +347,13 @@ fn write_pagefind_indexes(
.build()
.map_err(EngineError::Io)?;
let grouped = documents.iter().fold(HashMap::<String, Vec<&crate::render::PagefindDocument>>::new(), |mut acc, doc| {
acc.entry(doc.language.clone()).or_default().push(doc);
acc
});
let grouped = documents.iter().fold(
HashMap::<String, Vec<&crate::render::PagefindDocument>>::new(),
|mut acc, doc| {
acc.entry(doc.language.clone()).or_default().push(doc);
acc
},
);
for (language, docs) in grouped {
let config = PagefindServiceConfig::builder()
@@ -292,9 +369,16 @@ fn write_pagefind_indexes(
.await
.map_err(|error| EngineError::Parse(error.to_string()))?;
}
let files = index.get_files().await.map_err(|error| EngineError::Parse(error.to_string()))?;
let files = index
.get_files()
.await
.map_err(|error| EngineError::Parse(error.to_string()))?;
for file in files {
let relative = file.filename.to_string_lossy().trim_start_matches('/').to_string();
let relative = file
.filename
.to_string_lossy()
.trim_start_matches('/')
.to_string();
match write_generated_bytes(conn, output_dir, project_id, &relative, &file.contents)
.map_err(|error| EngineError::Parse(error.to_string()))?
{
@@ -332,7 +416,12 @@ fn expected_paths_for_sections(
expected.insert("calendar.json".to_string());
expected.insert("rss.xml".to_string());
for language in render_languages(metadata) {
let prefix = if language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
let prefix = if language
== metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string())
{
String::new()
} else {
format!("{language}/")
@@ -376,7 +465,10 @@ fn remove_extra_section_paths(
{
continue;
}
if !matches_generated_extension(&rel) || !path_matches_sections(&rel, sections) || expected.contains(&rel) {
if !matches_generated_extension(&rel)
|| !path_matches_sections(&rel, sections)
|| expected.contains(&rel)
{
continue;
}
std::fs::remove_file(entry.path()).map_err(EngineError::Io)?;
@@ -412,9 +504,14 @@ fn classify_generated_path(path: &str) -> Option<GenerationSection> {
["category", ..] => Some(GenerationSection::Category),
["tag", ..] => Some(GenerationSection::Tag),
[year, "index.html"] if is_year_segment(year) => Some(GenerationSection::Date),
[year, month, "index.html"] if is_year_segment(year) && is_month_segment(month) => Some(GenerationSection::Date),
[year, month, "index.html"] if is_year_segment(year) && is_month_segment(month) => {
Some(GenerationSection::Date)
}
[year, month, day, _slug, "index.html"]
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) => Some(GenerationSection::Single),
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) =>
{
Some(GenerationSection::Single)
}
_ => None,
}
}
@@ -425,7 +522,10 @@ fn has_language_prefix(parts: &[&str]) -> bool {
!is_year_segment(first)
&& *first != "category"
&& *first != "tag"
&& (*second == "index.html" || is_year_segment(second) || *second == "category" || *second == "tag")
&& (*second == "index.html"
|| is_year_segment(second)
|| *second == "category"
|| *second == "tag")
}
_ => false,
}
@@ -468,14 +568,22 @@ fn section_sort_key(section: &GenerationSection) -> u8 {
}
fn report_is_empty(report: &SiteValidationReport) -> bool {
report.missing_pages.is_empty() && report.extra_pages.is_empty() && report.stale_pages.is_empty()
report.missing_pages.is_empty()
&& report.extra_pages.is_empty()
&& report.stale_pages.is_empty()
}
fn render_languages(metadata: &ProjectMetadata) -> Vec<String> {
let main = metadata.main_language.clone().unwrap_or_else(|| "en".to_string());
let main = metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string());
let mut languages = vec![main.clone()];
for language in &metadata.blog_languages {
if !languages.iter().any(|existing| existing.eq_ignore_ascii_case(language)) {
if !languages
.iter()
.any(|existing| existing.eq_ignore_ascii_case(language))
{
languages.push(language.clone());
}
}
@@ -496,9 +604,17 @@ fn localized_sources(
localized.push(source.clone());
continue;
}
if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(conn, &source.post.id, language) {
let raw = std::fs::read_to_string(data_dir.join(translation.file_path.trim_start_matches('/')))
.map_err(EngineError::Io)?;
if let Ok(translation) =
queries::post_translation::get_post_translation_by_post_and_language(
conn,
&source.post.id,
language,
)
{
let raw = std::fs::read_to_string(
data_dir.join(translation.file_path.trim_start_matches('/')),
)
.map_err(EngineError::Io)?;
let (_, body) = crate::util::frontmatter::read_translation_file(&raw)
.map_err(EngineError::Parse)?;
let mut translated_post = source.post.clone();
@@ -524,7 +640,8 @@ fn filter_posts_for_lists(
posts: &[PublishedPostSource],
category_settings: &HashMap<String, CategorySettings>,
) -> Vec<PublishedPostSource> {
posts.iter()
posts
.iter()
.filter(|source| {
!source.post.categories.iter().any(|category| {
category_settings
@@ -537,8 +654,16 @@ fn filter_posts_for_lists(
.collect()
}
fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
fn build_rss_xml(
metadata: &ProjectMetadata,
posts: &[PublishedPostSource],
language: &str,
) -> String {
let base_url = metadata
.public_url
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let last_build = posts
.iter()
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
@@ -557,22 +682,46 @@ fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lang
];
for source in posts {
let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en")));
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at)).unwrap_or_else(Utc::now);
let url = format!(
"{base_url}{}",
build_canonical_post_path(
&source.post,
language,
metadata.main_language.as_deref().unwrap_or("en")
)
);
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
.unwrap_or_else(Utc::now);
xml.push(" <item>".to_string());
xml.push(format!(" <title>{}</title>", escape_xml(&source.post.title)));
xml.push(format!(
" <title>{}</title>",
escape_xml(&source.post.title)
));
xml.push(format!(" <link>{url}</link>"));
xml.push(format!(" <guid isPermaLink=\"true\">{url}</guid>"));
xml.push(format!(" <pubDate>{}</pubDate>", published.format("%a, %d %b %Y %H:%M:%S GMT")));
xml.push(format!(
" <pubDate>{}</pubDate>",
published.format("%a, %d %b %Y %H:%M:%S GMT")
));
if let Some(author) = &source.post.author {
xml.push(format!(" <author>{}</author>", escape_xml(author)));
}
xml.push(format!(" <dc:language>{}</dc:language>", escape_xml(language)));
xml.push(format!(
" <dc:language>{}</dc:language>",
escape_xml(language)
));
let html = render_markdown_to_html(&source.body_markdown);
xml.push(format!(" <description><![CDATA[{html}]]></description>"));
xml.push(format!(" <content:encoded><![CDATA[{html}]]></content:encoded>"));
xml.push(format!(
" <description><![CDATA[{html}]]></description>"
));
xml.push(format!(
" <content:encoded><![CDATA[{html}]]></content:encoded>"
));
for category in &source.post.categories {
xml.push(format!(" <category>{}</category>", escape_xml(category)));
xml.push(format!(
" <category>{}</category>",
escape_xml(category)
));
}
for tag in &source.post.tags {
xml.push(format!(" <category>{}</category>", escape_xml(tag)));
@@ -585,8 +734,16 @@ fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lang
xml.join("\n")
}
fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
fn build_atom_xml(
metadata: &ProjectMetadata,
posts: &[PublishedPostSource],
language: &str,
) -> String {
let base_url = metadata
.public_url
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let main_language = metadata.main_language.as_deref().unwrap_or("en");
let feed_prefix = language_prefix(language, main_language);
let updated = posts
@@ -598,30 +755,59 @@ fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lan
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
"<feed xmlns=\"http://www.w3.org/2005/Atom\">".to_string(),
format!(" <title>{}</title>", escape_xml(&metadata.name)),
format!(" <subtitle>{}</subtitle>", escape_xml(metadata.description.as_deref().unwrap_or(""))),
format!(
" <subtitle>{}</subtitle>",
escape_xml(metadata.description.as_deref().unwrap_or(""))
),
format!(" <id>{base_url}/</id>"),
format!(" <link href=\"{base_url}{feed_prefix}/\" rel=\"alternate\" />"),
format!(" <link href=\"{base_url}{feed_prefix}/atom.xml\" rel=\"self\" />"),
format!(" <updated>{}</updated>", updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
format!(
" <updated>{}</updated>",
updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
),
];
for source in posts {
let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en")));
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at)).unwrap_or_else(Utc::now);
let url = format!(
"{base_url}{}",
build_canonical_post_path(
&source.post,
language,
metadata.main_language.as_deref().unwrap_or("en")
)
);
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
.unwrap_or_else(Utc::now);
let html = render_markdown_to_html(&source.body_markdown);
xml.push(format!(" <entry xml:lang=\"{}\">", escape_xml(language)));
xml.push(format!(" <title>{}</title>", escape_xml(&source.post.title)));
xml.push(format!(
" <title>{}</title>",
escape_xml(&source.post.title)
));
xml.push(format!(" <id>{url}</id>"));
xml.push(format!(" <link href=\"{url}\" />"));
xml.push(format!(" <updated>{}</updated>", published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)));
xml.push(format!(" <published>{}</published>", published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)));
xml.push(format!(
" <updated>{}</updated>",
published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
));
xml.push(format!(
" <published>{}</published>",
published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
));
if let Some(author) = &source.post.author {
xml.push(format!(" <author><name>{}</name></author>", escape_xml(author)));
xml.push(format!(
" <author><name>{}</name></author>",
escape_xml(author)
));
}
xml.push(format!(" <summary type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></summary>"));
xml.push(format!(" <content type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></content>"));
for category in &source.post.categories {
xml.push(format!(" <category term=\"{}\" />", escape_xml(category)));
xml.push(format!(
" <category term=\"{}\" />",
escape_xml(category)
));
}
for tag in &source.post.tags {
xml.push(format!(" <category term=\"{}\" />", escape_xml(tag)));
@@ -639,7 +825,11 @@ fn build_sitemap_xml(
posts: &[PublishedPostSource],
language: &str,
) -> String {
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
let base_url = metadata
.public_url
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let main_language = metadata.main_language.as_deref().unwrap_or("en");
let languages = render_languages(metadata);
let index_lastmod = posts
@@ -694,7 +884,10 @@ fn build_sitemap_xml(
alternate.url_path,
));
}
if let Some(default_page) = alternates.iter().find(|alternate| alternate.language == main_language) {
if let Some(default_page) = alternates
.iter()
.find(|alternate| alternate.language == main_language)
{
xml.push(format!(
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{base_url}{}\" />",
default_page.url_path,
@@ -746,7 +939,9 @@ fn logical_page_key(relative_path: &str, languages: &[String], main_language: &s
if first.eq_ignore_ascii_case(main_language) {
return parts.collect::<Vec<_>>().join("/");
}
if languages.iter().any(|language| language.eq_ignore_ascii_case(first) && !language.eq_ignore_ascii_case(main_language)) {
if languages.iter().any(|language| {
language.eq_ignore_ascii_case(first) && !language.eq_ignore_ascii_case(main_language)
}) {
return parts.collect::<Vec<_>>().join("/");
}
relative_path.to_string()
@@ -763,4 +958,4 @@ fn escape_xml(value: &str) -> String {
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
}

View File

@@ -12,11 +12,11 @@ use crate::db::queries::post_media as qpm;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Media, MediaTranslation};
use crate::util::sidecar::{
read_sidecar, read_translation_sidecar, MediaSidecar, MediaTranslationSidecar,
MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar,
};
use crate::util::thumbnail::{
generate_all_thumbnails, image_dimensions, mime_from_extension, ThumbnailFormat,
THUMBNAIL_SIZES,
THUMBNAIL_SIZES, ThumbnailFormat, generate_all_thumbnails, image_dimensions,
mime_from_extension,
};
use crate::util::{
atomic_write_str, content_hash, media_dir_path, media_sidecar_path,
@@ -46,6 +46,10 @@ const SUPPORTED_IMAGE_TYPES: &[&str] = &[
];
/// Import a media file (image, etc.) into the project.
#[expect(
clippy::too_many_arguments,
reason = "arguments are the user-supplied media metadata fields"
)]
pub fn import_media(
conn: &Connection,
data_dir: &Path,
@@ -142,6 +146,10 @@ pub fn import_media(
}
/// Update a media item's metadata fields.
#[expect(
clippy::too_many_arguments,
reason = "optional arguments represent independent media field changes"
)]
pub fn update_media(
conn: &Connection,
data_dir: &Path,
@@ -655,9 +663,9 @@ fn rebuild_translation_sidecar(
#[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::db::Database;
use image::DynamicImage;
use tempfile::TempDir;

View File

@@ -1,6 +1,10 @@
use std::fs;
use std::path::Path;
use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::{Reader, Writer, XmlVersion, escape::escape};
use crate::engine::EngineError;
use crate::engine::EngineResult;
use crate::util::atomic_write_str;
@@ -53,14 +57,14 @@ pub fn read_menu(data_dir: &Path) -> EngineResult<Vec<MenuItem>> {
}]);
}
let content = fs::read_to_string(&path)?;
Ok(parse_opml(&content))
parse_opml(&content)
}
/// Write the navigation menu to meta/menu.opml.
/// Per menu.allium UpdateMenu rule: Home entry is always extracted and prepended.
pub fn write_menu(data_dir: &Path, items: &[MenuItem]) -> EngineResult<()> {
let normalized = normalize_menu(items);
let opml = serialize_opml(&normalized);
let opml = serialize_opml(&normalized)?;
let path = data_dir.join("meta").join("menu.opml");
atomic_write_str(&path, &opml)?;
Ok(())
@@ -74,7 +78,7 @@ pub fn default_menu_opml() -> String {
slug: None,
children: Vec::new(),
}];
serialize_opml(&items)
serialize_opml(&items).expect("writing menu XML to memory cannot fail")
}
/// Per menu.allium HomeAlwaysPresent: ensure Home is always first.
@@ -95,211 +99,136 @@ fn normalize_menu(items: &[MenuItem]) -> Vec<MenuItem> {
}
/// Parse OPML 2.0 XML into menu items.
fn parse_opml(content: &str) -> Vec<MenuItem> {
// Simple XML parsing using quick-xml-style manual parsing.
// OPML structure: <opml><body><outline .../></body></opml>
fn parse_opml(content: &str) -> EngineResult<Vec<MenuItem>> {
let mut reader = Reader::from_str(content);
reader.config_mut().trim_text(true);
let mut items = Vec::new();
parse_outlines(content, &mut items);
let mut parents = Vec::new();
let mut in_body = false;
// Ensure HomeAlwaysPresent
if items.is_empty() || items[0].kind != MenuItemKind::Home {
let without_home: Vec<MenuItem> = items
.into_iter()
.filter(|i| i.kind != MenuItemKind::Home)
.collect();
let mut normalized = vec![MenuItem {
kind: MenuItemKind::Home,
label: "Home".to_string(),
slug: None,
children: Vec::new(),
}];
normalized.extend(without_home);
return normalized;
}
items
}
/// Simple OPML outline parser.
/// Parses <outline text="..." type="..." htmlUrl="..."> elements.
fn parse_outlines(xml: &str, items: &mut Vec<MenuItem>) {
// Find all outline elements at the body level
let body_start = xml.find("<body>");
let body_end = xml.find("</body>");
if body_start.is_none() || body_end.is_none() {
return;
}
let body = &xml[body_start.unwrap() + 6..body_end.unwrap()];
parse_outline_children(body, items);
}
fn parse_outline_children(content: &str, items: &mut Vec<MenuItem>) {
let mut pos = 0;
while pos < content.len() {
// Find next <outline
let Some(start) = content[pos..].find("<outline") else {
break;
};
let abs_start = pos + start;
let tag_start = abs_start;
// Find the end of the opening tag
let rest = &content[tag_start..];
let is_self_closing;
let tag_end;
if let Some(sc) = rest.find("/>") {
if let Some(gt) = rest.find('>') {
if sc < gt {
is_self_closing = true;
tag_end = tag_start + sc + 2;
} else {
is_self_closing = false;
tag_end = tag_start + gt + 1;
}
} else {
is_self_closing = true;
tag_end = tag_start + sc + 2;
loop {
match reader.read_event() {
Ok(Event::Start(event)) if event.name().as_ref() == b"body" => in_body = true,
Ok(Event::End(event)) if event.name().as_ref() == b"body" => in_body = false,
Ok(Event::Start(event)) if in_body && event.name().as_ref() == b"outline" => {
parents.push(parse_outline(&event)?);
}
} else if let Some(gt) = rest.find('>') {
is_self_closing = false;
tag_end = tag_start + gt + 1;
} else {
break;
}
let tag_content = &content[tag_start..tag_end];
// Extract attributes
let label = extract_attr(tag_content, "text")
.unwrap_or_else(|| "Untitled".to_string());
let kind_str = extract_attr(tag_content, "type")
.unwrap_or_else(|| "page".to_string());
let slug = extract_attr(tag_content, "htmlUrl");
let kind = MenuItemKind::from_str(&kind_str);
let mut children = Vec::new();
let after_tag;
if is_self_closing {
after_tag = tag_end;
} else {
// Find matching </outline>
let inner = &content[tag_end..];
if let Some(close_idx) = find_closing_outline(inner) {
let inner_content = &inner[..close_idx];
parse_outline_children(inner_content, &mut children);
after_tag = tag_end + close_idx + "</outline>".len();
} else {
after_tag = tag_end;
Ok(Event::Empty(event)) if in_body && event.name().as_ref() == b"outline" => {
attach_outline(parse_outline(&event)?, &mut parents, &mut items);
}
}
items.push(MenuItem {
kind,
label,
slug,
children,
});
pos = after_tag;
}
}
fn find_closing_outline(content: &str) -> Option<usize> {
let mut depth = 0i32;
let mut pos = 0;
while pos < content.len() {
if content[pos..].starts_with("<outline") {
if let Some(sc) = content[pos..].find("/>") {
if let Some(gt) = content[pos..].find('>') {
if sc < gt {
pos += sc + 2;
continue;
}
}
Ok(Event::End(event)) if event.name().as_ref() == b"outline" => {
let item = parents
.pop()
.ok_or_else(|| EngineError::Parse("unexpected </outline>".to_string()))?;
attach_outline(item, &mut parents, &mut items);
}
depth += 1;
pos += 8; // skip past "<outline"
} else if content[pos..].starts_with("</outline>") {
if depth == 0 {
return Some(pos);
}
depth -= 1;
pos += 10;
} else {
pos += 1;
Ok(Event::Eof) => break,
Ok(_) => {}
Err(error) => return Err(EngineError::Parse(error.to_string())),
}
}
None
if !parents.is_empty() {
return Err(EngineError::Parse("unclosed <outline>".to_string()));
}
Ok(normalize_menu(&items))
}
fn extract_attr(tag: &str, name: &str) -> Option<String> {
let pattern = format!("{name}=\"");
let start = tag.find(&pattern)?;
let value_start = start + pattern.len();
let rest = &tag[value_start..];
let end = rest.find('"')?;
let value = &rest[..end];
// Unescape XML entities
Some(
value
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'"),
)
fn parse_outline(event: &BytesStart<'_>) -> EngineResult<MenuItem> {
let mut label = "Untitled".to_string();
let mut kind = MenuItemKind::Page;
let mut slug = None;
for attribute in event.attributes() {
let attribute = attribute.map_err(|error| EngineError::Parse(error.to_string()))?;
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, event.decoder())
.map_err(|error| EngineError::Parse(error.to_string()))?
.into_owned();
match attribute.key.as_ref() {
b"text" => label = value,
b"type" => kind = MenuItemKind::from_str(&value),
b"htmlUrl" => slug = Some(value),
_ => {}
}
}
Ok(MenuItem {
kind,
label,
slug,
children: Vec::new(),
})
}
fn attach_outline(item: MenuItem, parents: &mut [MenuItem], items: &mut Vec<MenuItem>) {
if let Some(parent) = parents.last_mut() {
parent.children.push(item);
} else {
items.push(item);
}
}
/// Serialize menu items to OPML 2.0 format.
fn serialize_opml(items: &[MenuItem]) -> String {
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str("<opml version=\"2.0\">\n");
out.push_str(" <head><title>Blog Menu</title></head>\n");
out.push_str(" <body>\n");
fn serialize_opml(items: &[MenuItem]) -> EngineResult<String> {
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2);
writer
.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
.map_err(|error| EngineError::Parse(error.to_string()))?;
let mut opml = BytesStart::new("opml");
opml.push_attribute(("version", "2.0"));
writer
.write_event(Event::Start(opml))
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::Start(BytesStart::new("head")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::Start(BytesStart::new("title")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::Text(BytesText::new("Blog Menu")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::End(BytesEnd::new("title")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::End(BytesEnd::new("head")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::Start(BytesStart::new("body")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
for item in items {
serialize_outline(&mut out, item, 2);
write_outline(&mut writer, item).map_err(|error| EngineError::Parse(error.to_string()))?;
}
out.push_str(" </body>\n");
out.push_str("</opml>\n");
out
writer
.write_event(Event::End(BytesEnd::new("body")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::End(BytesEnd::new("opml")))
.map_err(|error| EngineError::Parse(error.to_string()))?;
String::from_utf8(writer.into_inner()).map_err(|error| EngineError::Parse(error.to_string()))
}
fn serialize_outline(out: &mut String, item: &MenuItem, indent: usize) {
let pad = " ".repeat(indent);
out.push_str(&pad);
out.push_str("<outline");
out.push_str(&format!(
" text=\"{}\"",
xml_escape(&item.label)
));
out.push_str(&format!(
" type=\"{}\"",
item.kind.as_str()
));
if let Some(ref slug) = item.slug {
out.push_str(&format!(
" htmlUrl=\"{}\"",
xml_escape(slug)
));
fn write_outline(writer: &mut Writer<Vec<u8>>, item: &MenuItem) -> quick_xml::Result<()> {
let label = escape(&item.label);
let slug = item.slug.as_deref().map(escape);
let mut outline = BytesStart::new("outline");
outline.push_attribute(("text", label.as_ref()));
outline.push_attribute(("type", item.kind.as_str()));
if let Some(slug) = &slug {
outline.push_attribute(("htmlUrl", slug.as_ref()));
}
if item.children.is_empty() {
out.push_str("/>\n");
writer.write_event(Event::Empty(outline))?;
} else {
out.push_str(">\n");
writer.write_event(Event::Start(outline))?;
for child in &item.children {
serialize_outline(out, child, indent + 1);
write_outline(writer, child)?;
}
out.push_str(&pad);
out.push_str("</outline>\n");
writer.write_event(Event::End(BytesEnd::new("outline")))?;
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
Ok(())
}
#[cfg(test)]
@@ -332,18 +261,16 @@ mod tests {
kind: MenuItemKind::Submenu,
label: "Categories".into(),
slug: None,
children: vec![
MenuItem {
kind: MenuItemKind::CategoryArchive,
label: "Tech".into(),
slug: Some("/category/tech/".into()),
children: Vec::new(),
},
],
children: vec![MenuItem {
kind: MenuItemKind::CategoryArchive,
label: "Tech".into(),
slug: Some("/category/tech/".into()),
children: Vec::new(),
}],
},
];
let opml = serialize_opml(&items);
let parsed = parse_opml(&opml);
let opml = serialize_opml(&items).unwrap();
let parsed = parse_opml(&opml).unwrap();
assert_eq!(parsed.len(), 3);
assert_eq!(parsed[0].kind, MenuItemKind::Home);
assert_eq!(parsed[1].label, "About");
@@ -354,14 +281,12 @@ mod tests {
#[test]
fn normalize_prepends_home() {
let items = vec![
MenuItem {
kind: MenuItemKind::Page,
label: "About".into(),
slug: Some("/about".into()),
children: Vec::new(),
},
];
let items = vec![MenuItem {
kind: MenuItemKind::Page,
label: "About".into(),
slug: Some("/about".into()),
children: Vec::new(),
}];
let normalized = normalize_menu(&items);
assert_eq!(normalized.len(), 2);
assert_eq!(normalized[0].kind, MenuItemKind::Home);
@@ -381,14 +306,12 @@ mod tests {
fn write_and_read_menu() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
let items = vec![
MenuItem {
kind: MenuItemKind::Page,
label: "Blog".into(),
slug: Some("/blog".into()),
children: Vec::new(),
},
];
let items = vec![MenuItem {
kind: MenuItemKind::Page,
label: "Blog".into(),
slug: Some("/blog".into()),
children: Vec::new(),
}];
write_menu(dir.path(), &items).unwrap();
let read = read_menu(dir.path()).unwrap();
// Home is always prepended
@@ -398,8 +321,25 @@ mod tests {
}
#[test]
fn xml_escape_special_chars() {
let escaped = xml_escape("Tom & Jerry <3 \"quotes\"");
assert_eq!(escaped, "Tom &amp; Jerry &lt;3 &quot;quotes&quot;");
fn reads_single_quoted_xml_attributes() {
let parsed = parse_opml(
"<opml version='2.0'><body><outline text='About' type='page' htmlUrl='/about'/></body></opml>",
)
.unwrap();
assert_eq!(parsed[1].label, "About");
assert_eq!(parsed[1].slug.as_deref(), Some("/about"));
}
#[test]
fn read_menu_rejects_malformed_xml() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
std::fs::write(
dir.path().join("meta/menu.opml"),
"<opml><body><outline></body></opml>",
)
.unwrap();
assert!(read_menu(dir.path()).is_err());
}
}

View File

@@ -3,8 +3,8 @@ use std::fs;
use std::path::Path;
use crate::engine::EngineResult;
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
use crate::model::PublishingPreferences;
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
use crate::util::atomic_write_str;
// ── project.json ────────────────────────────────────────────────────
@@ -38,7 +38,7 @@ pub fn read_categories_json(data_dir: &Path) -> EngineResult<Vec<String>> {
/// Sort categories, then atomic write to meta/categories.json.
pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineResult<()> {
let mut sorted = categories.to_vec();
sorted.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
sorted.sort_by_key(|a| a.to_lowercase());
let path = data_dir.join("meta").join("categories.json");
let json = serde_json::to_string_pretty(&sorted)?;
atomic_write_str(&path, &json)?;
@@ -48,9 +48,7 @@ pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineRe
// ── category-meta.json ──────────────────────────────────────────────
/// Read meta/category-meta.json.
pub fn read_category_meta_json(
data_dir: &Path,
) -> EngineResult<HashMap<String, CategorySettings>> {
pub fn read_category_meta_json(data_dir: &Path) -> EngineResult<HashMap<String, CategorySettings>> {
let path = data_dir.join("meta").join("category-meta.json");
let content = fs::read_to_string(&path)?;
let meta: HashMap<String, CategorySettings> = serde_json::from_str(&content)?;
@@ -79,10 +77,7 @@ pub fn read_publishing_json(data_dir: &Path) -> EngineResult<PublishingPreferenc
}
/// Atomic write to meta/publishing.json.
pub fn write_publishing_json(
data_dir: &Path,
prefs: &PublishingPreferences,
) -> EngineResult<()> {
pub fn write_publishing_json(data_dir: &Path, prefs: &PublishingPreferences) -> EngineResult<()> {
let path = data_dir.join("meta").join("publishing.json");
let json = serde_json::to_string_pretty(prefs)?;
atomic_write_str(&path, &json)?;
@@ -102,7 +97,7 @@ pub fn read_tags_json(data_dir: &Path) -> EngineResult<Vec<TagEntry>> {
/// Sort by name case-insensitive, then atomic write to meta/tags.json.
pub fn write_tags_json(data_dir: &Path, tags: &[TagEntry]) -> EngineResult<()> {
let mut sorted = tags.to_vec();
sorted.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
sorted.sort_by_key(|a| a.name.to_lowercase());
let path = data_dir.join("meta").join("tags.json");
let json = serde_json::to_string_pretty(&sorted)?;
atomic_write_str(&path, &json)?;
@@ -162,10 +157,7 @@ pub fn update_blog_languages(data_dir: &Path, languages: Vec<String>) -> EngineR
/// Update arbitrary fields of project metadata.
/// Per metadata.allium UpdateProjectMetadata rule.
pub fn update_project_metadata(
data_dir: &Path,
changes: &serde_json::Value,
) -> EngineResult<()> {
pub fn update_project_metadata(data_dir: &Path, changes: &serde_json::Value) -> EngineResult<()> {
let mut meta = read_project_json(data_dir)?;
if let Some(name) = changes.get("name").and_then(|v| v.as_str()) {
meta.name = name.to_string();
@@ -191,7 +183,10 @@ pub fn update_project_metadata(
if let Some(theme) = changes.get("picoTheme") {
meta.pico_theme = theme.as_str().map(|s| s.to_string());
}
if let Some(enabled) = changes.get("semanticSimilarityEnabled").and_then(|v| v.as_bool()) {
if let Some(enabled) = changes
.get("semanticSimilarityEnabled")
.and_then(|v| v.as_bool())
{
meta.semantic_similarity_enabled = enabled;
}
if let Some(langs) = changes.get("blogLanguages").and_then(|v| v.as_array()) {
@@ -200,7 +195,8 @@ pub fn update_project_metadata(
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
meta.validate().map_err(|e| crate::engine::EngineError::Validation(e))?;
meta.validate()
.map_err(crate::engine::EngineError::Validation)?;
write_project_json(data_dir, &meta)?;
Ok(())
}
@@ -380,7 +376,7 @@ mod tests {
fn add_category_creates_entries() {
let dir = setup();
// Seed files
write_categories_json(dir.path(), &vec!["article".into()]).unwrap();
write_categories_json(dir.path(), &["article".into()]).unwrap();
write_category_meta_json(dir.path(), &HashMap::new()).unwrap();
add_category(dir.path(), "page").unwrap();
@@ -395,7 +391,7 @@ mod tests {
#[test]
fn add_category_idempotent() {
let dir = setup();
write_categories_json(dir.path(), &vec!["article".into()]).unwrap();
write_categories_json(dir.path(), &["article".into()]).unwrap();
write_category_meta_json(dir.path(), &HashMap::new()).unwrap();
add_category(dir.path(), "article").unwrap();
@@ -406,7 +402,7 @@ mod tests {
#[test]
fn remove_category_deletes_entries() {
let dir = setup();
write_categories_json(dir.path(), &vec!["article".into(), "page".into()]).unwrap();
write_categories_json(dir.path(), &["article".into(), "page".into()]).unwrap();
let mut meta = HashMap::new();
meta.insert(
"article".to_string(),

View File

@@ -13,7 +13,9 @@ 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::util::frontmatter::{read_post_file, read_script_file, read_template_file, read_translation_file};
use crate::util::frontmatter::{
read_post_file, read_script_file, read_template_file, read_translation_file,
};
use crate::util::sidecar::read_sidecar;
/// A single field difference.
@@ -139,7 +141,11 @@ fn opt_to_str(opt: &Option<String>) -> String {
}
fn bool_to_str(b: bool) -> String {
if b { "true".to_string() } else { "false".to_string() }
if b {
"true".to_string()
} else {
"false".to_string()
}
}
fn tags_to_json(tags: &[String]) -> String {
@@ -172,7 +178,7 @@ fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
}
let content = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_post_file(&content).map_err(|e| EngineError::Parse(e))?;
let (fm, _body) = read_post_file(&content).map_err(EngineError::Parse)?;
let mut fields = Vec::new();
@@ -257,21 +263,23 @@ fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
}
}
fn diff_translation(
data_dir: &Path,
t: &PostTranslation,
) -> EngineResult<Option<EntityDiff>> {
fn diff_translation(data_dir: &Path, t: &PostTranslation) -> EngineResult<Option<EntityDiff>> {
let abs_path = data_dir.join(&t.file_path);
if !abs_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_translation_file(&content).map_err(|e| EngineError::Parse(e))?;
let (fm, _body) = read_translation_file(&content).map_err(EngineError::Parse)?;
let mut fields = Vec::new();
compare_field(&mut fields, "translationFor", &t.translation_for, &fm.translation_for);
compare_field(
&mut fields,
"translationFor",
&t.translation_for,
&fm.translation_for,
);
compare_field(&mut fields, "language", &t.language, &fm.language);
compare_field(&mut fields, "title", &t.title, &fm.title);
compare_field(
@@ -285,7 +293,7 @@ fn diff_translation(
Ok(None)
} else {
Ok(Some(EntityDiff {
entity_type: "translation".to_string(),
entity_type: "post_translation".to_string(),
entity_id: t.id.clone(),
file_path: t.file_path.clone(),
fields,
@@ -300,7 +308,7 @@ fn diff_media(data_dir: &Path, media: &Media) -> EngineResult<Option<EntityDiff>
}
let content = fs::read_to_string(&abs_path)?;
let sc = read_sidecar(&content).map_err(|e| EngineError::Parse(e))?;
let sc = read_sidecar(&content).map_err(EngineError::Parse)?;
let mut fields = Vec::new();
@@ -360,7 +368,7 @@ fn diff_template(data_dir: &Path, tpl: &Template) -> EngineResult<Option<EntityD
}
let content = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_template_file(&content).map_err(|e| EngineError::Parse(e))?;
let (fm, _body) = read_template_file(&content).map_err(EngineError::Parse)?;
let mut fields = Vec::new();
@@ -403,7 +411,7 @@ fn diff_script(data_dir: &Path, script: &Script) -> EngineResult<Option<EntityDi
}
let content = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_script_file(&content).map_err(|e| EngineError::Parse(e))?;
let (fm, _body) = read_script_file(&content).map_err(EngineError::Parse)?;
let mut fields = Vec::new();
@@ -414,7 +422,12 @@ fn diff_script(data_dir: &Path, script: &Script) -> EngineResult<Option<EntityDi
script_kind_to_str(&script.kind),
&fm.kind,
);
compare_field(&mut fields, "entrypoint", &script.entrypoint, &fm.entrypoint);
compare_field(
&mut fields,
"entrypoint",
&script.entrypoint,
&fm.entrypoint,
);
compare_field(
&mut fields,
"enabled",
@@ -494,10 +507,7 @@ fn detect_orphan_files(
if !dir_path.exists() {
continue;
}
for entry in WalkDir::new(&dir_path)
.into_iter()
.filter_map(|e| e.ok())
{
for entry in WalkDir::new(&dir_path).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
@@ -600,20 +610,20 @@ fn detect_orphan_files(
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::db::fts::ensure_fts_tables;
use crate::db::queries::media::insert_media;
use crate::db::queries::post::insert_post;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::queries::script::insert_script;
use crate::db::queries::template::insert_template;
use crate::db::Database;
use crate::engine::post::{create_post, publish_post};
use crate::model::{
Media, Post, PostStatus, Script, ScriptKind, ScriptStatus, Template, TemplateKind,
TemplateStatus,
};
use crate::util::frontmatter::{
write_script_file, write_template_file, ScriptFrontmatter, TemplateFrontmatter,
ScriptFrontmatter, TemplateFrontmatter, write_script_file, write_template_file,
};
use crate::util::sidecar::MediaSidecar;
use tempfile::TempDir;
@@ -658,11 +668,7 @@ mod tests {
let non_time_diffs: Vec<_> = report
.diffs
.iter()
.filter(|d| {
d.fields
.iter()
.any(|f| f.field_name != "updatedAt")
})
.filter(|d| d.fields.iter().any(|f| f.field_name != "updatedAt"))
.collect();
assert!(
non_time_diffs.is_empty(),
@@ -786,7 +792,11 @@ mod tests {
// Create a .md file in posts/ that is not in the DB
let posts_dir = dir.path().join("posts/2024/01");
fs::create_dir_all(&posts_dir).unwrap();
fs::write(posts_dir.join("orphan.md"), "---\ntitle: Orphan\n---\nBody\n").unwrap();
fs::write(
posts_dir.join("orphan.md"),
"---\ntitle: Orphan\n---\nBody\n",
)
.unwrap();
let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
@@ -801,7 +811,9 @@ mod tests {
"expected at least one orphan file"
);
assert!(
orphan_files.iter().any(|o| o.file_path.contains("orphan.md")),
orphan_files
.iter()
.any(|o| o.file_path.contains("orphan.md")),
"expected orphan.md in orphans, got: {orphan_files:?}"
);
}

View File

@@ -1,29 +1,29 @@
pub mod error;
pub mod ai;
pub mod calendar;
pub mod context;
pub mod project;
pub mod meta;
pub mod tag;
pub mod post;
pub mod error;
pub mod generation;
pub mod media;
pub mod menu;
pub mod meta;
pub mod metadata_diff;
pub mod post;
pub mod post_media;
pub mod template;
pub mod template_rebuild;
pub mod preview;
pub mod project;
pub mod rebuild;
pub mod script;
pub mod script_rebuild;
pub mod task;
pub mod metadata_diff;
pub mod site_assets;
pub mod rebuild;
pub mod search;
pub mod calendar;
pub mod generation;
pub mod preview;
pub mod site_assets;
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;
pub mod validate_media;
pub mod validate_content;
pub mod menu;
pub use error::{EngineError, EngineResult};
pub use context::EngineContext;
pub use error::{EngineError, EngineResult};

View File

@@ -1,7 +1,7 @@
use std::fs;
use std::path::Path;
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use uuid::Uuid;
use walkdir::WalkDir;
@@ -30,6 +30,10 @@ pub struct RebuildReport {
}
/// Create a new draft post.
#[expect(
clippy::too_many_arguments,
reason = "arguments are the user-supplied post fields"
)]
pub fn create_post(
conn: &Connection,
_data_dir: &Path,
@@ -90,6 +94,10 @@ pub fn create_post(
}
/// Update a post's fields.
#[expect(
clippy::too_many_arguments,
reason = "optional arguments represent independent post field changes"
)]
pub fn update_post(
conn: &Connection,
_data_dir: &Path,
@@ -115,14 +123,13 @@ pub fn update_post(
}
// Slug uniqueness check
if let Some(new_slug) = slug {
if new_slug != post.slug {
if qp::get_post_by_project_and_slug(conn, &post.project_id, new_slug).is_ok() {
return Err(EngineError::Conflict(format!(
"slug '{new_slug}' already exists in this project"
)));
}
}
if let Some(new_slug) = slug
&& new_slug != post.slug
&& qp::get_post_by_project_and_slug(conn, &post.project_id, new_slug).is_ok()
{
return Err(EngineError::Conflict(format!(
"slug '{new_slug}' already exists in this project"
)));
}
if let Some(t) = title {
@@ -161,12 +168,11 @@ pub fn update_post(
// Reload content from filesystem if content field is NULL (published state)
if post.content.is_none() && !post.file_path.is_empty() {
let abs_path = _data_dir.join(&post.file_path);
if abs_path.exists() {
if let Ok(file_content) = fs::read_to_string(&abs_path) {
if let Ok((_fm, body)) = read_post_file(&file_content) {
post.content = Some(body);
}
}
if abs_path.exists()
&& let Ok(file_content) = fs::read_to_string(&abs_path)
&& let Ok((_fm, body)) = read_post_file(&file_content)
{
post.content = Some(body);
}
}
post.status = PostStatus::Draft;
@@ -207,7 +213,7 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
c.clone()
} else if abs_path.exists() {
let file_content = fs::read_to_string(&abs_path)?;
let (_fm, body) = read_post_file(&file_content).map_err(|e| EngineError::Parse(e))?;
let (_fm, body) = read_post_file(&file_content).map_err(EngineError::Parse)?;
body
} else {
String::new()
@@ -310,13 +316,12 @@ pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
if post.status == PostStatus::Published && post.content.is_none() && !post.file_path.is_empty()
{
let abs_path = data_dir.join(&post.file_path);
if abs_path.exists() {
if let Ok(file_content) = fs::read_to_string(&abs_path) {
if let Ok((_fm, body)) = read_post_file(&file_content) {
post.content = Some(body);
qp::update_post(conn, &post)?;
}
}
if abs_path.exists()
&& let Ok(file_content) = fs::read_to_string(&abs_path)
&& let Ok((_fm, body)) = read_post_file(&file_content)
{
post.content = Some(body);
qp::update_post(conn, &post)?;
}
}
let now = now_unix_ms();
@@ -325,11 +330,7 @@ pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
}
/// Discard unpublished draft changes and restore the published version.
pub fn discard_post_draft(
conn: &Connection,
data_dir: &Path,
post_id: &str,
) -> EngineResult<Post> {
pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<Post> {
let mut post = qp::get_post_by_id(conn, post_id)?;
if post.published_at.is_none() {
return Err(EngineError::Conflict(
@@ -337,49 +338,43 @@ pub fn discard_post_draft(
));
}
let (title, slug, excerpt, author, language, template_slug, do_not_translate, tags, categories, checksum) =
if !post.file_path.is_empty() {
let abs_path = data_dir.join(&post.file_path);
if abs_path.exists() {
let raw = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_post_file(&raw).map_err(EngineError::Parse)?;
(
fm.title,
fm.slug,
fm.excerpt,
fm.author,
fm.language,
fm.template_slug,
fm.do_not_translate,
fm.tags,
fm.categories,
Some(content_hash(raw.as_bytes())),
)
} else {
(
post.published_title.clone().unwrap_or_else(|| post.title.clone()),
post.slug.clone(),
post.published_excerpt.clone().or_else(|| post.excerpt.clone()),
post.author.clone(),
post.language.clone(),
post.template_slug.clone(),
post.do_not_translate,
post.published_tags
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_else(|| post.tags.clone()),
post.published_categories
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_else(|| post.categories.clone()),
post.checksum.clone(),
)
}
let (
title,
slug,
excerpt,
author,
language,
template_slug,
do_not_translate,
tags,
categories,
checksum,
) = if !post.file_path.is_empty() {
let abs_path = data_dir.join(&post.file_path);
if abs_path.exists() {
let raw = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_post_file(&raw).map_err(EngineError::Parse)?;
(
fm.title,
fm.slug,
fm.excerpt,
fm.author,
fm.language,
fm.template_slug,
fm.do_not_translate,
fm.tags,
fm.categories,
Some(content_hash(raw.as_bytes())),
)
} else {
(
post.published_title.clone().unwrap_or_else(|| post.title.clone()),
post.published_title
.clone()
.unwrap_or_else(|| post.title.clone()),
post.slug.clone(),
post.published_excerpt.clone().or_else(|| post.excerpt.clone()),
post.published_excerpt
.clone()
.or_else(|| post.excerpt.clone()),
post.author.clone(),
post.language.clone(),
post.template_slug.clone(),
@@ -394,7 +389,31 @@ pub fn discard_post_draft(
.unwrap_or_else(|| post.categories.clone()),
post.checksum.clone(),
)
};
}
} else {
(
post.published_title
.clone()
.unwrap_or_else(|| post.title.clone()),
post.slug.clone(),
post.published_excerpt
.clone()
.or_else(|| post.excerpt.clone()),
post.author.clone(),
post.language.clone(),
post.template_slug.clone(),
post.do_not_translate,
post.published_tags
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_else(|| post.tags.clone()),
post.published_categories
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_else(|| post.categories.clone()),
post.checksum.clone(),
)
};
post.title = title;
post.slug = slug;
@@ -415,11 +434,7 @@ pub fn discard_post_draft(
}
/// Create a new draft post by duplicating an existing post.
pub fn duplicate_post(
conn: &Connection,
data_dir: &Path,
post_id: &str,
) -> EngineResult<Post> {
pub fn duplicate_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<Post> {
let source = qp::get_post_by_id(conn, post_id)?;
let body = if let Some(ref content) = source.content {
content.clone()
@@ -529,7 +544,7 @@ pub fn canonical_url(created_at_ms: i64, slug: &str) -> String {
/// Upsert a translation for a post.
pub fn upsert_translation(
conn: &Connection,
_data_dir: &Path,
data_dir: &Path,
post_id: &str,
language: &str,
title: &str,
@@ -548,7 +563,20 @@ pub fn upsert_translation(
let existing = qt::get_post_translation_by_post_and_language(conn, post_id, language);
match existing {
Ok(mut t) => {
// Update existing
let published_body = if t.status == PostStatus::Published && !t.file_path.is_empty() {
let raw = fs::read_to_string(data_dir.join(&t.file_path))?;
let (_, body) = read_translation_file(&raw).map_err(EngineError::Parse)?;
Some(body)
} else {
t.content.clone()
};
let affects_published_content = t.title != title
|| t.excerpt.as_deref() != excerpt
|| content.is_some_and(|value| published_body.as_deref() != Some(value));
if t.status == PostStatus::Published && affects_published_content {
t.status = PostStatus::Draft;
t.content = published_body;
}
t.title = title.to_string();
t.excerpt = excerpt.map(|s| s.to_string());
if let Some(c) = content {
@@ -834,8 +862,7 @@ fn publish_translation(
c.clone()
} else if abs_path.exists() {
let file_content = fs::read_to_string(&abs_path)?;
let (_fm, body) =
read_translation_file(&file_content).map_err(|e| EngineError::Parse(e))?;
let (_fm, body) = read_translation_file(&file_content).map_err(EngineError::Parse)?;
body
} else {
String::new()
@@ -912,7 +939,7 @@ fn rebuild_canonical_post(
path: &Path,
) -> EngineResult<bool> {
let content = fs::read_to_string(path)?;
let (fm, body) = read_post_file(&content).map_err(|e| EngineError::Parse(e))?;
let (fm, body) = read_post_file(&content).map_err(EngineError::Parse)?;
let rel_path = path
.strip_prefix(data_dir)
@@ -1002,7 +1029,7 @@ fn rebuild_translation(
path: &Path,
) -> EngineResult<bool> {
let content = fs::read_to_string(path)?;
let (fm, body) = read_translation_file(&content).map_err(|e| EngineError::Parse(e))?;
let (fm, body) = read_translation_file(&content).map_err(EngineError::Parse)?;
let rel_path = path
.strip_prefix(data_dir)
@@ -1011,7 +1038,6 @@ fn rebuild_translation(
.to_string();
let hash = content_hash(content.as_bytes());
let now = now_unix_ms();
// Check if parent post exists
let parent = qp::get_post_by_id(conn, &fm.translation_for);
@@ -1023,12 +1049,17 @@ fn rebuild_translation(
}
let parent = parent.unwrap();
// Determine status from parent
let status = if parent.status == PostStatus::Published {
PostStatus::Published
} else {
PostStatus::Draft
// Current files carry independent translation metadata. Legacy files fall back to the
// canonical post values for compatibility.
let status = match fm.status.as_deref() {
Some("published") => PostStatus::Published,
Some("draft") => PostStatus::Draft,
_ if parent.status == PostStatus::Published => PostStatus::Published,
_ => PostStatus::Draft,
};
let created_at = fm.created_at.unwrap_or(parent.created_at);
let updated_at = fm.updated_at.unwrap_or(parent.updated_at);
let published_at = fm.published_at.or(parent.published_at);
// Check if translation exists
let existing =
@@ -1045,13 +1076,15 @@ fn rebuild_translation(
t.status = status;
t.file_path = rel_path;
t.checksum = Some(hash);
t.updated_at = now;
t.created_at = created_at;
t.updated_at = updated_at;
t.published_at = published_at;
qt::update_post_translation(conn, &t)?;
Ok(false)
}
Err(_) => {
let t = PostTranslation {
id: Uuid::new_v4().to_string(),
id: fm.id.unwrap_or_else(|| Uuid::new_v4().to_string()),
project_id: project_id.to_string(),
translation_for: fm.translation_for,
language: fm.language,
@@ -1065,13 +1098,9 @@ fn rebuild_translation(
status,
file_path: rel_path,
checksum: Some(hash),
created_at: now,
updated_at: now,
published_at: if parent.published_at.is_some() {
Some(now)
} else {
None
},
created_at,
updated_at,
published_at,
};
qt::insert_post_translation(conn, &t)?;
Ok(true)
@@ -1093,15 +1122,9 @@ pub fn post_insert_link(slug: &str) -> String {
/// Returns the Markdown syntax: ![alt](bds-media://id) or [name](bds-media://id)
pub fn post_insert_media(media_id: &str, is_image: bool, original_name: &str) -> String {
if is_image {
format!(
"![]({bds_media_url})",
bds_media_url = format!("bds-media://{media_id}")
)
format!("![](bds-media://{media_id})")
} else {
format!(
"[{original_name}]({bds_media_url})",
bds_media_url = format!("bds-media://{media_id}")
)
format!("[{original_name}](bds-media://{media_id})")
}
}
@@ -1120,9 +1143,9 @@ pub fn list_post_backlinks(conn: &Connection, post_id: &str) -> EngineResult<Vec
#[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::db::Database;
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
@@ -1584,6 +1607,49 @@ mod tests {
assert_eq!(t2.title, "Neuer Titel");
}
#[test]
fn editing_published_translation_reopens_draft() {
let (db, dir) = setup();
let post = create_post(
db.conn(),
dir.path(),
"p1",
"Parent",
Some("body"),
vec![],
vec![],
None,
Some("en"),
None,
)
.unwrap();
upsert_translation(
db.conn(),
dir.path(),
&post.id,
"de",
"Titel",
None,
Some("Inhalt"),
)
.unwrap();
publish_post(db.conn(), dir.path(), &post.id).unwrap();
let edited = upsert_translation(
db.conn(),
dir.path(),
&post.id,
"de",
"Neuer Titel",
None,
Some("Neuer Inhalt"),
)
.unwrap();
assert_eq!(edited.status, PostStatus::Draft);
assert_eq!(edited.content.as_deref(), Some("Neuer Inhalt"));
}
#[test]
fn delete_translation_removes_file_and_db() {
let (db, dir) = setup();

View File

@@ -107,10 +107,10 @@ mod tests {
use tempfile::TempDir;
use crate::db::Database;
use crate::db::queries::media::{insert_media, make_test_media};
use crate::db::queries::post_media::list_post_media_by_post;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use crate::util::sidecar::read_sidecar;
fn setup() -> (Database, TempDir) {
@@ -182,12 +182,7 @@ mod tests {
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m2", 1).unwrap();
// Reverse the order: m2 first, m1 second
reorder_post_media(
db.conn(),
"post1",
&["m2".to_string(), "m1".to_string()],
)
.unwrap();
reorder_post_media(db.conn(), "post1", &["m2".to_string(), "m1".to_string()]).unwrap();
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
assert_eq!(list.len(), 2);

View File

@@ -2,11 +2,11 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use axum::Router;
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::{StatusCode, Uri, header};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use serde::Deserialize;
use tokio::sync::oneshot;
@@ -81,7 +81,9 @@ pub fn start_preview_server(
};
let listener = std::net::TcpListener::bind((PREVIEW_HOST, PREVIEW_PORT)).map_err(|error| {
if error.kind() == std::io::ErrorKind::AddrInUse {
EngineError::Conflict(format!("preview server already running on {PREVIEW_HOST}:{PREVIEW_PORT}"))
EngineError::Conflict(format!(
"preview server already running on {PREVIEW_HOST}:{PREVIEW_PORT}"
))
} else {
EngineError::Io(error)
}
@@ -135,23 +137,23 @@ async fn handle_draft_preview(
theme: query.theme.clone(),
mode: query.mode.clone(),
};
match render_preview_response(&state, &format!("/__draft/{post_id}"), query.language.as_deref(), Some(&style)) {
match render_preview_response(
&state,
&format!("/__draft/{post_id}"),
query.language.as_deref(),
Some(&style),
) {
Ok(response) => response,
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
async fn handle_style_preview(
Query(query): Query<StylePreviewQuery>,
) -> Response {
async fn handle_style_preview(Query(query): Query<StylePreviewQuery>) -> Response {
let theme = query.theme.unwrap_or_else(|| "default".to_string());
let mode = query.mode.unwrap_or_else(|| "auto".to_string());
let html = format!(
"<!doctype html><html lang=\"en\" data-theme=\"{}\" data-mode=\"{}\"><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>Style Preview</title><link rel=\"stylesheet\" href=\"/assets/pico.min.css\" /><link rel=\"stylesheet\" href=\"/assets/bds.css\" /></head><body><main><section class=\"style-preview\"><h1>Style Preview</h1><p>Theme: {}</p><p>Mode: {}</p></section></main></body></html>",
theme,
mode,
theme,
mode,
theme, mode, theme, mode,
);
Html(html).into_response()
}
@@ -163,7 +165,10 @@ fn render_preview_response(
style: Option<&StylePreviewQuery>,
) -> EngineResult<Response> {
if let Some(post_id) = path.strip_prefix("/__draft/") {
let html = apply_preview_style(render_draft_preview(state, post_id, requested_language)?, style);
let html = apply_preview_style(
render_draft_preview(state, post_id, requested_language)?,
style,
);
return Ok(Html(html).into_response());
}
@@ -178,8 +183,15 @@ fn render_preview_response(
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>();
let response = build_preview_response(db.conn(), &state.data_dir, &state.project_id, &metadata, &input_posts, path)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let response = build_preview_response(
db.conn(),
&state.data_dir,
&state.project_id,
&metadata,
&input_posts,
path,
)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let status = StatusCode::from_u16(response.status_code).unwrap_or(StatusCode::OK);
Ok((status, Html(apply_preview_style(response.html, style))).into_response())
}
@@ -188,7 +200,9 @@ fn apply_preview_style(html: String, style: Option<&StylePreviewQuery>) -> Strin
let Some(style) = style else {
return html;
};
if style.theme.as_deref().unwrap_or("").is_empty() && style.mode.as_deref().unwrap_or("").is_empty() {
if style.theme.as_deref().unwrap_or("").is_empty()
&& style.mode.as_deref().unwrap_or("").is_empty()
{
return html;
}
@@ -238,34 +252,42 @@ fn render_draft_preview(
let db = Database::open(&state.db_path)?;
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
let post = queries::post::get_post_by_id(db.conn(), post_id)?;
let canonical_language = post.language.as_deref().unwrap_or_else(|| metadata.main_language.as_deref().unwrap_or("en"));
let canonical_language = post
.language
.as_deref()
.unwrap_or_else(|| metadata.main_language.as_deref().unwrap_or("en"));
let target_language = requested_language.unwrap_or(canonical_language);
if target_language != canonical_language {
if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(
db.conn(),
post_id,
target_language,
) {
let mut translated_post = post.clone();
translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
translated_post.language = Some(translation.language.clone());
translated_post.status = translation.status.clone();
translated_post.file_path = translation.file_path.clone();
translated_post.published_at = translation.published_at.or(post.published_at);
let body = load_translation_body(&state.data_dir, &translation)?;
let response = build_preview_response(
if target_language != canonical_language
&& let Ok(translation) =
queries::post_translation::get_post_translation_by_post_and_language(
db.conn(),
&state.data_dir,
&state.project_id,
&metadata,
&[(translated_post, body)],
&crate::render::build_canonical_post_path(&post, target_language, metadata.main_language.as_deref().unwrap_or("en")),
post_id,
target_language,
)
.map_err(|error| EngineError::Parse(error.to_string()))?;
return Ok(response.html);
}
{
let mut translated_post = post.clone();
translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
translated_post.language = Some(translation.language.clone());
translated_post.status = translation.status.clone();
translated_post.file_path = translation.file_path.clone();
translated_post.published_at = translation.published_at.or(post.published_at);
let body = load_translation_body(&state.data_dir, &translation)?;
let response = build_preview_response(
db.conn(),
&state.data_dir,
&state.project_id,
&metadata,
&[(translated_post, body)],
&crate::render::build_canonical_post_path(
&post,
target_language,
metadata.main_language.as_deref().unwrap_or("en"),
),
)
.map_err(|error| EngineError::Parse(error.to_string()))?;
return Ok(response.html);
}
let body = load_post_body(&state.data_dir, &post)?;
@@ -275,7 +297,11 @@ fn render_draft_preview(
&state.project_id,
&metadata,
&[(post.clone(), body)],
&crate::render::build_canonical_post_path(&post, canonical_language, metadata.main_language.as_deref().unwrap_or("en")),
&crate::render::build_canonical_post_path(
&post,
canonical_language,
metadata.main_language.as_deref().unwrap_or("en"),
),
)
.map_err(|error| EngineError::Parse(error.to_string()))?;
Ok(response.html)
@@ -288,7 +314,10 @@ fn collect_published_posts(
let db = Database::open(&state.db_path)?;
let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?;
let mut published = Vec::new();
for post in posts.into_iter().filter(|post| matches!(post.status, PostStatus::Published)) {
for post in posts
.into_iter()
.filter(|post| matches!(post.status, PostStatus::Published))
{
published.push(PublishedPostSource {
body_markdown: load_post_body(&state.data_dir, &post)?,
post,
@@ -318,7 +347,11 @@ fn load_translation_body(
load_markdown_body(data_dir, &translation.file_path, true)
}
fn load_markdown_body(data_dir: &Path, relative_path: &str, translation: bool) -> EngineResult<String> {
fn load_markdown_body(
data_dir: &Path,
relative_path: &str,
translation: bool,
) -> EngineResult<String> {
let raw = fs::read_to_string(data_dir.join(relative_path.trim_start_matches('/')))?;
let body = if translation {
read_translation_file(&raw).map(|(_, body)| body)
@@ -351,25 +384,32 @@ fn serve_scoped_file(
let scope_root = data_dir.join(scope_dir);
let candidate = scope_root.join(relative);
if !candidate.exists() || !candidate.is_file() {
return Ok(Some(error_response(StatusCode::NOT_FOUND, "preview asset not found")));
return Ok(Some(error_response(
StatusCode::NOT_FOUND,
"preview asset not found",
)));
}
let canonical_candidate = candidate.canonicalize()?;
let canonical_scope_root = scope_root.canonicalize().unwrap_or(scope_root);
if !canonical_candidate.starts_with(&canonical_scope_root) {
return Ok(Some(error_response(StatusCode::NOT_FOUND, "preview asset not found")));
return Ok(Some(error_response(
StatusCode::NOT_FOUND,
"preview asset not found",
)));
}
let bytes = fs::read(&canonical_candidate)?;
let mime = guess_content_type(&canonical_candidate);
Ok(Some((
StatusCode::OK,
[(header::CONTENT_TYPE, mime)],
bytes,
)
.into_response()))
Ok(Some(
(StatusCode::OK, [(header::CONTENT_TYPE, mime)], bytes).into_response(),
))
}
fn guess_content_type(path: &Path) -> &'static str {
match path.extension().and_then(|ext| ext.to_str()).unwrap_or_default() {
match path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
{
"css" => "text/css; charset=utf-8",
"js" => "application/javascript; charset=utf-8",
"json" => "application/json; charset=utf-8",
@@ -383,7 +423,12 @@ fn guess_content_type(path: &Path) -> &'static str {
}
fn error_response(status: StatusCode, message: &str) -> Response {
(status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], message.to_string()).into_response()
(
status,
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
message.to_string(),
)
.into_response()
}
#[cfg(test)]
@@ -391,7 +436,7 @@ mod tests {
use super::*;
use crate::db::queries;
use crate::engine::meta;
use crate::model::{Post, Project, ProjectMetadata, PostStatus};
use crate::model::{Post, PostStatus, Project, ProjectMetadata};
use std::sync::{Mutex, OnceLock};
fn preview_port_guard() -> &'static Mutex<()> {
@@ -504,9 +549,16 @@ mod tests {
#[test]
fn root_preview_renders_index_page() {
let db = Database::open_in_memory().unwrap();
let html = build_preview_response(db.conn(), Path::new("."), "project-1", &make_metadata(), &[(make_post().post, make_post().body_markdown)], "/")
.unwrap()
.html;
let html = build_preview_response(
db.conn(),
Path::new("."),
"project-1",
&make_metadata(),
&[(make_post().post, make_post().body_markdown)],
"/",
)
.unwrap()
.html;
assert!(html.contains("post-list"));
}
@@ -568,11 +620,15 @@ mod tests {
let client = reqwest::blocking::Client::new();
let mut body = None;
for _ in 0..20 {
if let Ok(response) = client.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/__draft/post-1")).send() {
if response.status().is_success() {
body = Some(response.text().unwrap());
break;
}
if let Ok(response) = client
.get(format!(
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/__draft/post-1"
))
.send()
&& response.status().is_success()
{
body = Some(response.text().unwrap());
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
@@ -599,7 +655,9 @@ mod tests {
let client = reqwest::blocking::Client::new();
let response = client
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/../outside.txt"))
.get(format!(
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/../outside.txt"
))
.send()
.unwrap();
server.stop().unwrap();
@@ -628,7 +686,9 @@ mod tests {
.send()
.unwrap();
let asset = client
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/site.css"))
.get(format!(
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/site.css"
))
.send()
.unwrap();
let media_body = media.text().unwrap();
@@ -739,7 +799,10 @@ mod tests {
dir.path(),
"project-1",
&make_metadata(),
&[(hidden.post.clone(), hidden.body_markdown.clone()), (featured.post.clone(), featured.body_markdown.clone())],
&[
(hidden.post.clone(), hidden.body_markdown.clone()),
(featured.post.clone(), featured.body_markdown.clone()),
],
"/category/hidden",
)
.unwrap();
@@ -750,7 +813,10 @@ mod tests {
dir.path(),
"project-1",
&make_metadata(),
&[(hidden.post, hidden.body_markdown), (featured.post, featured.body_markdown)],
&[
(hidden.post, hidden.body_markdown),
(featured.post, featured.body_markdown),
],
"/category/featured",
)
.unwrap();
@@ -893,6 +959,10 @@ mod tests {
.unwrap();
assert_eq!(response.status_code, 404);
assert!(response.html.contains("No rendered page exists for /missing/route"));
assert!(
response
.html
.contains("No rendered page exists for /missing/route")
);
}
}
}

View File

@@ -10,7 +10,7 @@ use crate::engine::site_assets::copy_bundled_site_assets;
use crate::engine::{EngineError, EngineResult};
use crate::model::Project;
use crate::model::metadata::ProjectMetadata;
use crate::util::{atomic_write_str, now_unix_ms, slugify, ensure_unique};
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
/// The well-known ID of the default project (spec: DefaultProjectExists).
pub const DEFAULT_PROJECT_ID: &str = "default";
@@ -113,7 +113,11 @@ pub fn list_projects(conn: &Connection) -> EngineResult<Vec<Project>> {
/// Delete a project row (cascading handled by queries).
/// Rejects deletion of the default project and the currently active project.
/// Only cleans up the internal project data directory (not external custom paths).
pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Option<&Path>) -> EngineResult<()> {
pub fn delete_project(
conn: &Connection,
project_id: &str,
internal_data_dir: Option<&Path>,
) -> EngineResult<()> {
// Cannot delete the default project
if project_id == DEFAULT_PROJECT_ID {
return Err(EngineError::Validation(
@@ -122,12 +126,12 @@ pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Op
}
// Check if this is the active project (don't delete active)
if let Ok(active) = q::get_active_project(conn) {
if active.id == project_id {
return Err(EngineError::Validation(
"cannot delete the active project".to_string(),
));
}
if let Ok(active) = q::get_active_project(conn)
&& active.id == project_id
{
return Err(EngineError::Validation(
"cannot delete the active project".to_string(),
));
}
// Only delete internal data directory, never external custom data_path.
@@ -139,12 +143,11 @@ pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Op
q::delete_project(conn, project_id)?;
// Clean up internal filesystem only (not custom external paths per spec)
if !is_custom_path {
if let Some(dir) = internal_data_dir {
if dir.exists() {
let _ = fs::remove_dir_all(dir);
}
}
if !is_custom_path
&& let Some(dir) = internal_data_dir
&& dir.exists()
{
let _ = fs::remove_dir_all(dir);
}
Ok(())
@@ -153,7 +156,15 @@ pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Op
// ── helpers ──────────────────────────────────────────────────────────
fn create_directory_structure(data_dir: &Path) -> EngineResult<()> {
let subdirs = ["posts", "media", "meta", "thumbnails", "templates", "scripts", "assets"];
let subdirs = [
"posts",
"media",
"meta",
"thumbnails",
"templates",
"scripts",
"assets",
];
for sub in &subdirs {
fs::create_dir_all(data_dir.join(sub))?;
}
@@ -215,15 +226,36 @@ fn copy_starter_templates(data_dir: &Path) -> EngineResult<()> {
// 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")),
(
"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")),
(
"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 {
@@ -258,12 +290,8 @@ mod tests {
fn create_project_inserts_and_creates_dirs() {
let (db, dir) = setup();
let data_path = dir.path().join("my-blog");
let project = create_project(
db.conn(),
"My Blog",
Some(data_path.to_str().unwrap()),
)
.unwrap();
let project =
create_project(db.conn(), "My Blog", Some(data_path.to_str().unwrap())).unwrap();
assert_eq!(project.name, "My Blog");
assert_eq!(project.slug, "my-blog");
@@ -287,14 +315,16 @@ mod tests {
assert_eq!(project_json["name"], "My Blog");
assert_eq!(project_json["maxPostsPerPage"], 50);
let cats: Vec<String> =
serde_json::from_str(&fs::read_to_string(data_path.join("meta/categories.json")).unwrap())
.unwrap();
let cats: Vec<String> = serde_json::from_str(
&fs::read_to_string(data_path.join("meta/categories.json")).unwrap(),
)
.unwrap();
assert_eq!(cats, vec!["article", "aside", "page", "picture"]);
let cat_meta: serde_json::Value =
serde_json::from_str(&fs::read_to_string(data_path.join("meta/category-meta.json")).unwrap())
.unwrap();
let cat_meta: serde_json::Value = serde_json::from_str(
&fs::read_to_string(data_path.join("meta/category-meta.json")).unwrap(),
)
.unwrap();
assert!(cat_meta.as_object().unwrap().is_empty());
let tags: Vec<String> =
@@ -364,7 +394,10 @@ mod tests {
assert!(internal_dir.join("posts").is_dir());
delete_project(db.conn(), &project.id, Some(&internal_dir)).unwrap();
assert!(list_projects(db.conn()).unwrap().is_empty());
assert!(!internal_dir.exists(), "internal directory should be cleaned up");
assert!(
!internal_dir.exists(),
"internal directory should be cleaned up"
);
}
#[test]

View File

@@ -4,11 +4,11 @@ use std::sync::Arc;
use rusqlite::Connection;
use crate::db::fts;
use crate::engine::EngineResult;
use crate::engine::media;
use crate::engine::post;
use crate::engine::script_rebuild;
use crate::engine::template_rebuild;
use crate::engine::EngineResult;
/// Report from a full rebuild operation.
#[derive(Debug, Default)]
@@ -68,7 +68,11 @@ pub fn rebuild_from_filesystem_with_progress(
let post_item_cb: Option<post::ItemProgressFn> = on_progress.as_ref().map(|cb| {
let cb = Arc::clone(cb);
let f: post::ItemProgressFn = Box::new(move |current, total, name| {
let phase_pct = if total > 0 { current as f32 / total as f32 } else { 1.0 };
let phase_pct = if total > 0 {
current as f32 / total as f32
} else {
1.0
};
let global_pct = 0.01 + phase_pct * 0.34;
let msg = format!("Posts: {current}/{total} \u{2014} {name}");
cb(global_pct, &msg);
@@ -76,7 +80,10 @@ pub fn rebuild_from_filesystem_with_progress(
f
});
let post_report = post::rebuild_posts_from_filesystem_with_progress(
conn, data_dir, project_id, post_item_cb,
conn,
data_dir,
project_id,
post_item_cb,
)?;
report.posts_created = post_report.posts_created;
report.posts_updated = post_report.posts_updated;
@@ -89,7 +96,11 @@ pub fn rebuild_from_filesystem_with_progress(
let media_item_cb: Option<media::ItemProgressFn> = on_progress.as_ref().map(|cb| {
let cb = Arc::clone(cb);
let f: media::ItemProgressFn = Box::new(move |current, total, name| {
let phase_pct = if total > 0 { current as f32 / total as f32 } else { 1.0 };
let phase_pct = if total > 0 {
current as f32 / total as f32
} else {
1.0
};
let global_pct = 0.35 + phase_pct * 0.35;
let msg = format!("Media: {current}/{total} \u{2014} {name}");
cb(global_pct, &msg);
@@ -97,7 +108,10 @@ pub fn rebuild_from_filesystem_with_progress(
f
});
let media_report = media::rebuild_media_from_filesystem_with_progress(
conn, data_dir, project_id, media_item_cb,
conn,
data_dir,
project_id,
media_item_cb,
)?;
report.media_created = media_report.media_created;
report.media_updated = media_report.media_updated;
@@ -133,12 +147,12 @@ pub fn rebuild_from_filesystem_with_progress(
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::db::queries::media as qm;
use crate::db::queries::post as qp;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::queries::script as qs;
use crate::db::queries::template as qtpl;
use crate::db::Database;
use std::fs;
use tempfile::TempDir;
@@ -276,11 +290,7 @@ updatedAt: 2024-01-15T12:00:00.000Z
tags: []
---
";
fs::write(
media_dir.join("test-media-1.jpg.meta"),
sidecar_content,
)
.unwrap();
fs::write(media_dir.join("test-media-1.jpg.meta"), sidecar_content).unwrap();
// Template
let tpl_dir = dir.path().join("templates");

View File

@@ -7,7 +7,7 @@ use uuid::Uuid;
use crate::db::queries::script as qs;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Script, ScriptKind, ScriptStatus};
use crate::util::frontmatter::{write_script_file, ScriptFrontmatter};
use crate::util::frontmatter::{ScriptFrontmatter, write_script_file};
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
/// Create a new draft script. Content stored in DB, no file written.
@@ -53,6 +53,10 @@ pub fn create_script(
}
/// Update a script's metadata and/or content. Bumps version.
#[expect(
clippy::too_many_arguments,
reason = "optional arguments represent independent script field changes"
)]
pub fn update_script(
conn: &Connection,
script_id: &str,
@@ -67,14 +71,13 @@ pub fn update_script(
let mut script = qs::get_script_by_id(conn, script_id)?;
// Slug uniqueness
if let Some(new_slug) = slug {
if new_slug != script.slug {
if qs::get_script_by_slug(conn, project_id, new_slug).is_ok() {
return Err(EngineError::Conflict(format!(
"script slug '{new_slug}' already exists"
)));
}
}
if let Some(new_slug) = slug
&& new_slug != script.slug
&& qs::get_script_by_slug(conn, project_id, new_slug).is_ok()
{
return Err(EngineError::Conflict(format!(
"script slug '{new_slug}' already exists"
)));
}
if let Some(t) = title {
@@ -108,11 +111,7 @@ pub fn update_script(
}
/// Save script content (editor save). Updates DB, bumps version.
pub fn save_script(
conn: &Connection,
script_id: &str,
content: &str,
) -> EngineResult<Script> {
pub fn save_script(conn: &Connection, script_id: &str, content: &str) -> EngineResult<Script> {
let mut script = qs::get_script_by_id(conn, script_id)?;
script.content = Some(content.to_string());
script.version += 1;
@@ -161,11 +160,7 @@ pub fn discover_entrypoints(content: &str) -> Vec<String> {
}
/// Publish a script: write frontmatter+body to file, clear content in DB.
pub fn publish_script(
conn: &Connection,
data_dir: &Path,
script_id: &str,
) -> EngineResult<Script> {
pub fn publish_script(conn: &Connection, data_dir: &Path, script_id: &str) -> EngineResult<Script> {
let mut script = qs::get_script_by_id(conn, script_id)?;
if script.status == ScriptStatus::Published {
@@ -221,9 +216,7 @@ pub fn unpublish_script(
let mut script = qs::get_script_by_id(conn, script_id)?;
if script.status != ScriptStatus::Published {
return Err(EngineError::Conflict(
"script is not published".to_string(),
));
return Err(EngineError::Conflict("script is not published".to_string()));
}
if !script.file_path.is_empty() {
@@ -244,11 +237,7 @@ pub fn unpublish_script(
}
/// Delete a script and its file.
pub fn delete_script(
conn: &Connection,
data_dir: &Path,
script_id: &str,
) -> EngineResult<()> {
pub fn delete_script(conn: &Connection, data_dir: &Path, script_id: &str) -> EngineResult<()> {
let script = qs::get_script_by_id(conn, script_id)?;
// Delete file if exists
@@ -311,8 +300,8 @@ fn validate_lua_blocks(content: &str) -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
@@ -326,7 +315,15 @@ mod tests {
#[test]
fn create_draft_script() {
let (db, _dir) = setup();
let s = create_script(db.conn(), "p1", "My Script", ScriptKind::Macro, "return 'hi'", None).unwrap();
let s = create_script(
db.conn(),
"p1",
"My Script",
ScriptKind::Macro,
"return 'hi'",
None,
)
.unwrap();
assert_eq!(s.title, "My Script");
assert_eq!(s.slug, "my-script");
assert_eq!(s.kind, ScriptKind::Macro);
@@ -340,7 +337,15 @@ mod tests {
#[test]
fn create_with_custom_entrypoint() {
let (db, _dir) = setup();
let s = create_script(db.conn(), "p1", "Util", ScriptKind::Utility, "", Some("main")).unwrap();
let s = create_script(
db.conn(),
"p1",
"Util",
ScriptKind::Utility,
"",
Some("main"),
)
.unwrap();
assert_eq!(s.entrypoint, "main");
}
@@ -357,7 +362,18 @@ mod tests {
fn update_script_bumps_version() {
let (db, _dir) = setup();
let s = create_script(db.conn(), "p1", "S", ScriptKind::Macro, "old", None).unwrap();
let updated = update_script(db.conn(), &s.id, "p1", Some("New"), None, None, None, None, Some("new")).unwrap();
let updated = update_script(
db.conn(),
&s.id,
"p1",
Some("New"),
None,
None,
None,
None,
Some("new"),
)
.unwrap();
assert_eq!(updated.title, "New");
assert_eq!(updated.content, Some("new".to_string()));
assert_eq!(updated.version, 2);
@@ -375,7 +391,15 @@ mod tests {
#[test]
fn publish_and_unpublish_script() {
let (db, dir) = setup();
let s = create_script(db.conn(), "p1", "Pub", ScriptKind::Macro, "function render()\n return 'hi'\nend", None).unwrap();
let s = create_script(
db.conn(),
"p1",
"Pub",
ScriptKind::Macro,
"function render()\n return 'hi'\nend",
None,
)
.unwrap();
let published = publish_script(db.conn(), dir.path(), &s.id).unwrap();
assert_eq!(published.status, ScriptStatus::Published);
@@ -391,7 +415,15 @@ mod tests {
#[test]
fn delete_script_removes_file() {
let (db, dir) = setup();
let s = create_script(db.conn(), "p1", "Del", ScriptKind::Macro, "function render()\nend", None).unwrap();
let s = create_script(
db.conn(),
"p1",
"Del",
ScriptKind::Macro,
"function render()\nend",
None,
)
.unwrap();
publish_script(db.conn(), dir.path(), &s.id).unwrap();
assert!(dir.path().join("scripts/del.lua").exists());

View File

@@ -140,8 +140,8 @@ fn rebuild_single_script(
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
@@ -176,8 +176,7 @@ end
";
fs::write(scripts_dir.join("my-macro.lua"), content).unwrap();
let report =
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
let report = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 1);
assert_eq!(report.updated, 0);
@@ -191,7 +190,10 @@ end
assert!(script.enabled);
assert_eq!(script.version, 1);
assert_eq!(script.status, ScriptStatus::Published);
assert!(script.content.is_none(), "published script should have content=None in DB");
assert!(
script.content.is_none(),
"published script should have content=None in DB"
);
assert_eq!(script.file_path, "scripts/my-macro.lua");
}
@@ -219,8 +221,7 @@ def main():
";
fs::write(scripts_dir.join("my-utility.py"), content).unwrap();
let report =
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
let report = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 1);
assert_eq!(report.updated, 0);
@@ -280,8 +281,7 @@ end
";
fs::write(scripts_dir.join("updated-script.lua"), content).unwrap();
let report =
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
let report = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 0);
assert_eq!(report.updated, 1);
@@ -321,14 +321,12 @@ function run() end
fs::write(scripts_dir.join("idem.lua"), content).unwrap();
// First run
let r1 =
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
let r1 = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(r1.created, 1);
assert_eq!(r1.updated, 0);
// Second run - should update, not create
let r2 =
rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
let r2 = rebuild_scripts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(r2.created, 0);
assert_eq!(r2.updated, 1);

View File

@@ -13,42 +13,222 @@ pub(crate) struct BundledSiteAsset {
}
const BUNDLED_SITE_ASSETS: &[BundledSiteAsset] = &[
BundledSiteAsset { relative_path: "assets/bds.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/bds.css") },
BundledSiteAsset { relative_path: "assets/calendar-runtime.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/calendar-runtime.js") },
BundledSiteAsset { relative_path: "assets/code-enhancements.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/code-enhancements.js") },
BundledSiteAsset { relative_path: "assets/d3.layout.cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/d3.layout.cloud.js") },
BundledSiteAsset { relative_path: "assets/highlight.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.css") },
BundledSiteAsset { relative_path: "assets/highlight.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.js") },
BundledSiteAsset { relative_path: "assets/lightbox.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.css") },
BundledSiteAsset { relative_path: "assets/lightbox.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.js") },
BundledSiteAsset { relative_path: "assets/pico.amber.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.amber.min.css") },
BundledSiteAsset { relative_path: "assets/pico.blue.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.blue.min.css") },
BundledSiteAsset { relative_path: "assets/pico.cyan.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.cyan.min.css") },
BundledSiteAsset { relative_path: "assets/pico.fuchsia.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.fuchsia.min.css") },
BundledSiteAsset { relative_path: "assets/pico.green.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.green.min.css") },
BundledSiteAsset { relative_path: "assets/pico.grey.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.grey.min.css") },
BundledSiteAsset { relative_path: "assets/pico.indigo.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.indigo.min.css") },
BundledSiteAsset { relative_path: "assets/pico.jade.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.jade.min.css") },
BundledSiteAsset { relative_path: "assets/pico.lime.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.lime.min.css") },
BundledSiteAsset { relative_path: "assets/pico.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.min.css") },
BundledSiteAsset { relative_path: "assets/pico.orange.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.orange.min.css") },
BundledSiteAsset { relative_path: "assets/pico.pink.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pink.min.css") },
BundledSiteAsset { relative_path: "assets/pico.pumpkin.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pumpkin.min.css") },
BundledSiteAsset { relative_path: "assets/pico.purple.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.purple.min.css") },
BundledSiteAsset { relative_path: "assets/pico.red.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.red.min.css") },
BundledSiteAsset { relative_path: "assets/pico.sand.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.sand.min.css") },
BundledSiteAsset { relative_path: "assets/pico.slate.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.slate.min.css") },
BundledSiteAsset { relative_path: "assets/pico.violet.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.violet.min.css") },
BundledSiteAsset { relative_path: "assets/pico.yellow.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.yellow.min.css") },
BundledSiteAsset { relative_path: "assets/pico.zinc.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.zinc.min.css") },
BundledSiteAsset { relative_path: "assets/search-runtime.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/search-runtime.js") },
BundledSiteAsset { relative_path: "assets/tag-cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/tag-cloud.js") },
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.css") },
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.js") },
BundledSiteAsset { relative_path: "images/close.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/close.png") },
BundledSiteAsset { relative_path: "images/loading.gif", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/loading.gif") },
BundledSiteAsset { relative_path: "images/next.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/next.png") },
BundledSiteAsset { relative_path: "images/prev.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/prev.png") },
BundledSiteAsset {
relative_path: "assets/bds.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/bds.css"
),
},
BundledSiteAsset {
relative_path: "assets/calendar-runtime.js",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/calendar-runtime.js"
),
},
BundledSiteAsset {
relative_path: "assets/code-enhancements.js",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/code-enhancements.js"
),
},
BundledSiteAsset {
relative_path: "assets/d3.layout.cloud.js",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/d3.layout.cloud.js"
),
},
BundledSiteAsset {
relative_path: "assets/highlight.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/highlight.min.js",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.js"
),
},
BundledSiteAsset {
relative_path: "assets/lightbox.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/lightbox.min.js",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.js"
),
},
BundledSiteAsset {
relative_path: "assets/pico.amber.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.amber.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.blue.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.blue.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.cyan.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.cyan.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.fuchsia.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.fuchsia.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.green.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.green.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.grey.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.grey.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.indigo.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.indigo.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.jade.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.jade.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.lime.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.lime.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.orange.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.orange.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.pink.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pink.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.pumpkin.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pumpkin.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.purple.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.purple.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.red.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.red.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.sand.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.sand.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.slate.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.slate.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.violet.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.violet.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.yellow.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.yellow.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/pico.zinc.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.zinc.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/search-runtime.js",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/search-runtime.js"
),
},
BundledSiteAsset {
relative_path: "assets/tag-cloud.js",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/tag-cloud.js"
),
},
BundledSiteAsset {
relative_path: "assets/vanilla-calendar.min.css",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.css"
),
},
BundledSiteAsset {
relative_path: "assets/vanilla-calendar.min.js",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.js"
),
},
BundledSiteAsset {
relative_path: "images/close.png",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/images/close.png"
),
},
BundledSiteAsset {
relative_path: "images/loading.gif",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/images/loading.gif"
),
},
BundledSiteAsset {
relative_path: "images/next.png",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/images/next.png"
),
},
BundledSiteAsset {
relative_path: "images/prev.png",
bytes: include_bytes!(
"../../../../fixtures/golden-generated-sites/rfc1437-sample/images/prev.png"
),
},
];
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
@@ -72,12 +252,22 @@ pub(crate) fn write_bundled_site_assets(
report: &mut crate::engine::generation::GenerationReport,
) -> EngineResult<()> {
for asset in BUNDLED_SITE_ASSETS {
match write_generated_bytes(conn, output_dir, project_id, asset.relative_path, asset.bytes)
.map_err(|error| EngineError::Parse(error.to_string()))?
match write_generated_bytes(
conn,
output_dir,
project_id,
asset.relative_path,
asset.bytes,
)
.map_err(|error| EngineError::Parse(error.to_string()))?
{
GeneratedWriteOutcome::Written => report.written_paths.push(asset.relative_path.to_string()),
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(asset.relative_path.to_string()),
GeneratedWriteOutcome::Written => {
report.written_paths.push(asset.relative_path.to_string())
}
GeneratedWriteOutcome::SkippedUnchanged => {
report.skipped_paths.push(asset.relative_path.to_string())
}
}
}
Ok(())
}
}

View File

@@ -7,8 +7,8 @@ use crate::db::queries::post as post_q;
use crate::db::queries::tag as tag_q;
use crate::engine::meta;
use crate::engine::{EngineError, EngineResult};
use crate::model::metadata::TagEntry;
use crate::model::Tag;
use crate::model::metadata::TagEntry;
use crate::util::now_unix_ms;
/// Create a new tag. Case-insensitive duplicate check.
@@ -240,10 +240,7 @@ pub fn import_tags_from_file(
/// Sync tags from all posts: collect unique tag names, create missing tags in DB.
/// This is additive only — it does NOT rewrite tags.json.
pub fn sync_tags_from_posts(
conn: &Connection,
project_id: &str,
) -> EngineResult<Vec<Tag>> {
pub fn sync_tags_from_posts(conn: &Connection, project_id: &str) -> EngineResult<Vec<Tag>> {
let posts = post_q::list_posts_by_project(conn, project_id)?;
// Collect all unique tag names from posts (preserve original casing per spec).
@@ -290,11 +287,7 @@ pub fn discover_tags(
}
/// Rewrite meta/tags.json from DB state.
pub fn rewrite_tags_json(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<()> {
pub fn rewrite_tags_json(conn: &Connection, data_dir: &Path, project_id: &str) -> EngineResult<()> {
let tags = tag_q::list_tags_by_project(conn, project_id)?;
let entries: Vec<TagEntry> = tags
.into_iter()
@@ -317,8 +310,8 @@ fn flush_post_frontmatter(
data_dir: &Path,
post_ids: &[String],
) -> EngineResult<()> {
use crate::util::frontmatter::write_post_file;
use crate::util::atomic_write_str;
use crate::util::frontmatter::write_post_file;
for post_id in post_ids {
let post = post_q::get_post_by_id(conn, post_id)?;
@@ -328,7 +321,7 @@ fn flush_post_frontmatter(
// Read existing body from file
let content = std::fs::read_to_string(&abs_path)?;
let (_fm, body) = crate::util::frontmatter::read_post_file(&content)
.map_err(|e| EngineError::Parse(e))?;
.map_err(EngineError::Parse)?;
// Rewrite with updated frontmatter
let file_content = write_post_file(&post, &body);
atomic_write_str(&abs_path, &file_content)?;
@@ -348,8 +341,7 @@ fn remove_tag_name_from_posts(
let mut modified = Vec::new();
for mut post in posts {
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_name)) {
post.tags
.retain(|t| !t.eq_ignore_ascii_case(tag_name));
post.tags.retain(|t| !t.eq_ignore_ascii_case(tag_name));
post.updated_at = now;
post_q::update_post(conn, &post)?;
modified.push(post.id.clone());
@@ -361,9 +353,9 @@ fn remove_tag_name_from_posts(
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::db::queries::post::insert_post;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use crate::model::{Post, PostStatus};
use tempfile::TempDir;
@@ -470,11 +462,7 @@ mod tests {
fn rename_tag_updates_posts() {
let (db, dir) = setup();
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
insert_post(
db.conn(),
&make_post("x1", "hello", vec!["rust".into()]),
)
.unwrap();
insert_post(db.conn(), &make_post("x1", "hello", vec!["rust".into()])).unwrap();
rename_tag(db.conn(), dir.path(), "p1", &tag.id, "golang").unwrap();
@@ -493,25 +481,14 @@ mod tests {
let t2 = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
let t3 = create_tag(db.conn(), dir.path(), "p1", "target", None).unwrap();
insert_post(
db.conn(),
&make_post("x1", "a", vec!["rs".into()]),
)
.unwrap();
insert_post(db.conn(), &make_post("x1", "a", vec!["rs".into()])).unwrap();
insert_post(
db.conn(),
&make_post("x2", "b", vec!["rust".into(), "target".into()]),
)
.unwrap();
merge_tags(
db.conn(),
dir.path(),
"p1",
&[&t1.id, &t2.id],
&t3.id,
)
.unwrap();
merge_tags(db.conn(), dir.path(), "p1", &[&t1.id, &t2.id], &t3.id).unwrap();
// Post x1 should now have "target"
let p1 = crate::db::queries::post::get_post_by_id(db.conn(), "x1").unwrap();
@@ -563,7 +540,10 @@ mod tests {
assert_eq!(tags.len(), 2);
let entries = meta::read_tags_json(dir.path()).unwrap();
let names = entries.into_iter().map(|entry| entry.name).collect::<Vec<_>>();
let names = entries
.into_iter()
.map(|entry| entry.name)
.collect::<Vec<_>>();
assert_eq!(names, vec!["rust".to_string(), "web".to_string()]);
}
@@ -572,8 +552,16 @@ mod tests {
let (db, dir) = setup();
// Write tags.json with colors
let entries = vec![
TagEntry { name: "rust".into(), color: Some("#ff0000".into()), post_template_slug: None },
TagEntry { name: "web".into(), color: None, post_template_slug: Some("blog".into()) },
TagEntry {
name: "rust".into(),
color: Some("#ff0000".into()),
post_template_slug: None,
},
TagEntry {
name: "web".into(),
color: None,
post_template_slug: Some("blog".into()),
},
];
meta::write_tags_json(dir.path(), &entries).unwrap();
@@ -605,9 +593,18 @@ mod tests {
use crate::db::fts::ensure_fts_tables;
ensure_fts_tables(db.conn()).unwrap();
let post = crate::engine::post::create_post(
db.conn(), dir.path(), "p1", "Tagged Post", Some("body content"),
vec!["rust".into()], vec![], None, None, None,
).unwrap();
db.conn(),
dir.path(),
"p1",
"Tagged Post",
Some("body content"),
vec!["rust".into()],
vec![],
None,
None,
None,
)
.unwrap();
crate::engine::post::publish_post(db.conn(), dir.path(), &post.id).unwrap();
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
@@ -616,7 +613,13 @@ mod tests {
// Read the file from disk and verify tag was updated
let from_db = crate::db::queries::post::get_post_by_id(db.conn(), &post.id).unwrap();
let file_content = std::fs::read_to_string(dir.path().join(&from_db.file_path)).unwrap();
assert!(file_content.contains("golang"), "frontmatter should contain renamed tag");
assert!(!file_content.contains("rust"), "frontmatter should not contain old tag name");
assert!(
file_content.contains("golang"),
"frontmatter should contain renamed tag"
);
assert!(
!file_content.contains("rust"),
"frontmatter should not contain old tag name"
);
}
}

View File

@@ -1,6 +1,6 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use std::time::{Duration, Instant};
/// Unique task identifier.
pub type TaskId = u64;
@@ -23,6 +23,19 @@ pub struct TaskProgress {
pub percent: Option<f32>,
}
/// Immutable task state exposed to UI consumers.
#[derive(Debug, Clone)]
pub struct TaskSnapshot {
pub id: TaskId,
pub label: String,
pub group_id: Option<String>,
pub group_name: Option<String>,
pub status: TaskStatus,
pub progress: Option<f32>,
pub message: Option<String>,
pub created_at: Instant,
}
/// Entry tracking a task.
#[derive(Debug)]
struct TaskEntry {
@@ -35,6 +48,7 @@ struct TaskEntry {
progress: Option<f32>,
message: Option<String>,
created_at: Instant,
finished_at: Option<Instant>,
last_progress_report: Option<Instant>,
}
@@ -71,17 +85,23 @@ impl TaskManager {
progress: None,
message: None,
created_at: Instant::now(),
finished_at: None,
last_progress_report: None,
};
let mut tasks = self.tasks.lock().unwrap();
tasks.push(entry);
// Auto-start if under capacity
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
if running < self.max_concurrent {
if let Some(t) = tasks.iter_mut().find(|t| t.id == id && t.status == TaskStatus::Pending) {
t.status = TaskStatus::Running;
}
let running = tasks
.iter()
.filter(|t| t.status == TaskStatus::Running)
.count();
if running < self.max_concurrent
&& let Some(t) = tasks
.iter_mut()
.find(|t| t.id == id && t.status == TaskStatus::Pending)
{
t.status = TaskStatus::Running;
}
id
}
@@ -101,15 +121,18 @@ impl TaskManager {
/// Running, false if concurrency is at capacity or the task is not Queued.
pub fn try_start(&self, task_id: TaskId) -> bool {
let mut tasks = self.tasks.lock().unwrap();
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
let running = tasks
.iter()
.filter(|t| t.status == TaskStatus::Running)
.count();
if running >= self.max_concurrent {
return false;
}
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
if entry.status == TaskStatus::Pending {
entry.status = TaskStatus::Running;
return true;
}
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
&& entry.status == TaskStatus::Pending
{
entry.status = TaskStatus::Running;
return true;
}
false
}
@@ -117,11 +140,12 @@ impl TaskManager {
/// Mark a task as completed.
pub fn complete(&self, task_id: TaskId) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
if matches!(entry.status, TaskStatus::Running) {
entry.status = TaskStatus::Completed;
entry.progress = Some(1.0);
}
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
&& matches!(entry.status, TaskStatus::Running)
{
entry.status = TaskStatus::Completed;
entry.progress = Some(1.0);
entry.finished_at = Some(Instant::now());
}
Self::promote_next(&mut tasks, self.max_concurrent);
}
@@ -129,11 +153,12 @@ impl TaskManager {
/// Mark a task as failed with an error message.
pub fn fail(&self, task_id: TaskId, error: String) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
if matches!(entry.status, TaskStatus::Running) {
entry.message = Some(error.clone());
entry.status = TaskStatus::Failed(error);
}
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
&& matches!(entry.status, TaskStatus::Running)
{
entry.message = Some(error.clone());
entry.status = TaskStatus::Failed(error);
entry.finished_at = Some(Instant::now());
}
Self::promote_next(&mut tasks, self.max_concurrent);
}
@@ -141,11 +166,12 @@ impl TaskManager {
/// Cancel a task by setting its cancel flag and status.
pub fn cancel(&self, task_id: TaskId) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
if matches!(entry.status, TaskStatus::Running | TaskStatus::Pending) {
entry.cancel_flag.store(true, Ordering::Release);
entry.status = TaskStatus::Cancelled;
}
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
&& matches!(entry.status, TaskStatus::Running | TaskStatus::Pending)
{
entry.cancel_flag.store(true, Ordering::Release);
entry.status = TaskStatus::Cancelled;
entry.finished_at = Some(Instant::now());
}
Self::promote_next(&mut tasks, self.max_concurrent);
}
@@ -163,54 +189,73 @@ impl TaskManager {
/// Return the current status of a task.
pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> {
let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.id == task_id).map(|t| t.status.clone())
tasks
.iter()
.find(|t| t.id == task_id)
.map(|t| t.status.clone())
}
/// Count tasks that are still queued.
pub fn pending_count(&self) -> usize {
let tasks = self.tasks.lock().unwrap();
tasks.iter().filter(|t| t.status == TaskStatus::Pending).count()
tasks
.iter()
.filter(|t| t.status == TaskStatus::Pending)
.count()
}
/// Count tasks that are currently running.
pub fn running_count(&self) -> usize {
let tasks = self.tasks.lock().unwrap();
tasks.iter().filter(|t| t.status == TaskStatus::Running).count()
tasks
.iter()
.filter(|t| t.status == TaskStatus::Running)
.count()
}
/// Remove all completed, failed, and cancelled tasks.
pub fn drain_completed(&self) {
/// Remove finished tasks older than the configured retention period.
pub fn evict_expired(&self) {
let cutoff = Instant::now() - FINISHED_TASK_TTL;
let mut tasks = self.tasks.lock().unwrap();
tasks.retain(|t| matches!(t.status, TaskStatus::Pending | TaskStatus::Running));
tasks.retain(|task| {
task.finished_at
.is_none_or(|finished_at| finished_at > cutoff)
});
}
/// Return the label of a task.
pub fn label(&self, task_id: TaskId) -> Option<String> {
let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.id == task_id).map(|t| t.label.clone())
tasks
.iter()
.find(|t| t.id == task_id)
.map(|t| t.label.clone())
}
/// Return the id of the first queued task (FIFO order).
pub fn next_queued(&self) -> Option<TaskId> {
let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.status == TaskStatus::Pending).map(|t| t.id)
tasks
.iter()
.find(|t| t.status == TaskStatus::Pending)
.map(|t| t.id)
}
/// Update progress for a running task. Throttled to at most once per 250ms.
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
if entry.status == TaskStatus::Running {
let now = Instant::now();
let should_report = match entry.last_progress_report {
Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128,
None => true,
};
if should_report {
entry.progress = progress;
entry.message = message;
entry.last_progress_report = Some(now);
}
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
&& entry.status == TaskStatus::Running
{
let now = Instant::now();
let should_report = match entry.last_progress_report {
Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128,
None => true,
};
if should_report {
entry.progress = progress;
entry.message = message;
entry.last_progress_report = Some(now);
}
}
}
@@ -218,31 +263,59 @@ impl TaskManager {
/// Return the current progress of a task.
pub fn progress(&self, task_id: TaskId) -> Option<f32> {
let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.id == task_id).and_then(|t| t.progress)
tasks
.iter()
.find(|t| t.id == task_id)
.and_then(|t| t.progress)
}
/// Return the current message of a task.
pub fn message(&self, task_id: TaskId) -> Option<String> {
let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.id == task_id).and_then(|t| t.message.clone())
tasks
.iter()
.find(|t| t.id == task_id)
.and_then(|t| t.message.clone())
}
/// Return a snapshot of all tasks for UI display.
pub fn snapshots(&self) -> Vec<(TaskId, String, TaskStatus, Option<f32>, Option<String>)> {
pub fn snapshots(&self) -> Vec<TaskSnapshot> {
let tasks = self.tasks.lock().unwrap();
tasks
let mut snapshots = tasks
.iter()
.map(|t| (t.id, t.label.clone(), t.status.clone(), t.progress, t.message.clone()))
.collect()
.filter(|task| task.finished_at.is_none())
.chain(
tasks
.iter()
.rev()
.filter(|task| task.finished_at.is_some())
.take(RECENT_FINISHED_LIMIT),
)
.map(|task| TaskSnapshot {
id: task.id,
label: task.label.clone(),
group_id: task.group_id.clone(),
group_name: task.group_name.clone(),
status: task.status.clone(),
progress: task.progress,
message: task.message.clone(),
created_at: task.created_at,
})
.collect::<Vec<_>>();
snapshots.sort_by_key(|snapshot| snapshot.created_at);
snapshots
}
/// Promote the next queued task to running if capacity allows.
fn promote_next(tasks: &mut Vec<TaskEntry>, max_concurrent: usize) {
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
if running < max_concurrent {
if let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Pending) {
t.status = TaskStatus::Running;
}
fn promote_next(tasks: &mut [TaskEntry], max_concurrent: usize) {
let running = tasks
.iter()
.filter(|t| t.status == TaskStatus::Running)
.count();
if running < max_concurrent
&& let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Pending)
{
t.status = TaskStatus::Running;
}
}
}
@@ -255,6 +328,8 @@ impl Default for TaskManager {
/// Default progress throttle interval (250ms per spec).
pub const PROGRESS_THROTTLE_MS: u64 = 250;
pub const RECENT_FINISHED_LIMIT: usize = 10;
pub const FINISHED_TASK_TTL: Duration = Duration::from_secs(60 * 60);
/// Throttles progress reporting to avoid flooding.
pub struct ProgressThrottle {
@@ -310,14 +385,14 @@ mod tests {
#[test]
fn fifo_order() {
let mgr = TaskManager::new(1); // limit to 1 to test FIFO
let a = mgr.submit("first"); // auto-starts
let a = mgr.submit("first"); // auto-starts
let b = mgr.submit("second"); // queued
let c = mgr.submit("third"); // queued
let c = mgr.submit("third"); // queued
assert_eq!(mgr.status(a), Some(TaskStatus::Running));
assert_eq!(mgr.next_queued(), Some(b));
mgr.complete(a); // should auto-promote b
mgr.complete(a); // should auto-promote b
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
assert_eq!(mgr.next_queued(), Some(c));
}
@@ -344,17 +419,20 @@ mod tests {
mgr.fail(bad, "disk full".into());
assert_eq!(mgr.status(ok), Some(TaskStatus::Completed));
assert_eq!(mgr.status(bad), Some(TaskStatus::Failed("disk full".into())));
assert_eq!(
mgr.status(bad),
Some(TaskStatus::Failed("disk full".into()))
);
// Progress should be 1.0 on completed
assert_eq!(mgr.progress(ok), Some(1.0));
}
#[test]
fn drain_removes_finished() {
fn eviction_removes_only_expired_finished_tasks() {
let mgr = TaskManager::new(3);
let a = mgr.submit("done"); // auto-starts
let b = mgr.submit("broken"); // auto-starts
let e = mgr.submit("busy"); // auto-starts
let a = mgr.submit("done"); // auto-starts
let b = mgr.submit("broken"); // auto-starts
let e = mgr.submit("busy"); // auto-starts
let _c = mgr.submit("stopped"); // queued (at capacity)
let _d = mgr.submit("waiting"); // queued
@@ -364,7 +442,14 @@ mod tests {
// After a completes: c promoted to running
// After b fails: d promoted to running
mgr.drain_completed();
{
let mut tasks = mgr.tasks.lock().unwrap();
for task in tasks.iter_mut().filter(|task| task.finished_at.is_some()) {
task.finished_at =
Some(Instant::now() - FINISHED_TASK_TTL - Duration::from_secs(1));
}
}
mgr.evict_expired();
assert_eq!(mgr.status(a), None);
assert_eq!(mgr.status(b), None);
@@ -372,10 +457,21 @@ mod tests {
assert_eq!(mgr.status(e), Some(TaskStatus::Running));
}
#[test]
fn snapshots_retain_only_ten_finished_tasks() {
let mgr = TaskManager::new(20);
for index in 0..12 {
let id = mgr.submit(&format!("task {index}"));
mgr.complete(id);
}
assert_eq!(mgr.snapshots().len(), 10);
}
#[test]
fn completing_task_starts_next_queued() {
let mgr = TaskManager::new(1);
let a = mgr.submit("first"); // auto-starts
let a = mgr.submit("first"); // auto-starts
let b = mgr.submit("second"); // queued
assert_eq!(mgr.status(a), Some(TaskStatus::Running));

View File

@@ -7,7 +7,7 @@ use uuid::Uuid;
use crate::db::queries::template as qt;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Template, TemplateKind, TemplateStatus};
use crate::util::frontmatter::{write_template_file, TemplateFrontmatter};
use crate::util::frontmatter::{TemplateFrontmatter, write_template_file};
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
/// Create a new draft template. Content stored in DB, no file written.
@@ -51,6 +51,10 @@ pub fn create_template(
}
/// Update a template's metadata and/or content. Bumps version.
#[expect(
clippy::too_many_arguments,
reason = "optional arguments represent independent template field changes"
)]
pub fn update_template(
conn: &Connection,
template_id: &str,
@@ -64,14 +68,13 @@ pub fn update_template(
let mut tpl = qt::get_template_by_id(conn, template_id)?;
// Slug uniqueness check
if let Some(new_slug) = slug {
if new_slug != tpl.slug {
if qt::get_template_by_slug(conn, project_id, new_slug).is_ok() {
return Err(EngineError::Conflict(format!(
"template slug '{new_slug}' already exists"
)));
}
}
if let Some(new_slug) = slug
&& new_slug != tpl.slug
&& qt::get_template_by_slug(conn, project_id, new_slug).is_ok()
{
return Err(EngineError::Conflict(format!(
"template slug '{new_slug}' already exists"
)));
}
if let Some(t) = title {
@@ -149,10 +152,7 @@ pub fn publish_template(
));
}
let body = tpl
.content
.clone()
.unwrap_or_default();
let body = tpl.content.clone().unwrap_or_default();
// Validate before publishing
validate_template(&body).map_err(EngineError::Validation)?;
@@ -210,9 +210,8 @@ pub fn unpublish_template(
let abs_path = data_dir.join(&tpl.file_path);
if abs_path.exists() {
let file_content = fs::read_to_string(&abs_path)?;
let (_fm, body) =
crate::util::frontmatter::read_template_file(&file_content)
.map_err(EngineError::Parse)?;
let (_fm, body) = crate::util::frontmatter::read_template_file(&file_content)
.map_err(EngineError::Parse)?;
tpl.content = Some(body);
}
}
@@ -340,25 +339,29 @@ fn validate_liquid_blocks(content: &str) -> Result<(), String> {
let start = i + 2;
if let Some(end_offset) = content[start..].find("%}") {
let tag_content = content[start..start + end_offset].trim();
let tag_content = tag_content.trim_start_matches('-').trim_end_matches('-').trim();
let first_word = tag_content
.split_whitespace()
.next()
.unwrap_or("");
let tag_content = tag_content
.trim_start_matches('-')
.trim_end_matches('-')
.trim();
let first_word = tag_content.split_whitespace().next().unwrap_or("");
match first_word {
"if" => if_depth += 1,
"endif" => {
if_depth -= 1;
if if_depth < 0 {
return Err("unexpected {% endif %} without matching {% if %}".to_string());
return Err(
"unexpected {% endif %} without matching {% if %}".to_string()
);
}
}
"for" => for_depth += 1,
"endfor" => {
for_depth -= 1;
if for_depth < 0 {
return Err("unexpected {% endfor %} without matching {% for %}".to_string());
return Err(
"unexpected {% endfor %} without matching {% for %}".to_string()
);
}
}
_ => {}
@@ -416,8 +419,8 @@ fn null_template_slug_on_tags(conn: &Connection, slug: &str) -> EngineResult<()>
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
@@ -431,7 +434,14 @@ mod tests {
#[test]
fn create_draft_template() {
let (db, _dir) = setup();
let tpl = create_template(db.conn(), "p1", "My Template", TemplateKind::Post, "<p>hello</p>").unwrap();
let tpl = create_template(
db.conn(),
"p1",
"My Template",
TemplateKind::Post,
"<p>hello</p>",
)
.unwrap();
assert_eq!(tpl.title, "My Template");
assert_eq!(tpl.slug, "my-template");
assert_eq!(tpl.kind, TemplateKind::Post);
@@ -456,9 +466,16 @@ mod tests {
let (db, _dir) = setup();
let tpl = create_template(db.conn(), "p1", "Tpl", TemplateKind::Post, "old").unwrap();
let updated = update_template(
db.conn(), &tpl.id, "p1",
Some("New Title"), None, None, None, Some("new content"),
).unwrap();
db.conn(),
&tpl.id,
"p1",
Some("New Title"),
None,
None,
None,
Some("new content"),
)
.unwrap();
assert_eq!(updated.title, "New Title");
assert_eq!(updated.content, Some("new content".to_string()));
assert_eq!(updated.version, 2);
@@ -469,7 +486,16 @@ mod tests {
let (db, _dir) = setup();
create_template(db.conn(), "p1", "Alpha", TemplateKind::Post, "").unwrap();
let t2 = create_template(db.conn(), "p1", "Beta", TemplateKind::Post, "").unwrap();
let result = update_template(db.conn(), &t2.id, "p1", None, Some("alpha"), None, None, None);
let result = update_template(
db.conn(),
&t2.id,
"p1",
None,
Some("alpha"),
None,
None,
None,
);
assert!(result.is_err());
}
@@ -485,7 +511,14 @@ mod tests {
#[test]
fn publish_and_unpublish_template() {
let (db, dir) = setup();
let tpl = create_template(db.conn(), "p1", "Pub", TemplateKind::Post, "<div>body</div>").unwrap();
let tpl = create_template(
db.conn(),
"p1",
"Pub",
TemplateKind::Post,
"<div>body</div>",
)
.unwrap();
// Publish
let published = publish_template(db.conn(), dir.path(), &tpl.id).unwrap();

View File

@@ -139,8 +139,8 @@ fn rebuild_single_template(
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
@@ -172,8 +172,7 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
";
fs::write(tpl_dir.join("my-template.liquid"), content).unwrap();
let report =
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
let report = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 1);
assert_eq!(report.updated, 0);
@@ -186,7 +185,10 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
assert!(tpl.enabled);
assert_eq!(tpl.version, 1);
assert_eq!(tpl.status, TemplateStatus::Published);
assert!(tpl.content.is_none(), "published template should have content=None in DB");
assert!(
tpl.content.is_none(),
"published template should have content=None in DB"
);
assert_eq!(tpl.file_path, "templates/my-template.liquid");
}
@@ -230,8 +232,7 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
";
fs::write(tpl_dir.join("updated-slug.liquid"), content).unwrap();
let report =
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
let report = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 0);
assert_eq!(report.updated, 1);
@@ -256,8 +257,7 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
fs::write(tpl_dir.join("readme.txt"), "not a template").unwrap();
fs::write(tpl_dir.join("styles.css"), "body {}").unwrap();
let report =
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
let report = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.created, 0);
assert_eq!(report.updated, 0);
@@ -286,14 +286,12 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
fs::write(tpl_dir.join("idem.liquid"), content).unwrap();
// First run
let r1 =
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
let r1 = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(r1.created, 1);
assert_eq!(r1.updated, 0);
// Second run - should update, not create
let r2 =
rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
let r2 = rebuild_templates_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(r2.created, 0);
assert_eq!(r2.updated, 1);

View File

@@ -1,9 +1,9 @@
/// 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.
//! 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)]
@@ -35,8 +35,10 @@ 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"];
let block_tags = [
"if", "unless", "for", "case", "capture", "comment", "raw", "paginate", "tablerow",
"block", "schema",
];
for tag in &block_tags {
let open_pattern = format!("{{% {tag}");

View File

@@ -25,8 +25,9 @@ pub fn validate_site(
let metadata = crate::engine::meta::read_project_json(data_dir)?;
let output_dir = generated_output_dir(data_dir);
let published_posts = load_published_posts(data_dir, conn, project_id)?;
let artifacts = build_site_render_artifacts(conn, data_dir, project_id, &metadata, &published_posts)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let artifacts =
build_site_render_artifacts(conn, data_dir, project_id, &metadata, &published_posts)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let mut expected = artifacts
.pages
@@ -36,7 +37,12 @@ pub fn validate_site(
expected.insert("calendar.json".to_string());
expected.insert("rss.xml".to_string());
for language in render_languages(&metadata) {
let prefix = if language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
let prefix = if language
== metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string())
{
String::new()
} else {
format!("{language}/")
@@ -79,7 +85,9 @@ pub fn validate_site(
let mut stale_pages = Vec::new();
for rel in expected.intersection(&actual) {
if let Ok(stored) = queries::generated_file_hash::get_generated_file_hash(conn, project_id, rel) {
if let Ok(stored) =
queries::generated_file_hash::get_generated_file_hash(conn, project_id, rel)
{
let actual_hash = file_hash(&output_dir.join(rel))?;
if actual_hash != stored.content_hash {
stale_pages.push(rel.clone());
@@ -114,13 +122,17 @@ fn load_published_posts(
) -> EngineResult<Vec<(Post, String)>> {
let posts = queries::post::list_posts_by_project(conn, project_id)?;
let mut published = Vec::new();
for post in posts.into_iter().filter(|post| post.status == PostStatus::Published) {
for post in posts
.into_iter()
.filter(|post| post.status == PostStatus::Published)
{
let body = if let Some(content) = &post.content {
content.clone()
} else if let Some(content) = &post.published_content {
content.clone()
} else {
let raw = std::fs::read_to_string(data_dir.join(post.file_path.trim_start_matches('/')))?;
let raw =
std::fs::read_to_string(data_dir.join(post.file_path.trim_start_matches('/')))?;
crate::util::frontmatter::read_post_file(&raw)
.map(|(_, body)| body)
.map_err(EngineError::Parse)?
@@ -131,12 +143,18 @@ fn load_published_posts(
}
fn render_languages(metadata: &crate::model::ProjectMetadata) -> Vec<String> {
let main = metadata.main_language.clone().unwrap_or_else(|| "en".to_string());
let main = metadata
.main_language
.clone()
.unwrap_or_else(|| "en".to_string());
let mut languages = vec![main.clone()];
for language in &metadata.blog_languages {
if !languages.iter().any(|existing| existing.eq_ignore_ascii_case(language)) {
if !languages
.iter()
.any(|existing| existing.eq_ignore_ascii_case(language))
{
languages.push(language.clone());
}
}
languages
}
}

View File

@@ -63,7 +63,14 @@ pub fn validate_translations(
blog_languages: &[String],
main_language: &str,
) -> EngineResult<TranslationValidationReport> {
validate_translations_with_progress(conn, data_dir, project_id, blog_languages, main_language, None)
validate_translations_with_progress(
conn,
data_dir,
project_id,
blog_languages,
main_language,
None,
)
}
/// Like `validate_translations` but with optional per-item progress.
@@ -193,11 +200,13 @@ pub fn validate_translations_with_progress(
Err(_) => continue,
};
let Some((yaml_str, _body)) = crate::util::frontmatter::split_frontmatter(&content) else {
let Some((yaml_str, _body)) = crate::util::frontmatter::split_frontmatter(&content)
else {
continue;
};
let Ok(fm) = crate::util::frontmatter::TranslationFrontmatter::from_yaml(yaml_str) else {
let Ok(fm) = crate::util::frontmatter::TranslationFrontmatter::from_yaml(yaml_str)
else {
continue;
};