chore: source formatting and spec allignment
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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, §ion_set);
|
||||
|
||||
for page in &artifacts.pages {
|
||||
if path_matches_sections(&page.relative_path, §ion_set) {
|
||||
write_out(conn, output_dir, project_id, &page.relative_path, &page.html, &mut report)?;
|
||||
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, §ion_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('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'"),
|
||||
)
|
||||
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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
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 & Jerry <3 "quotes"");
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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:  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 = format!("bds-media://{media_id}")
|
||||
)
|
||||
format!("")
|
||||
} 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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user