chore: source formatting and spec allignment

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

View File

@@ -1,6 +1,6 @@
use crate::db::migrations;
use rusqlite::Connection;
use std::path::Path;
use crate::db::migrations;
/// Database wrapper managing a SQLite connection.
pub struct Database {
@@ -11,7 +11,9 @@ impl Database {
/// Open an existing bDS project database.
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
let conn = Connection::open(path)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;")?;
conn.execute_batch(
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;",
)?;
Ok(Self { conn })
}

View File

@@ -162,11 +162,9 @@ pub const POST_TRANSLATION_COLUMNS: &str = "\
id, project_id, translation_for, language, title, excerpt, content, \
status, file_path, checksum, created_at, updated_at, published_at";
pub const POST_LINK_COLUMNS: &str =
"id, source_post_id, target_post_id, link_text, created_at";
pub const POST_LINK_COLUMNS: &str = "id, source_post_id, target_post_id, link_text, created_at";
pub const POST_MEDIA_COLUMNS: &str =
"id, project_id, post_id, media_id, sort_order, created_at";
pub const POST_MEDIA_COLUMNS: &str = "id, project_id, post_id, media_id, sort_order, created_at";
pub const MEDIA_COLUMNS: &str = "\
id, project_id, filename, original_name, mime_type, size, \
@@ -189,8 +187,7 @@ pub const SCRIPT_COLUMNS: &str = "\
pub const SETTING_COLUMNS: &str = "key, value, updated_at";
pub const GENERATED_FILE_HASH_COLUMNS: &str =
"project_id, relative_path, content_hash, updated_at";
pub const GENERATED_FILE_HASH_COLUMNS: &str = "project_id, relative_path, content_hash, updated_at";
pub const DB_NOTIFICATION_COLUMNS: &str =
"id, entity_type, entity_id, action, from_cli, seen_at, created_at";
@@ -418,7 +415,10 @@ mod tests {
#[test]
fn parse_post_status_valid() {
assert_eq!(parse_post_status("draft").unwrap(), PostStatus::Draft);
assert_eq!(parse_post_status("published").unwrap(), PostStatus::Published);
assert_eq!(
parse_post_status("published").unwrap(),
PostStatus::Published
);
assert_eq!(parse_post_status("archived").unwrap(), PostStatus::Archived);
}
@@ -431,8 +431,14 @@ mod tests {
fn parse_template_kind_valid() {
assert_eq!(parse_template_kind("post").unwrap(), TemplateKind::Post);
assert_eq!(parse_template_kind("list").unwrap(), TemplateKind::List);
assert_eq!(parse_template_kind("not_found").unwrap(), TemplateKind::NotFound);
assert_eq!(parse_template_kind("partial").unwrap(), TemplateKind::Partial);
assert_eq!(
parse_template_kind("not_found").unwrap(),
TemplateKind::NotFound
);
assert_eq!(
parse_template_kind("partial").unwrap(),
TemplateKind::Partial
);
}
#[test]
@@ -442,36 +448,69 @@ mod tests {
#[test]
fn parse_template_status_valid() {
assert_eq!(parse_template_status("draft").unwrap(), TemplateStatus::Draft);
assert_eq!(parse_template_status("published").unwrap(), TemplateStatus::Published);
assert_eq!(
parse_template_status("draft").unwrap(),
TemplateStatus::Draft
);
assert_eq!(
parse_template_status("published").unwrap(),
TemplateStatus::Published
);
}
#[test]
fn parse_script_kind_valid() {
assert_eq!(parse_script_kind("macro").unwrap(), ScriptKind::Macro);
assert_eq!(parse_script_kind("utility").unwrap(), ScriptKind::Utility);
assert_eq!(parse_script_kind("transform").unwrap(), ScriptKind::Transform);
assert_eq!(
parse_script_kind("transform").unwrap(),
ScriptKind::Transform
);
}
#[test]
fn parse_script_status_valid() {
assert_eq!(parse_script_status("draft").unwrap(), ScriptStatus::Draft);
assert_eq!(parse_script_status("published").unwrap(), ScriptStatus::Published);
assert_eq!(
parse_script_status("published").unwrap(),
ScriptStatus::Published
);
}
#[test]
fn parse_notification_entity_valid() {
assert_eq!(parse_notification_entity("post").unwrap(), NotificationEntity::Post);
assert_eq!(parse_notification_entity("media").unwrap(), NotificationEntity::Media);
assert_eq!(parse_notification_entity("script").unwrap(), NotificationEntity::Script);
assert_eq!(parse_notification_entity("template").unwrap(), NotificationEntity::Template);
assert_eq!(
parse_notification_entity("post").unwrap(),
NotificationEntity::Post
);
assert_eq!(
parse_notification_entity("media").unwrap(),
NotificationEntity::Media
);
assert_eq!(
parse_notification_entity("script").unwrap(),
NotificationEntity::Script
);
assert_eq!(
parse_notification_entity("template").unwrap(),
NotificationEntity::Template
);
}
#[test]
fn parse_notification_action_valid() {
assert_eq!(parse_notification_action("created").unwrap(), NotificationAction::Created);
assert_eq!(parse_notification_action("updated").unwrap(), NotificationAction::Updated);
assert_eq!(parse_notification_action("deleted").unwrap(), NotificationAction::Deleted);
assert_eq!(
parse_notification_action("created").unwrap(),
NotificationAction::Created
);
assert_eq!(
parse_notification_action("updated").unwrap(),
NotificationAction::Updated
);
assert_eq!(
parse_notification_action("deleted").unwrap(),
NotificationAction::Deleted
);
}
// ── JSON helpers ─────────────────────────────────────────────────
@@ -504,11 +543,13 @@ mod tests {
VALUES ('p1', 'Blog', 'blog', 'My blog', '/data', 1, 1000, 2000)",
[],
).unwrap();
let p = c.query_row(
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = 'p1'"),
[],
project_from_row,
).unwrap();
let p = c
.query_row(
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = 'p1'"),
[],
project_from_row,
)
.unwrap();
assert_eq!(p.id, "p1");
assert_eq!(p.name, "Blog");
assert_eq!(p.slug, "blog");
@@ -527,7 +568,8 @@ mod tests {
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
[],
).unwrap();
)
.unwrap();
c.execute(
"INSERT INTO posts (id, project_id, title, slug, excerpt, content, status, author,
language, do_not_translate, template_slug, file_path, checksum,
@@ -541,11 +583,13 @@ mod tests {
1000, 2000, NULL)",
[],
).unwrap();
let p = c.query_row(
&format!("SELECT {POST_COLUMNS} FROM posts WHERE id = 'x'"),
[],
post_from_row,
).unwrap();
let p = c
.query_row(
&format!("SELECT {POST_COLUMNS} FROM posts WHERE id = 'x'"),
[],
post_from_row,
)
.unwrap();
assert_eq!(p.id, "x");
assert_eq!(p.project_id, "p1");
assert_eq!(p.title, "Hello");
@@ -575,19 +619,23 @@ mod tests {
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
[],
).unwrap();
)
.unwrap();
c.execute(
"INSERT INTO templates (id, project_id, slug, title, kind, enabled, version,
file_path, status, content, created_at, updated_at)
VALUES ('t1', 'p1', 'default', 'Default', 'not_found', 0, 3,
'templates/default.liquid', 'draft', 'html', 1000, 2000)",
[],
).unwrap();
let t = c.query_row(
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = 't1'"),
[],
template_from_row,
).unwrap();
)
.unwrap();
let t = c
.query_row(
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = 't1'"),
[],
template_from_row,
)
.unwrap();
assert_eq!(t.kind, TemplateKind::NotFound);
assert!(!t.enabled);
assert_eq!(t.version, 3);
@@ -604,11 +652,15 @@ mod tests {
VALUES ('media', 'm1', 'deleted', 1, 5000, 1000)",
[],
).unwrap();
let n = c.query_row(
&format!("SELECT {DB_NOTIFICATION_COLUMNS} FROM db_notifications WHERE entity_id = 'm1'"),
[],
db_notification_from_row,
).unwrap();
let n = c
.query_row(
&format!(
"SELECT {DB_NOTIFICATION_COLUMNS} FROM db_notifications WHERE entity_id = 'm1'"
),
[],
db_notification_from_row,
)
.unwrap();
assert_eq!(n.entity_type, NotificationEntity::Media);
assert_eq!(n.entity_id, "m1");
assert_eq!(n.action, NotificationAction::Deleted);

View File

@@ -1,5 +1,7 @@
use rust_stemmers::{Algorithm, Stemmer};
use rusqlite::Connection;
use rust_stemmers::{Algorithm, Stemmer};
use crate::util::calendar_range_unix_ms;
/// Create FTS5 virtual tables at runtime (not in migrations per spec).
///
@@ -22,7 +24,7 @@ pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
caption,
original_name,
tags
);"
);",
)?;
Ok(())
}
@@ -82,6 +84,10 @@ pub struct MediaTranslationFts {
/// Index a post in the FTS table with separate columns per spec.
///
/// Translation titles go to the title column, excerpts to excerpt, content to content.
#[expect(
clippy::too_many_arguments,
reason = "FTS columns mirror the persisted post fields"
)]
pub fn index_post(
conn: &Connection,
post_id: &str,
@@ -137,6 +143,10 @@ pub fn index_post(
/// Index a media item in the FTS table with separate columns per spec.
///
/// Translation titles go to the title column, alts to alt, captions to caption.
#[expect(
clippy::too_many_arguments,
reason = "FTS columns mirror the persisted media fields"
)]
pub fn index_media(
conn: &Connection,
media_id: &str,
@@ -209,14 +219,15 @@ pub fn remove_media_from_index(conn: &Connection, media_id: &str) -> rusqlite::R
}
/// Search posts by full-text query. Returns matching post IDs.
pub fn search_posts(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
pub fn search_posts(
conn: &Connection,
query: &str,
language: &str,
) -> rusqlite::Result<Vec<String>> {
let stemmed = stem_text(query, language);
let mut stmt = conn.prepare(
"SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank"
)?;
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
row.get::<_, String>(0)
})?;
let mut stmt =
conn.prepare("SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank")?;
let rows = stmt.query_map(rusqlite::params![stemmed], |row| row.get::<_, String>(0))?;
rows.collect()
}
@@ -255,16 +266,28 @@ pub fn search_posts_filtered(
// Get FTS matches first
let fts_ids = search_posts(conn, query, language)?;
if fts_ids.is_empty() {
return Ok(SearchResults { post_ids: vec![], total: 0, offset: filters.offset.unwrap_or(0), limit: filters.limit.unwrap_or(0) });
return Ok(SearchResults {
post_ids: vec![],
total: 0,
offset: filters.offset.unwrap_or(0),
limit: filters.limit.unwrap_or(0),
});
}
// Apply filters by querying posts table
let placeholders: Vec<String> = fts_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect();
let placeholders: Vec<String> = fts_ids
.iter()
.enumerate()
.map(|(i, _)| format!("?{}", i + 1))
.collect();
let mut sql = format!(
"SELECT id FROM posts WHERE id IN ({}) ",
placeholders.join(",")
);
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = fts_ids.iter().map(|id| Box::new(id.clone()) as Box<dyn rusqlite::types::ToSql>).collect();
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = fts_ids
.iter()
.map(|id| Box::new(id.clone()) as Box<dyn rusqlite::types::ToSql>)
.collect();
let mut param_idx = fts_ids.len() + 1;
if let Some(status) = filters.status {
@@ -296,29 +319,21 @@ pub fn search_posts_filtered(
}
if let Some(year) = filters.year {
// Filter by year from created_at (unix ms)
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
sql.push_str(&format!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1));
let (start, end) =
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
sql.push_str(&format!(
"AND created_at >= ?{param_idx} AND created_at < ?{} ",
param_idx + 1
));
params.push(Box::new(start));
params.push(Box::new(end));
param_idx += 2;
}
if let Some(month) = filters.month {
if let Some(year) = filters.year {
let (end_year, end_month) = if month == 12 { (year + 1, 1) } else { (year, month as i32 + 1) };
let start = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
let end = chrono::NaiveDate::from_ymd_opt(end_year, end_month as u32, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
sql.push_str(&format!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1));
params.push(Box::new(start));
params.push(Box::new(end));
param_idx += 2;
}
}
if let Some(lang) = filters.language {
sql.push_str(&format!("AND (language = ?{param_idx} OR language IS NULL) "));
sql.push_str(&format!(
"AND (language = ?{param_idx} OR language IS NULL) "
));
params.push(Box::new(lang.to_string()));
param_idx += 1;
}
@@ -349,7 +364,8 @@ pub fn search_posts_filtered(
let count_sql = sql.replace("SELECT id FROM posts", "SELECT COUNT(*) FROM posts");
let total: usize = {
let mut stmt = conn.prepare(&count_sql)?;
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|p| p.as_ref()).collect();
stmt.query_row(params_refs.as_slice(), |row| row.get::<_, usize>(0))?
};
@@ -365,22 +381,26 @@ pub fn search_posts_filtered(
let mut stmt = conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(params_refs.as_slice(), |row| {
row.get::<_, String>(0)
})?;
let rows = stmt.query_map(params_refs.as_slice(), |row| row.get::<_, String>(0))?;
let post_ids: Vec<String> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(SearchResults { post_ids, total, offset, limit })
Ok(SearchResults {
post_ids,
total,
offset,
limit,
})
}
/// Search media by full-text query. Returns matching media IDs.
pub fn search_media(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
pub fn search_media(
conn: &Connection,
query: &str,
language: &str,
) -> rusqlite::Result<Vec<String>> {
let stemmed = stem_text(query, language);
let mut stmt = conn.prepare(
"SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank"
)?;
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
row.get::<_, String>(0)
})?;
let mut stmt =
conn.prepare("SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank")?;
let rows = stmt.query_map(rusqlite::params![stemmed], |row| row.get::<_, String>(0))?;
rows.collect()
}
@@ -526,7 +546,8 @@ mod tests {
language: "en".into(),
}],
"en",
).unwrap();
)
.unwrap();
let results = search_posts(db.conn(), "spider", "en").unwrap();
assert_eq!(results, vec!["post-1"]);
@@ -545,7 +566,8 @@ mod tests {
&["nature".into()],
&[],
"en",
).unwrap();
)
.unwrap();
let results = search_media(db.conn(), "sunset", "en").unwrap();
assert_eq!(results, vec!["media-1"]);
@@ -561,10 +583,7 @@ mod tests {
#[test]
fn remove_from_index() {
let db = setup();
index_post(
db.conn(), "p1", "Test", None, None,
&[], &[], &[], "en",
).unwrap();
index_post(db.conn(), "p1", "Test", None, None, &[], &[], &[], "en").unwrap();
assert_eq!(search_posts(db.conn(), "test", "en").unwrap().len(), 1);
remove_post_from_index(db.conn(), "p1").unwrap();
@@ -586,8 +605,13 @@ mod tests {
let db = setup();
// German post with English translation
index_post(
db.conn(), "p1", "Programmierung", None, Some("Deutsche Entwicklung"),
&[], &[],
db.conn(),
"p1",
"Programmierung",
None,
Some("Deutsche Entwicklung"),
&[],
&[],
&[PostTranslationFts {
title: "English development programming".into(),
excerpt: None,
@@ -595,7 +619,8 @@ mod tests {
language: "en".into(),
}],
"de",
).unwrap();
)
.unwrap();
// Search with English stemming should find via English translation
let results = search_posts(db.conn(), "develop", "en").unwrap();
@@ -605,7 +630,18 @@ mod tests {
#[test]
fn search_by_title_field() {
let db = setup();
index_post(db.conn(), "p1", "Unique Title Here", None, Some("body text"), &[], &[], &[], "en").unwrap();
index_post(
db.conn(),
"p1",
"Unique Title Here",
None,
Some("body text"),
&[],
&[],
&[],
"en",
)
.unwrap();
// Search for title content
let results = search_posts(db.conn(), "unique", "en").unwrap();
@@ -615,7 +651,18 @@ mod tests {
#[test]
fn search_by_tags() {
let db = setup();
index_post(db.conn(), "p1", "Post", None, None, &["photography".into()], &[], &[], "en").unwrap();
index_post(
db.conn(),
"p1",
"Post",
None,
None,
&["photography".into()],
&[],
&[],
"en",
)
.unwrap();
let results = search_posts(db.conn(), "photography", "en").unwrap();
assert_eq!(results, vec!["p1"]);
@@ -624,7 +671,18 @@ mod tests {
#[test]
fn search_by_categories() {
let db = setup();
index_post(db.conn(), "p1", "Post", None, None, &[], &["article".into()], &[], "en").unwrap();
index_post(
db.conn(),
"p1",
"Post",
None,
None,
&[],
&["article".into()],
&[],
"en",
)
.unwrap();
let results = search_posts(db.conn(), "article", "en").unwrap();
assert_eq!(results, vec!["p1"]);
@@ -647,8 +705,30 @@ mod tests {
[],
).unwrap();
index_post(db.conn(), "post1", "Test Post", None, None, &[], &[], &[], "en").unwrap();
index_post(db.conn(), "post2", "Draft Post", None, None, &[], &[], &[], "en").unwrap();
index_post(
db.conn(),
"post1",
"Test Post",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
index_post(
db.conn(),
"post2",
"Draft Post",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
let filters = PostSearchFilters {
status: Some("published"),
@@ -670,19 +750,45 @@ mod tests {
// 2023-06-15 in unix ms
let ts_2023: i64 = 1686873600000;
db.conn().execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
db.conn()
.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('p2024', 'p1', 'Year 2024', 'y2024', 'draft', ?1, ?1)",
rusqlite::params![ts_2024],
).unwrap();
db.conn().execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
rusqlite::params![ts_2024],
)
.unwrap();
db.conn()
.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('p2023', 'p1', 'Year 2023', 'y2023', 'draft', ?1, ?1)",
rusqlite::params![ts_2023],
).unwrap();
rusqlite::params![ts_2023],
)
.unwrap();
index_post(db.conn(), "p2024", "Year 2024", None, None, &[], &[], &[], "en").unwrap();
index_post(db.conn(), "p2023", "Year 2023", None, None, &[], &[], &[], "en").unwrap();
index_post(
db.conn(),
"p2024",
"Year 2024",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
index_post(
db.conn(),
"p2023",
"Year 2023",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
let filters = PostSearchFilters {
year: Some(2024),
@@ -707,7 +813,18 @@ mod tests {
VALUES (?1, 'p1', 'Searchable', ?2, 'draft', ?3, ?3)",
rusqlite::params![id, slug, 1700000000000i64 - i as i64 * 1000],
).unwrap();
index_post(db.conn(), &id, "Searchable", None, None, &[], &[], &[], "en").unwrap();
index_post(
db.conn(),
&id,
"Searchable",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
}
let filters = PostSearchFilters {

View File

@@ -2,14 +2,16 @@ use rusqlite::Connection;
mod embedded {
use refinery::embed_migrations;
embed_migrations!("migrations");
embed_migrations!("./migrations");
}
/// Run all embedded migrations against the given connection using refinery.
///
/// Creates the full bDS schema as specified in specs/schema.allium.
/// Uses refinery for proper versioned migration tracking.
pub fn run_migrations(conn: &mut Connection) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
pub fn run_migrations(
conn: &mut Connection,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
embedded::migrations::runner().run(conn)?;
Ok(())
}
@@ -63,19 +65,34 @@ mod tests {
fn all_tables_exist() {
let conn = setup();
let expected = [
"projects", "posts", "post_translations", "media", "media_translations",
"tags", "templates", "scripts", "post_links", "post_media", "settings",
"generated_file_hashes", "chat_conversations", "chat_messages", "ai_providers",
"ai_models", "ai_model_modalities", "ai_catalog_meta", "embedding_keys",
"dismissed_duplicate_pairs", "import_definitions", "db_notifications",
"projects",
"posts",
"post_translations",
"media",
"media_translations",
"tags",
"templates",
"scripts",
"post_links",
"post_media",
"settings",
"generated_file_hashes",
"chat_conversations",
"chat_messages",
"ai_providers",
"ai_models",
"ai_model_modalities",
"ai_catalog_meta",
"embedding_keys",
"dismissed_duplicate_pairs",
"import_definitions",
"db_notifications",
];
for table in &expected {
let count: i64 = conn
.query_row(
&format!("SELECT COUNT(*) FROM {table}"),
[],
|row| row.get(0),
)
.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
row.get(0)
})
.unwrap_or_else(|e| panic!("table '{table}' should be queryable: {e}"));
assert_eq!(count, 0, "table '{table}' should start empty");
}
@@ -89,11 +106,9 @@ mod tests {
fn refinery_schema_history_exists() {
let conn = setup();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM refinery_schema_history",
[],
|row| row.get(0),
)
.query_row("SELECT COUNT(*) FROM refinery_schema_history", [], |row| {
row.get(0)
})
.unwrap();
assert!(count >= 1, "refinery should track at least one migration");
}
@@ -124,7 +139,10 @@ mod tests {
VALUES ('post2', 'p1', 'Other', 'hello', 'draft', 1000, 1000)",
[],
);
assert!(err.is_err(), "duplicate post slug within same project must be rejected");
assert!(
err.is_err(),
"duplicate post slug within same project must be rejected"
);
}
#[test]
@@ -156,7 +174,10 @@ mod tests {
VALUES ('t2', 'p1', 'post1', 'de', 'Hallo2', 'draft', 1000, 1000)",
[],
);
assert!(err.is_err(), "duplicate (translation_for, language) must be rejected");
assert!(
err.is_err(),
"duplicate (translation_for, language) must be rejected"
);
}
#[test]
@@ -174,7 +195,10 @@ mod tests {
VALUES ('mt2', 'p1', 'm1', 'de', 1000, 1000)",
[],
);
assert!(err.is_err(), "duplicate (media translation_for, language) must be rejected");
assert!(
err.is_err(),
"duplicate (media translation_for, language) must be rejected"
);
}
#[test]
@@ -185,13 +209,17 @@ mod tests {
"INSERT INTO tags (id, project_id, name, created_at, updated_at)
VALUES ('t1', 'p1', 'rust', 1000, 1000)",
[],
).unwrap();
)
.unwrap();
let err = conn.execute(
"INSERT INTO tags (id, project_id, name, created_at, updated_at)
VALUES ('t2', 'p1', 'rust', 1000, 1000)",
[],
);
assert!(err.is_err(), "duplicate tag name within same project must be rejected");
assert!(
err.is_err(),
"duplicate tag name within same project must be rejected"
);
}
#[test]
@@ -208,7 +236,10 @@ mod tests {
VALUES ('tpl2', 'p1', 'default', 'Default2', 'list', 'templates/default.liquid', 1000, 1000)",
[],
);
assert!(err.is_err(), "duplicate template slug within same project must be rejected");
assert!(
err.is_err(),
"duplicate template slug within same project must be rejected"
);
}
#[test]
@@ -225,7 +256,10 @@ mod tests {
VALUES ('s2', 'p1', 'gallery', 'Gallery2', 'utility', 'scripts/gallery.lua', 1000, 1000)",
[],
);
assert!(err.is_err(), "duplicate script slug within same project must be rejected");
assert!(
err.is_err(),
"duplicate script slug within same project must be rejected"
);
}
#[test]
@@ -238,13 +272,17 @@ mod tests {
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
VALUES ('pm1', 'p1', 'post1', 'm1', 0, 1000)",
[],
).unwrap();
)
.unwrap();
let err = conn.execute(
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
VALUES ('pm2', 'p1', 'post1', 'm1', 1, 1000)",
[],
);
assert!(err.is_err(), "duplicate (post_id, media_id) must be rejected");
assert!(
err.is_err(),
"duplicate (post_id, media_id) must be rejected"
);
}
#[test]
@@ -261,7 +299,10 @@ mod tests {
VALUES ('p1', 'index.html', 'def456', 2000)",
[],
);
assert!(err.is_err(), "duplicate (project_id, relative_path) must be rejected");
assert!(
err.is_err(),
"duplicate (project_id, relative_path) must be rejected"
);
}
#[test]
@@ -278,7 +319,10 @@ mod tests {
VALUES ('d2', 'p1', 'a', 'b', 2000)",
[],
);
assert!(err.is_err(), "duplicate (project_id, post_id_a, post_id_b) must be rejected");
assert!(
err.is_err(),
"duplicate (project_id, post_id_a, post_id_b) must be rejected"
);
}
// ================================================================
@@ -323,19 +367,25 @@ mod tests {
[],
).unwrap();
let title: String = conn.query_row(
"SELECT title FROM posts WHERE id = 'post1'", [], |r| r.get(0)
).unwrap();
let title: String = conn
.query_row("SELECT title FROM posts WHERE id = 'post1'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(title, "Hello");
let tags: String = conn.query_row(
"SELECT tags FROM posts WHERE id = 'post1'", [], |r| r.get(0)
).unwrap();
let tags: String = conn
.query_row("SELECT tags FROM posts WHERE id = 'post1'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(tags, "[\"rust\",\"blog\"]");
let content: Option<String> = conn.query_row(
"SELECT content FROM posts WHERE id = 'post1'", [], |r| r.get(0)
).unwrap();
let content: Option<String> = conn
.query_row("SELECT content FROM posts WHERE id = 'post1'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(content.as_deref(), Some("Body text"));
}
@@ -349,10 +399,15 @@ mod tests {
[],
).unwrap();
let content: Option<String> = conn.query_row(
"SELECT content FROM posts WHERE id = 'post1'", [], |r| r.get(0)
).unwrap();
assert!(content.is_none(), "published post content must be null in DB");
let content: Option<String> = conn
.query_row("SELECT content FROM posts WHERE id = 'post1'", [], |r| {
r.get(0)
})
.unwrap();
assert!(
content.is_none(),
"published post content must be null in DB"
);
}
#[test]
@@ -366,9 +421,13 @@ mod tests {
[],
).unwrap();
let (lang, title): (String, String) = conn.query_row(
"SELECT language, title FROM post_translations WHERE id = 't1'", [], |r| Ok((r.get(0)?, r.get(1)?))
).unwrap();
let (lang, title): (String, String) = conn
.query_row(
"SELECT language, title FROM post_translations WHERE id = 't1'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(lang, "de");
assert_eq!(title, "Hallo");
}
@@ -386,10 +445,13 @@ mod tests {
[],
).unwrap();
let (orig, w, h, tags): (String, Option<i32>, Option<i32>, String) = conn.query_row(
"SELECT original_name, width, height, tags FROM media WHERE id = 'm1'", [],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
).unwrap();
let (orig, w, h, tags): (String, Option<i32>, Option<i32>, String) = conn
.query_row(
"SELECT original_name, width, height, tags FROM media WHERE id = 'm1'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap();
assert_eq!(orig, "photo.jpg");
assert_eq!(w, Some(1920));
assert_eq!(h, Some(1080));
@@ -407,10 +469,13 @@ mod tests {
[],
).unwrap();
let (title, alt): (Option<String>, Option<String>) = conn.query_row(
"SELECT title, alt FROM media_translations WHERE id = 'mt1'", [],
|r| Ok((r.get(0)?, r.get(1)?))
).unwrap();
let (title, alt): (Option<String>, Option<String>) = conn
.query_row(
"SELECT title, alt FROM media_translations WHERE id = 'mt1'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(title.as_deref(), Some("Sonnenuntergang"));
assert_eq!(alt.as_deref(), Some("Ein Sonnenuntergang"));
}
@@ -425,10 +490,13 @@ mod tests {
[],
).unwrap();
let (name, color, tpl): (String, Option<String>, Option<String>) = conn.query_row(
"SELECT name, color, post_template_slug FROM tags WHERE id = 't1'", [],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))
).unwrap();
let (name, color, tpl): (String, Option<String>, Option<String>) = conn
.query_row(
"SELECT name, color, post_template_slug FROM tags WHERE id = 't1'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(name, "rust");
assert_eq!(color.as_deref(), Some("#ff5733"));
assert_eq!(tpl.as_deref(), Some("tag-tpl"));
@@ -444,14 +512,20 @@ mod tests {
[],
).unwrap();
let (kind, ver, status, content): (String, i32, String, Option<String>) = conn.query_row(
"SELECT kind, version, status, content FROM templates WHERE id = 'tpl1'", [],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
).unwrap();
let (kind, ver, status, content): (String, i32, String, Option<String>) = conn
.query_row(
"SELECT kind, version, status, content FROM templates WHERE id = 'tpl1'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap();
assert_eq!(kind, "post");
assert_eq!(ver, 3);
assert_eq!(status, "published");
assert!(content.is_none(), "published template content should be null");
assert!(
content.is_none(),
"published template content should be null"
);
}
#[test]
@@ -464,10 +538,13 @@ mod tests {
[],
).unwrap();
let (kind, ep, content): (String, String, Option<String>) = conn.query_row(
"SELECT kind, entrypoint, content FROM scripts WHERE id = 's1'", [],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))
).unwrap();
let (kind, ep, content): (String, String, Option<String>) = conn
.query_row(
"SELECT kind, entrypoint, content FROM scripts WHERE id = 's1'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(kind, "macro");
assert_eq!(ep, "render");
assert_eq!(content.as_deref(), Some("return html"));
@@ -483,12 +560,16 @@ mod tests {
"INSERT INTO post_links (id, source_post_id, target_post_id, link_text, created_at)
VALUES ('pl1', 'post1', 'post2', 'see also', 1000)",
[],
).unwrap();
)
.unwrap();
let (src, tgt, txt): (String, String, Option<String>) = conn.query_row(
"SELECT source_post_id, target_post_id, link_text FROM post_links WHERE id = 'pl1'", [],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))
).unwrap();
let (src, tgt, txt): (String, String, Option<String>) = conn
.query_row(
"SELECT source_post_id, target_post_id, link_text FROM post_links WHERE id = 'pl1'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(src, "post1");
assert_eq!(tgt, "post2");
assert_eq!(txt.as_deref(), Some("see also"));
@@ -504,11 +585,16 @@ mod tests {
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
VALUES ('pm1', 'p1', 'post1', 'm1', 5, 1000)",
[],
).unwrap();
)
.unwrap();
let order: i32 = conn.query_row(
"SELECT sort_order FROM post_media WHERE id = 'pm1'", [], |r| r.get(0)
).unwrap();
let order: i32 = conn
.query_row(
"SELECT sort_order FROM post_media WHERE id = 'pm1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(order, 5);
}
@@ -518,11 +604,14 @@ mod tests {
conn.execute(
"INSERT INTO settings (key, value, updated_at) VALUES ('theme', 'dark', 1000)",
[],
).unwrap();
)
.unwrap();
let val: String = conn.query_row(
"SELECT value FROM settings WHERE key = 'theme'", [], |r| r.get(0)
).unwrap();
let val: String = conn
.query_row("SELECT value FROM settings WHERE key = 'theme'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(val, "dark");
}
@@ -550,11 +639,16 @@ mod tests {
"INSERT INTO chat_conversations (id, title, model, created_at, updated_at)
VALUES ('c1', 'Test Chat', 'gpt-4', 1000, 1000)",
[],
).unwrap();
)
.unwrap();
let title: String = conn.query_row(
"SELECT title FROM chat_conversations WHERE id = 'c1'", [], |r| r.get(0)
).unwrap();
let title: String = conn
.query_row(
"SELECT title FROM chat_conversations WHERE id = 'c1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(title, "Test Chat");
}
@@ -565,28 +659,59 @@ mod tests {
"INSERT INTO chat_conversations (id, title, created_at, updated_at)
VALUES ('c1', 'Chat', 1000, 1000)",
[],
).unwrap();
)
.unwrap();
conn.execute(
"INSERT INTO chat_messages (conversation_id, role, content, created_at)
VALUES ('c1', 'user', 'Hello', 1000)",
[],
).unwrap();
)
.unwrap();
let (role, content): (String, Option<String>) = conn.query_row(
"SELECT role, content FROM chat_messages WHERE conversation_id = 'c1'", [],
|r| Ok((r.get(0)?, r.get(1)?))
).unwrap();
let (role, content): (String, Option<String>) = conn
.query_row(
"SELECT role, content FROM chat_messages WHERE conversation_id = 'c1'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(role, "user");
assert_eq!(content.as_deref(), Some("Hello"));
}
#[test]
fn ai_schema_uses_current_usage_and_package_columns() {
let conn = setup();
let columns = |table: &str| {
let mut statement = conn
.prepare(&format!("PRAGMA table_info({table})"))
.unwrap();
statement
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
};
let message_columns = columns("chat_messages");
assert!(message_columns.contains(&"cache_read_tokens".to_string()));
assert!(message_columns.contains(&"cache_write_tokens".to_string()));
let provider_columns = columns("ai_providers");
assert!(provider_columns.contains(&"package_ref".to_string()));
assert!(!provider_columns.contains(&"npm".to_string()));
let model_columns = columns("ai_models");
assert!(model_columns.contains(&"provider_package_ref".to_string()));
assert!(!model_columns.contains(&"provider_npm".to_string()));
}
#[test]
fn roundtrip_ai_provider_and_model() {
let conn = setup();
conn.execute(
"INSERT INTO ai_providers (id, name, updated_at) VALUES ('openai', 'OpenAI', 1000)",
[],
).unwrap();
)
.unwrap();
conn.execute(
"INSERT INTO ai_models (provider, model_id, name, context_window, max_input_tokens, max_output_tokens, updated_at)
VALUES ('openai', 'gpt-4', 'GPT-4', 128000, 128000, 4096, 1000)",
@@ -596,12 +721,16 @@ mod tests {
"INSERT INTO ai_model_modalities (provider, model_id, direction, modality)
VALUES ('openai', 'gpt-4', 'input', 'text')",
[],
).unwrap();
)
.unwrap();
let name: String = conn.query_row(
"SELECT name FROM ai_models WHERE provider = 'openai' AND model_id = 'gpt-4'",
[], |r| r.get(0)
).unwrap();
let name: String = conn
.query_row(
"SELECT name FROM ai_models WHERE provider = 'openai' AND model_id = 'gpt-4'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(name, "GPT-4");
let modality: String = conn.query_row(
@@ -617,11 +746,16 @@ mod tests {
conn.execute(
"INSERT INTO ai_catalog_meta (key, value) VALUES ('etag', 'abc')",
[],
).unwrap();
)
.unwrap();
let val: String = conn.query_row(
"SELECT value FROM ai_catalog_meta WHERE key = 'etag'", [], |r| r.get(0)
).unwrap();
let val: String = conn
.query_row(
"SELECT value FROM ai_catalog_meta WHERE key = 'etag'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(val, "abc");
}
@@ -632,11 +766,16 @@ mod tests {
"INSERT INTO embedding_keys (label, post_id, project_id, content_hash, vector)
VALUES (1, 'post1', 'p1', 'hash1', 'base64vector')",
[],
).unwrap();
)
.unwrap();
let vec: String = conn.query_row(
"SELECT vector FROM embedding_keys WHERE label = 1", [], |r| r.get(0)
).unwrap();
let vec: String = conn
.query_row(
"SELECT vector FROM embedding_keys WHERE label = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(vec, "base64vector");
}
@@ -650,10 +789,13 @@ mod tests {
[],
).unwrap();
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM dismissed_duplicate_pairs WHERE project_id = 'p1'",
[], |r| r.get(0)
).unwrap();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM dismissed_duplicate_pairs WHERE project_id = 'p1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
@@ -667,9 +809,13 @@ mod tests {
[],
).unwrap();
let name: String = conn.query_row(
"SELECT name FROM import_definitions WHERE id = 'i1'", [], |r| r.get(0)
).unwrap();
let name: String = conn
.query_row(
"SELECT name FROM import_definitions WHERE id = 'i1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(name, "WP Import");
}
@@ -680,7 +826,8 @@ mod tests {
"INSERT INTO db_notifications (entity_type, entity_id, action, from_cli, created_at)
VALUES ('post', 'post1', 'created', 1, 1000)",
[],
).unwrap();
)
.unwrap();
let (etype, action, cli): (String, String, i64) = conn.query_row(
"SELECT entity_type, action, from_cli FROM db_notifications WHERE entity_id = 'post1'",
@@ -715,7 +862,8 @@ mod tests {
"INSERT INTO posts (id, project_id, title, slug, created_at, updated_at)
VALUES ('post1', 'p1', 'Test', 'test', 1000, 1000)",
[],
).unwrap();
)
.unwrap();
let (status, file_path, tags, cats, dnt): (String, String, String, String, i64) = conn.query_row(
"SELECT status, file_path, tags, categories, do_not_translate FROM posts WHERE id = 'post1'",
@@ -736,12 +884,16 @@ mod tests {
"INSERT INTO templates (id, project_id, slug, title, file_path, created_at, updated_at)
VALUES ('tpl1', 'p1', 'test', 'Test', 'templates/test.liquid', 1000, 1000)",
[],
).unwrap();
)
.unwrap();
let (kind, enabled, version, status): (String, i64, i64, String) = conn.query_row(
"SELECT kind, enabled, version, status FROM templates WHERE id = 'tpl1'",
[], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
).unwrap();
let (kind, enabled, version, status): (String, i64, i64, String) = conn
.query_row(
"SELECT kind, enabled, version, status FROM templates WHERE id = 'tpl1'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap();
assert_eq!(kind, "post", "default kind must be 'post'");
assert_eq!(enabled, 1, "default enabled must be 1");
assert_eq!(version, 1, "default version must be 1");
@@ -756,12 +908,16 @@ mod tests {
"INSERT INTO scripts (id, project_id, slug, title, file_path, created_at, updated_at)
VALUES ('s1', 'p1', 'test', 'Test', 'scripts/test.lua', 1000, 1000)",
[],
).unwrap();
)
.unwrap();
let (kind, ep, enabled, version, status): (String, String, i64, i64, String) = conn.query_row(
"SELECT kind, entrypoint, enabled, version, status FROM scripts WHERE id = 's1'",
[], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
).unwrap();
let (kind, ep, enabled, version, status): (String, String, i64, i64, String) = conn
.query_row(
"SELECT kind, entrypoint, enabled, version, status FROM scripts WHERE id = 's1'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
)
.unwrap();
assert_eq!(kind, "utility", "default kind must be 'utility'");
assert_eq!(ep, "render", "default entrypoint must be 'render'");
assert_eq!(enabled, 1, "default enabled must be 1");

View File

@@ -1,6 +1,6 @@
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use crate::db::from_row::{generated_file_hash_from_row, GENERATED_FILE_HASH_COLUMNS};
use crate::db::from_row::{GENERATED_FILE_HASH_COLUMNS, generated_file_hash_from_row};
use crate::model::GeneratedFileHash;
pub fn get_generated_file_hash(
@@ -17,13 +17,21 @@ pub fn get_generated_file_hash(
)
}
pub fn upsert_generated_file_hash(conn: &Connection, hash: &GeneratedFileHash) -> rusqlite::Result<()> {
pub fn upsert_generated_file_hash(
conn: &Connection,
hash: &GeneratedFileHash,
) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO generated_file_hashes (project_id, relative_path, content_hash, updated_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(project_id, relative_path)
DO UPDATE SET content_hash = excluded.content_hash, updated_at = excluded.updated_at",
params![hash.project_id, hash.relative_path, hash.content_hash, hash.updated_at],
params![
hash.project_id,
hash.relative_path,
hash.content_hash,
hash.updated_at
],
)?;
Ok(())
}
@@ -54,8 +62,8 @@ pub fn list_generated_file_hashes_by_project(
#[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};
fn setup() -> Database {
let mut db = Database::open_in_memory().unwrap();
@@ -91,4 +99,4 @@ mod tests {
assert_eq!(stored.content_hash, "def");
assert_eq!(stored.updated_at, 99);
}
}
}

View File

@@ -1,7 +1,8 @@
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use crate::db::from_row::{media_from_row, MEDIA_COLUMNS};
use crate::db::from_row::{MEDIA_COLUMNS, media_from_row};
use crate::model::Media;
use crate::util::calendar_range_unix_ms;
fn tags_to_json(tags: &[String]) -> String {
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
@@ -133,9 +134,7 @@ pub struct MediaFilterParams {
impl MediaFilterParams {
pub fn has_active_filters(&self) -> bool {
!self.search_query.is_empty()
|| self.year.is_some()
|| !self.tags.is_empty()
!self.search_query.is_empty() || self.year.is_some() || !self.tags.is_empty()
}
}
@@ -161,49 +160,13 @@ pub fn list_media_filtered(
}
if let Some(year) = filters.year {
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() * 1000;
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() * 1000;
if let Some(month) = filters.month {
let m_start = chrono::NaiveDate::from_ymd_opt(year, month, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() * 1000;
let next_month = if month == 12 {
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
} else {
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1)
}
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() * 1000;
let idx1 = param_values.len() + 1;
let idx2 = param_values.len() + 2;
conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})"));
param_values.push(Box::new(m_start));
param_values.push(Box::new(next_month));
} else {
let idx1 = param_values.len() + 1;
let idx2 = param_values.len() + 2;
conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})"));
param_values.push(Box::new(start));
param_values.push(Box::new(end));
}
let (start, end) =
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
let idx1 = param_values.len() + 1;
let idx2 = param_values.len() + 2;
conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})"));
param_values.push(Box::new(start));
param_values.push(Box::new(end));
}
for tag in &filters.tags {
@@ -244,7 +207,7 @@ pub fn media_calendar_counts(
FROM media
WHERE project_id = ?1
GROUP BY y, m
ORDER BY y DESC, m DESC"
ORDER BY y DESC, m DESC",
)?;
let rows = stmt.query_map(params![project_id], |row| {
Ok((
@@ -257,22 +220,16 @@ pub fn media_calendar_counts(
}
/// Collect all distinct tag values across media for a project.
pub fn distinct_media_tags(
conn: &Connection,
project_id: &str,
) -> rusqlite::Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT DISTINCT tags FROM media WHERE project_id = ?1 AND tags != '[]'"
)?;
let rows = stmt.query_map(params![project_id], |row| {
row.get::<_, String>(0)
})?;
pub fn distinct_media_tags(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<String>> {
let mut stmt =
conn.prepare("SELECT DISTINCT tags FROM media WHERE project_id = ?1 AND tags != '[]'")?;
let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
let mut all_tags = std::collections::BTreeSet::new();
for json_str in rows {
if let Ok(json_str) = json_str {
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
all_tags.extend(tags);
}
if let Ok(json_str) = json_str
&& let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str)
{
all_tags.extend(tags);
}
}
Ok(all_tags.into_iter().collect())
@@ -307,8 +264,8 @@ pub fn make_test_media(id: &str, project_id: &str) -> Media {
#[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};
fn setup() -> Database {
let mut db = Database::open_in_memory().unwrap();

View File

@@ -1,12 +1,9 @@
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use crate::db::from_row::{media_translation_from_row, MEDIA_TRANSLATION_COLUMNS};
use crate::db::from_row::{MEDIA_TRANSLATION_COLUMNS, media_translation_from_row};
use crate::model::MediaTranslation;
pub fn insert_media_translation(
conn: &Connection,
t: &MediaTranslation,
) -> rusqlite::Result<()> {
pub fn insert_media_translation(conn: &Connection, t: &MediaTranslation) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO media_translations (
id, project_id, translation_for, language, title, alt, caption,
@@ -54,10 +51,7 @@ pub fn list_media_translations_by_media(
rows.collect()
}
pub fn upsert_media_translation(
conn: &Connection,
t: &MediaTranslation,
) -> rusqlite::Result<()> {
pub fn upsert_media_translation(conn: &Connection, t: &MediaTranslation) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO media_translations (
id, project_id, translation_for, language, title, alt, caption,
@@ -98,9 +92,9 @@ pub fn delete_media_translation(
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::db::queries::media::{insert_media, make_test_media};
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
fn setup() -> Database {
let mut db = Database::open_in_memory().unwrap();
@@ -164,8 +158,6 @@ mod tests {
let db = setup();
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
delete_media_translation(db.conn(), "m1", "de").unwrap();
assert!(
get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err()
);
assert!(get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err());
}
}

View File

@@ -1,12 +1,12 @@
pub mod project;
pub mod post;
pub mod post_translation;
pub mod generated_file_hash;
pub mod media;
pub mod media_translation;
pub mod tag;
pub mod post;
pub mod post_link;
pub mod post_media;
pub mod template;
pub mod post_translation;
pub mod project;
pub mod script;
pub mod setting;
pub mod generated_file_hash;
pub mod tag;
pub mod template;

View File

@@ -1,7 +1,8 @@
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use crate::db::from_row::{post_from_row, post_status_to_str, POST_COLUMNS};
use crate::db::from_row::{POST_COLUMNS, post_from_row, post_status_to_str};
use crate::model::{Post, PostStatus};
use crate::util::calendar_range_unix_ms;
fn tags_to_json(tags: &[String]) -> String {
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
@@ -165,6 +166,10 @@ pub fn set_post_file_path(
Ok(())
}
#[expect(
clippy::too_many_arguments,
reason = "arguments mirror the published snapshot columns"
)]
pub fn set_published_snapshot(
conn: &Connection,
id: &str,
@@ -182,7 +187,16 @@ pub fn set_published_snapshot(
published_categories = ?4, published_excerpt = ?5,
published_at = ?6, updated_at = ?7
WHERE id = ?8",
params![title, content, tags, categories, excerpt, published_at, updated_at, id],
params![
title,
content,
tags,
categories,
excerpt,
published_at,
updated_at,
id
],
)?;
Ok(())
}
@@ -295,50 +309,15 @@ pub fn list_posts_filtered(
}
if let Some(year) = filters.year {
// created_at is unix ms; compute year range
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() * 1000;
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() * 1000;
if let Some(month) = filters.month {
let m_start = chrono::NaiveDate::from_ymd_opt(year, month, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() * 1000;
let next_month = if month == 12 {
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
} else {
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1)
}
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp() * 1000;
let idx1 = param_values.len() + 1;
let idx2 = param_values.len() + 2;
filter_conditions.push(format!("(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"));
param_values.push(Box::new(m_start));
param_values.push(Box::new(next_month));
} else {
let idx1 = param_values.len() + 1;
let idx2 = param_values.len() + 2;
filter_conditions.push(format!("(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"));
param_values.push(Box::new(start));
param_values.push(Box::new(end));
}
let (start, end) =
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
let idx1 = param_values.len() + 1;
let idx2 = param_values.len() + 2;
filter_conditions.push(format!(
"(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"
));
param_values.push(Box::new(start));
param_values.push(Box::new(end));
}
for tag in &filters.tags {
@@ -435,22 +414,16 @@ pub fn post_calendar_counts(
}
/// Collect all distinct tag values across posts for a project.
pub fn distinct_post_tags(
conn: &Connection,
project_id: &str,
) -> rusqlite::Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT DISTINCT tags FROM posts WHERE project_id = ?1 AND tags != '[]'"
)?;
let rows = stmt.query_map(params![project_id], |row| {
row.get::<_, String>(0)
})?;
pub fn distinct_post_tags(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<String>> {
let mut stmt =
conn.prepare("SELECT DISTINCT tags FROM posts WHERE project_id = ?1 AND tags != '[]'")?;
let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
let mut all_tags = std::collections::BTreeSet::new();
for json_str in rows {
if let Ok(json_str) = json_str {
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
all_tags.extend(tags);
}
if let Ok(json_str) = json_str
&& let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str)
{
all_tags.extend(tags);
}
}
Ok(all_tags.into_iter().collect())
@@ -462,17 +435,15 @@ pub fn distinct_post_categories(
project_id: &str,
) -> rusqlite::Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT DISTINCT categories FROM posts WHERE project_id = ?1 AND categories != '[]'"
"SELECT DISTINCT categories FROM posts WHERE project_id = ?1 AND categories != '[]'",
)?;
let rows = stmt.query_map(params![project_id], |row| {
row.get::<_, String>(0)
})?;
let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
let mut all_cats = std::collections::BTreeSet::new();
for json_str in rows {
if let Ok(json_str) = json_str {
if let Ok(cats) = serde_json::from_str::<Vec<String>>(&json_str) {
all_cats.extend(cats);
}
if let Ok(json_str) = json_str
&& let Ok(cats) = serde_json::from_str::<Vec<String>>(&json_str)
{
all_cats.extend(cats);
}
}
Ok(all_cats.into_iter().collect())
@@ -481,8 +452,8 @@ pub fn distinct_post_categories(
#[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};
fn setup() -> Database {
let mut db = Database::open_in_memory().unwrap();
@@ -603,9 +574,17 @@ mod tests {
let db = setup();
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
set_published_snapshot(
db.conn(), "x1", "Pub Title", "Pub Body",
"[\"rust\"]", "[\"tech\"]", Some("Pub Excerpt"), 3000, 3000,
).unwrap();
db.conn(),
"x1",
"Pub Title",
"Pub Body",
"[\"rust\"]",
"[\"tech\"]",
Some("Pub Excerpt"),
3000,
3000,
)
.unwrap();
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
assert_eq!(fetched.published_title.as_deref(), Some("Pub Title"));
assert_eq!(fetched.published_content.as_deref(), Some("Pub Body"));

View File

@@ -1,6 +1,6 @@
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use crate::db::from_row::{post_link_from_row, POST_LINK_COLUMNS};
use crate::db::from_row::{POST_LINK_COLUMNS, post_link_from_row};
use crate::model::PostLink;
pub fn insert_post_link(conn: &Connection, link: &PostLink) -> rusqlite::Result<()> {
@@ -51,8 +51,8 @@ pub fn list_links_by_target(
#[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};
fn setup() -> Database {
let mut db = Database::open_in_memory().unwrap();
@@ -63,17 +63,20 @@ mod tests {
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('a', 'p1', 'A', 'a', 'draft', 1000, 1000)",
[],
).unwrap();
)
.unwrap();
c.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('b', 'p1', 'B', 'b', 'draft', 1000, 1000)",
[],
).unwrap();
)
.unwrap();
c.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('c', 'p1', 'C', 'c', 'draft', 1000, 1000)",
[],
).unwrap();
)
.unwrap();
db
}

View File

@@ -1,6 +1,6 @@
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use crate::db::from_row::{post_media_from_row, POST_MEDIA_COLUMNS};
use crate::db::from_row::{POST_MEDIA_COLUMNS, post_media_from_row};
use crate::model::PostMedia;
pub fn link_media(conn: &Connection, pm: &PostMedia) -> rusqlite::Result<()> {
@@ -65,9 +65,9 @@ pub fn update_sort_order(
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::db::queries::media::{insert_media, make_test_media};
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
fn setup() -> Database {
let mut db = Database::open_in_memory().unwrap();

View File

@@ -1,12 +1,11 @@
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use crate::db::from_row::{post_status_to_str, post_translation_from_row, POST_TRANSLATION_COLUMNS};
use crate::db::from_row::{
POST_TRANSLATION_COLUMNS, post_status_to_str, post_translation_from_row,
};
use crate::model::PostTranslation;
pub fn insert_post_translation(
conn: &Connection,
t: &PostTranslation,
) -> rusqlite::Result<()> {
pub fn insert_post_translation(conn: &Connection, t: &PostTranslation) -> rusqlite::Result<()> {
if !t.status.is_valid_for_translation() {
return Err(rusqlite::Error::InvalidParameterName(
"translation status must be draft or published".to_string(),
@@ -74,10 +73,7 @@ pub fn list_post_translations_by_post(
rows.collect()
}
pub fn update_post_translation(
conn: &Connection,
t: &PostTranslation,
) -> rusqlite::Result<()> {
pub fn update_post_translation(conn: &Connection, t: &PostTranslation) -> rusqlite::Result<()> {
if !t.status.is_valid_for_translation() {
return Err(rusqlite::Error::InvalidParameterName(
"translation status must be draft or published".to_string(),
@@ -104,10 +100,7 @@ pub fn update_post_translation(
}
pub fn delete_post_translation(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM post_translations WHERE id = ?1",
params![id],
)?;
conn.execute("DELETE FROM post_translations WHERE id = ?1", params![id])?;
Ok(())
}
@@ -125,8 +118,8 @@ pub fn delete_all_translations_for_post(
#[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 crate::model::PostStatus;
fn setup() -> Database {

View File

@@ -1,6 +1,6 @@
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use crate::db::from_row::{project_from_row, PROJECT_COLUMNS};
use crate::db::from_row::{PROJECT_COLUMNS, project_from_row};
use crate::model::Project;
pub fn insert_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {

View File

@@ -1,7 +1,7 @@
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use crate::db::from_row::{
script_from_row, script_kind_to_str, script_status_to_str, SCRIPT_COLUMNS,
SCRIPT_COLUMNS, script_from_row, script_kind_to_str, script_status_to_str,
};
use crate::model::Script;
@@ -44,9 +44,7 @@ pub fn get_script_by_slug(
slug: &str,
) -> rusqlite::Result<Script> {
conn.query_row(
&format!(
"SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 AND slug = ?2"
),
&format!("SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 AND slug = ?2"),
params![project_id, slug],
script_from_row,
)
@@ -94,8 +92,8 @@ pub fn delete_script(conn: &Connection, id: &str) -> rusqlite::Result<()> {
#[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 crate::model::{ScriptKind, ScriptStatus};
fn setup() -> Database {

View File

@@ -1,6 +1,6 @@
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use crate::db::from_row::{setting_from_row, SETTING_COLUMNS};
use crate::db::from_row::{SETTING_COLUMNS, setting_from_row};
use crate::model::Setting;
pub fn get_setting_by_key(conn: &Connection, key: &str) -> rusqlite::Result<Setting> {

View File

@@ -1,6 +1,6 @@
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use crate::db::from_row::{tag_from_row, TAG_COLUMNS};
use crate::db::from_row::{TAG_COLUMNS, tag_from_row};
use crate::model::Tag;
pub fn insert_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
@@ -73,8 +73,8 @@ pub fn delete_tag(conn: &Connection, id: &str) -> rusqlite::Result<()> {
#[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};
fn setup() -> Database {
let mut db = Database::open_in_memory().unwrap();

View File

@@ -1,7 +1,7 @@
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use crate::db::from_row::{
template_from_row, template_kind_to_str, template_status_to_str, TEMPLATE_COLUMNS,
TEMPLATE_COLUMNS, template_from_row, template_kind_to_str, template_status_to_str,
};
use crate::model::Template;
@@ -43,9 +43,7 @@ pub fn get_template_by_slug(
slug: &str,
) -> rusqlite::Result<Template> {
conn.query_row(
&format!(
"SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 AND slug = ?2"
),
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 AND slug = ?2"),
params![project_id, slug],
template_from_row,
)
@@ -92,8 +90,8 @@ pub fn delete_template(conn: &Connection, id: &str) -> rusqlite::Result<()> {
#[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 crate::model::{TemplateKind, TemplateStatus};
fn setup() -> Database {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
/// Validation functions for template (Liquid) and script (Lua/Python) content.
/// Per template.allium and script.allium, these are pre-publish gates.
///
/// Current implementation: basic structural checks.
/// When a full Liquid parser crate is added, upgrade `validate_liquid` to
/// attempt a real parse. Similarly for `validate_script` with a Lua parser.
//! Validation functions for template (Liquid) and script (Lua/Python) content.
//! Per template.allium and script.allium, these are pre-publish gates.
//!
//! Current implementation: basic structural checks.
//! When a full Liquid parser crate is added, upgrade `validate_liquid` to
//! attempt a real parse. Similarly for `validate_script` with a Lua parser.
/// Result of a validation check.
#[derive(Debug, Clone)]
@@ -35,8 +35,10 @@ pub fn validate_liquid(content: &str) -> ValidationResult {
let mut errors = Vec::new();
// Check for unmatched Liquid block tags
let block_tags = ["if", "unless", "for", "case", "capture", "comment",
"raw", "paginate", "tablerow", "block", "schema"];
let block_tags = [
"if", "unless", "for", "case", "capture", "comment", "raw", "paginate", "tablerow",
"block", "schema",
];
for tag in &block_tags {
let open_pattern = format!("{{% {tag}");

View File

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

View File

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

View File

@@ -133,10 +133,10 @@ pub fn translate(locale: UiLocale, key: &str) -> String {
if let Some(val) = catalog_for(locale).get(key) {
return val.clone();
}
if locale != UiLocale::En {
if let Some(val) = CATALOG_EN.get(key) {
return val.clone();
}
if locale != UiLocale::En
&& let Some(val) = CATALOG_EN.get(key)
{
return val.clone();
}
key.to_string()
}
@@ -163,10 +163,10 @@ pub fn translate_render(language: &str, key: &str) -> String {
if let Some(val) = catalog.get(key) {
return val.clone();
}
if !language.starts_with("en") {
if let Some(val) = RENDER_EN.get(key) {
return val.clone();
}
if !language.starts_with("en")
&& let Some(val) = RENDER_EN.get(key)
{
return val.clone();
}
key.to_string()
}
@@ -302,7 +302,10 @@ mod tests {
#[test]
fn translate_render_missing_key_returns_key() {
assert_eq!(translate_render("en", "render.nonexistent"), "render.nonexistent");
assert_eq!(
translate_render("en", "render.nonexistent"),
"render.nonexistent"
);
}
#[test]

View File

@@ -1,7 +1,7 @@
pub mod db;
pub mod engine;
pub mod i18n;
pub mod model;
pub mod render;
pub mod i18n;
pub mod scripting;
pub mod util;

View File

@@ -154,10 +154,15 @@ mod tests {
fn max_posts_per_page_validation() {
let mut meta = ProjectMetadata {
name: "Test".into(),
description: None, public_url: None, main_language: None,
default_author: None, max_posts_per_page: 50, blogmark_category: None,
description: None,
public_url: None,
main_language: None,
default_author: None,
max_posts_per_page: 50,
blogmark_category: None,
pico_theme: None,
semantic_similarity_enabled: false, blog_languages: vec![],
semantic_similarity_enabled: false,
blog_languages: vec![],
};
assert!(meta.validate().is_ok());

View File

@@ -1,20 +1,20 @@
mod post;
mod media;
mod tag;
mod project;
mod template;
mod script;
mod generation;
mod media;
pub mod metadata;
mod post;
mod project;
mod script;
mod tag;
mod template;
pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation};
pub use media::{Media, MediaTranslation};
pub use tag::Tag;
pub use project::{Project, Setting};
pub use template::{Template, TemplateKind, TemplateStatus};
pub use script::{Script, ScriptKind, ScriptStatus};
pub use generation::{
DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity,
PublishingPreferences, SshMode,
};
pub use media::{Media, MediaTranslation};
pub use metadata::{CategorySettings, ProjectMetadata, TagEntry};
pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation};
pub use project::{Project, Setting};
pub use script::{Script, ScriptKind, ScriptStatus};
pub use tag::Tag;
pub use template::{Template, TemplateKind, TemplateStatus};

View File

@@ -32,13 +32,12 @@ pub fn write_generated_file(
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content.as_bytes());
let target_path = output_dir.join(relative_path);
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) {
if existing.content_hash == hash
&& target_path.exists()
&& file_hash(&target_path)? == hash
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
&& existing.content_hash == hash
&& target_path.exists()
&& file_hash(&target_path)? == hash
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
if let Some(parent) = target_path.parent() {
@@ -68,13 +67,12 @@ pub fn write_generated_bytes(
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content);
let target_path = output_dir.join(relative_path);
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) {
if existing.content_hash == hash
&& target_path.exists()
&& file_hash(&target_path)? == hash
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
&& existing.content_hash == hash
&& target_path.exists()
&& file_hash(&target_path)? == hash
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
if let Some(parent) = target_path.parent() {
@@ -135,9 +133,13 @@ pub fn build_calendar_archive_data(posts: &[Post]) -> CalendarArchiveData {
*days.entry(day).or_insert(0) += 1;
}
CalendarArchiveData { years, months, days }
CalendarArchiveData {
years,
months,
days,
}
}
pub fn build_calendar_json(posts: &[Post]) -> serde_json::Result<String> {
serde_json::to_string_pretty(&build_calendar_archive_data(posts))
}
}

View File

@@ -52,17 +52,10 @@ fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option<String
"vimeo" => Some(render_vimeo(&args)),
"photo_archive" => Some(render_photo_archive(&args, context)),
"tag_cloud" => Some(render_tag_cloud(&args, context)),
_ => Some(unsupported_macro(name)),
_ => None,
}
}
fn unsupported_macro(name: &str) -> String {
format!(
"<section class=\"macro-unsupported\"><p>Unsupported macro: <code>{}</code></p></section>",
escape_html_attr(name),
)
}
fn tokenize_invocation(invocation: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
@@ -100,12 +93,11 @@ fn tokenize_invocation(invocation: &str) -> Vec<String> {
fn resolve_token(raw: &str, context: &MacroRenderContext) -> JsonValue {
let trimmed = raw.trim();
if trimmed.len() >= 2 {
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
return JsonValue::String(trimmed[1..trimmed.len() - 1].to_string());
}
if trimmed.len() >= 2
&& ((trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
{
return JsonValue::String(trimmed[1..trimmed.len() - 1].to_string());
}
match trimmed {
@@ -197,7 +189,11 @@ fn render_gallery(args: &HashMap<String, JsonValue>, context: &MacroRenderContex
fn render_youtube(args: &HashMap<String, JsonValue>) -> String {
let video_id = args.get("id").map(stringify_scalar).unwrap_or_default();
if video_id.is_empty() {
return empty_block("macro-youtube", "gallery-empty", "Missing YouTube video id.");
return empty_block(
"macro-youtube",
"gallery-empty",
"Missing YouTube video id.",
);
}
format!(
"<section class=\"macro-youtube\"><iframe src=\"https://www.youtube.com/embed/{}\" title=\"YouTube video\" loading=\"lazy\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe></section>",
@@ -224,10 +220,18 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
.or_else(|| linked_media(context));
let Some(media) = media else {
return empty_block("macro-photo-archive", "photo-archive-empty", "No photos available.");
return empty_block(
"macro-photo-archive",
"photo-archive-empty",
"No photos available.",
);
};
if media.is_empty() {
return empty_block("macro-photo-archive", "photo-archive-empty", "No photos available.");
return empty_block(
"macro-photo-archive",
"photo-archive-empty",
"No photos available.",
);
}
let mut grouped = BTreeMap::<String, Vec<JsonValue>>::new();
@@ -243,7 +247,11 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
let single_month = grouped.len() == 1;
let mut html = format!(
"<section class=\"macro-photo-archive{}\"><div class=\"photo-archive-container\">",
if single_month { " photo-archive-single-month" } else { "" }
if single_month {
" photo-archive-single-month"
} else {
""
}
);
for (bucket, items) in grouped.into_iter().rev() {
html.push_str("<section class=\"photo-archive-month\">");
@@ -362,7 +370,10 @@ fn tag_items(context: &MacroRenderContext) -> Option<Vec<JsonValue>> {
}
fn image_path(image: &JsonValue) -> Option<String> {
image.get("file_path").and_then(JsonValue::as_str).map(ToOwned::to_owned)
image
.get("file_path")
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned)
}
fn image_title(image: &JsonValue) -> Option<String> {
@@ -391,7 +402,9 @@ fn image_alt(image: &JsonValue, fallback: Option<&str>) -> String {
}
fn value_as_u64(value: &JsonValue) -> Option<u64> {
value.as_u64().or_else(|| value.as_i64().map(|number| number.max(0) as u64))
value
.as_u64()
.or_else(|| value.as_i64().map(|number| number.max(0) as u64))
}
fn stringify_scalar(value: &JsonValue) -> String {
@@ -407,7 +420,9 @@ fn stringify_scalar(value: &JsonValue) -> String {
fn month_bucket(path: &str) -> Option<String> {
let segments = path.trim_matches('/').split('/').collect::<Vec<_>>();
match segments.as_slice() {
["media", year, month, ..] if year.len() == 4 && month.len() == 2 => Some(format!("{year}-{month}")),
["media", year, month, ..] if year.len() == 4 && month.len() == 2 => {
Some(format!("{year}-{month}"))
}
_ => None,
}
}
@@ -422,11 +437,16 @@ fn empty_block(wrapper_class: &str, message_class: &str, message: &str) -> Strin
}
fn escape_html(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn escape_html_attr(value: &str) -> String {
escape_html(value).replace('"', "&quot;").replace('\'', "&#39;")
escape_html(value)
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
#[cfg(test)]
@@ -470,4 +490,13 @@ mod tests {
assert!(html.contains("macro-tag-cloud"));
assert!(html.contains("data-tag-cloud=\"true\""));
}
}
#[test]
fn leaves_unknown_macros_verbatim() {
let markdown = "Before [[future_macro value='x']] after";
assert_eq!(
expand_builtin_macros(markdown, &MacroRenderContext::default()),
markdown
);
}
}

View File

@@ -22,4 +22,4 @@ mod tests {
assert!(rendered.contains("<h1>Title</h1>"));
assert!(rendered.contains("<p>Some <em>text</em>.</p>"));
}
}
}

View File

@@ -1,27 +1,26 @@
mod markdown;
mod macros;
mod generation;
mod macros;
mod markdown;
mod page_renderer;
mod routes;
mod site;
mod template_lookup;
pub use generation::{
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json,
build_core_generation_paths, write_generated_bytes, write_generated_file,
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
write_generated_bytes, write_generated_file,
};
pub use markdown::render_markdown_to_html;
pub use page_renderer::{RenderError, render_liquid_template};
pub use routes::{
RenderedPage, build_canonical_post_path, render_starter_list_page,
render_starter_list_page_with_media_map, render_starter_single_post_page,
render_starter_single_post_page_with_media_map,
RenderedPage, build_canonical_post_path, render_starter_list_page,
render_starter_list_page_with_media_map, render_starter_single_post_page,
render_starter_single_post_page_with_media_map,
};
pub use site::{
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts,
build_preview_response, build_site_render_artifacts,
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, build_preview_response,
build_site_render_artifacts,
};
pub use template_lookup::{
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
resolve_post_template,
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, resolve_post_template,
};

View File

@@ -2,11 +2,11 @@ use std::collections::HashMap;
use liquid::ParserBuilder;
use liquid::partials::{EagerCompiler, InMemorySource};
use liquid_core::{
Display_filter, Expression, Filter, FilterParameters, FilterReflection,
FromFilterParameters, ParseFilter, Runtime, Value, ValueView,
};
use liquid_core::model::ScalarCow;
use liquid_core::{
Display_filter, Expression, Filter, FilterParameters, FilterReflection, FromFilterParameters,
ParseFilter, Runtime, Value, ValueView,
};
use serde::Serialize;
use serde_json::{Map as JsonMap, Value as JsonValue};
use thiserror::Error;
@@ -14,6 +14,7 @@ use thiserror::Error;
use crate::i18n::translate_render;
use crate::render::macros::{MacroRenderContext, expand_builtin_macros};
use crate::render::render_markdown_to_html;
use crate::util::slugify;
#[derive(Debug, Error)]
pub enum RenderError {
@@ -40,6 +41,7 @@ pub fn render_liquid_template<T: Serialize>(
let parser = ParserBuilder::with_stdlib()
.filter(I18n)
.filter(Markdown)
.filter(Slugify)
.partials(compiled_partials)
.build()?;
let template = parser.parse(template_source)?;
@@ -47,6 +49,28 @@ pub fn render_liquid_template<T: Serialize>(
Ok(template.render(&globals)?)
}
#[derive(Clone, ParseFilter, FilterReflection)]
#[filter(
name = "slugify",
description = "Convert text to the canonical post slug format.",
parsed(SlugifyFilter)
)]
struct Slugify;
#[derive(Debug, Default, Display_filter)]
#[name = "slugify"]
struct SlugifyFilter;
impl Filter for SlugifyFilter {
fn evaluate(
&self,
input: &dyn ValueView,
_runtime: &dyn Runtime,
) -> liquid_core::Result<Value> {
Ok(Value::scalar(slugify(input.to_kstr().as_str())))
}
}
#[derive(Debug, FilterParameters)]
struct I18nArgs {
#[parameter(description = "Render language", arg_type = "str")]
@@ -74,7 +98,10 @@ impl Filter for I18nFilter {
let args = self.args.evaluate(runtime)?;
let key = input.to_kstr();
let language = args.language.to_kstr();
Ok(Value::scalar(translate_render(language.as_str(), key.as_str())))
Ok(Value::scalar(translate_render(
language.as_str(),
key.as_str(),
)))
}
}
@@ -135,7 +162,10 @@ impl Filter for MarkdownFilter {
let expanded = expand_builtin_macros(markdown.as_str(), &macro_context);
let rendered = render_markdown_to_html(&expanded);
Ok(Value::scalar(rewrite_rendered_html_urls(&rendered, &rewrite_context)))
Ok(Value::scalar(rewrite_rendered_html_urls(
&rendered,
&rewrite_context,
)))
}
}
@@ -148,10 +178,10 @@ fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
}
}
if !roots.contains_key("Tags") {
if let Some(tags) = roots.get("post_tags").cloned() {
roots.insert("Tags".to_string(), tags);
}
if !roots.contains_key("Tags")
&& let Some(tags) = roots.get("post_tags").cloned()
{
roots.insert("Tags".to_string(), tags);
}
roots
@@ -202,9 +232,16 @@ fn value_to_string_map(value: &impl ValueView) -> HashMap<String, String> {
.unwrap_or_default()
}
pub(crate) fn rewrite_rendered_html_urls(html: &str, rewrite_context: &impl RewriteContextView) -> String {
let rewritten = rewrite_attribute_urls(html, "href", |href| normalize_preview_href(href, rewrite_context));
rewrite_attribute_urls(&rewritten, "src", |src| normalize_preview_src(src, rewrite_context))
pub(crate) fn rewrite_rendered_html_urls(
html: &str,
rewrite_context: &impl RewriteContextView,
) -> String {
let rewritten = rewrite_attribute_urls(html, "href", |href| {
normalize_preview_href(href, rewrite_context)
});
rewrite_attribute_urls(&rewritten, "src", |src| {
normalize_preview_src(src, rewrite_context)
})
}
pub(crate) trait RewriteContextView {
@@ -387,7 +424,9 @@ fn extract_post_slug(path: &str) -> Option<String> {
let segments: Vec<_> = trimmed.split('/').collect();
match segments.as_slice() {
["post" | "posts", slug] => Some(trim_html_suffix(slug)),
["post" | "posts", year, month, slug] if year.len() == 4 && month.chars().all(|ch| ch.is_ascii_digit()) => {
["post" | "posts", year, month, slug]
if year.len() == 4 && month.chars().all(|ch| ch.is_ascii_digit()) =>
{
Some(trim_html_suffix(slug))
}
_ => None,
@@ -422,7 +461,7 @@ fn trim_html_suffix(value: &str) -> String {
#[cfg(test)]
mod tests {
use super::{rewrite_rendered_html_urls, RewriteContextView};
use super::{RewriteContextView, render_liquid_template, rewrite_rendered_html_urls};
use std::collections::HashMap;
struct TestRewriteContext {
@@ -457,4 +496,16 @@ mod tests {
assert!(html.contains("src=\"/media/2026/04/media-1.png\""));
}
}
#[test]
fn exposes_slugify_liquid_filter() {
let rendered = render_liquid_template(
"{{ title | slugify }}",
&HashMap::new(),
&serde_json::json!({"title": "Über die Brücke"}),
)
.unwrap();
assert_eq!(rendered, "ueber-die-bruecke");
}
}

View File

@@ -7,11 +7,16 @@ use crate::i18n::normalize_language;
use crate::model::{Post, ProjectMetadata};
use crate::render::{RenderError, render_liquid_template};
const STARTER_SINGLE_POST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/single-post.liquid");
const STARTER_POST_LIST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/post-list.liquid");
const STARTER_HEAD_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/head.liquid");
const STARTER_MENU_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/menu.liquid");
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
const STARTER_SINGLE_POST_TEMPLATE: &str =
include_str!("../../../../assets/starter-templates/single-post.liquid");
const STARTER_POST_LIST_TEMPLATE: &str =
include_str!("../../../../assets/starter-templates/post-list.liquid");
const STARTER_HEAD_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/head.liquid");
const STARTER_MENU_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/menu.liquid");
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedPage {
@@ -136,7 +141,10 @@ pub fn render_starter_single_post_page_with_media_map(
language: &str,
canonical_media_path_by_source_path: HashMap<String, String>,
) -> Result<RenderedPage, RenderError> {
let relative_path = format!("{}/index.html", build_canonical_post_path(post, language, main_language(metadata)).trim_start_matches('/'));
let relative_path = format!(
"{}/index.html",
build_canonical_post_path(post, language, main_language(metadata)).trim_start_matches('/')
);
let canonical_path = build_canonical_post_path(post, language, main_language(metadata));
let (calendar_initial_year, calendar_initial_month) = calendar_initial_parts(post);
let context = PostTemplateContext {
@@ -171,13 +179,12 @@ pub fn render_starter_single_post_page_with_media_map(
post_data_json_by_id: HashMap::new(),
};
let html = render_liquid_template(
STARTER_SINGLE_POST_TEMPLATE,
&starter_partials(),
&context,
)?;
let html = render_liquid_template(STARTER_SINGLE_POST_TEMPLATE, &starter_partials(), &context)?;
Ok(RenderedPage { relative_path, html })
Ok(RenderedPage {
relative_path,
html,
})
}
pub fn render_starter_list_page(
@@ -246,26 +253,33 @@ pub fn render_starter_list_page_with_media_map(
post_data_json_by_id: HashMap::new(),
};
let html = render_liquid_template(
&starter_post_list_template(),
&starter_partials(),
&context,
)?;
let html =
render_liquid_template(&starter_post_list_template(), &starter_partials(), &context)?;
Ok(RenderedPage { relative_path, html })
Ok(RenderedPage {
relative_path,
html,
})
}
fn starter_partials() -> HashMap<String, String> {
HashMap::from([
("partials/head".to_string(), STARTER_HEAD_PARTIAL.to_string()),
("partials/menu".to_string(), STARTER_MENU_PARTIAL.to_string()),
(
"partials/head".to_string(),
STARTER_HEAD_PARTIAL.to_string(),
),
(
"partials/menu".to_string(),
STARTER_MENU_PARTIAL.to_string(),
),
(
"partials/language-switcher".to_string(),
STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string(),
),
(
"partials/menu-items".to_string(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}".to_string(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
.to_string(),
),
])
}
@@ -305,7 +319,11 @@ fn calendar_initial_parts(post: &Post) -> (i32, u32) {
.unwrap_or((1970, 1))
}
fn build_alternate_links(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<AlternateLinkContext> {
fn build_alternate_links(
post: &Post,
metadata: &ProjectMetadata,
current_language: &str,
) -> Vec<AlternateLinkContext> {
metadata
.blog_languages
.iter()
@@ -320,7 +338,11 @@ fn build_alternate_links(post: &Post, metadata: &ProjectMetadata, current_langua
.collect()
}
fn build_blog_languages(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<BlogLanguageContext> {
fn build_blog_languages(
post: &Post,
metadata: &ProjectMetadata,
current_language: &str,
) -> Vec<BlogLanguageContext> {
metadata
.blog_languages
.iter()
@@ -334,7 +356,10 @@ fn build_blog_languages(post: &Post, metadata: &ProjectMetadata, current_languag
.collect()
}
fn build_blog_languages_for_index(metadata: &ProjectMetadata, current_language: &str) -> Vec<BlogLanguageContext> {
fn build_blog_languages_for_index(
metadata: &ProjectMetadata,
current_language: &str,
) -> Vec<BlogLanguageContext> {
metadata
.blog_languages
.iter()
@@ -349,12 +374,23 @@ fn build_blog_languages_for_index(metadata: &ProjectMetadata, current_language:
}
fn build_absolute_post_url(post: &Post, metadata: &ProjectMetadata, language: &str) -> String {
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
format!("{base_url}{}", build_canonical_post_path(post, language, main_language(metadata)))
let base_url = metadata
.public_url
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
format!(
"{base_url}{}",
build_canonical_post_path(post, language, main_language(metadata))
)
}
fn build_absolute_index_url(metadata: &ProjectMetadata, 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 suffix = if language.eq_ignore_ascii_case(main_language(metadata)) {
"/".to_string()
} else {
@@ -377,7 +413,12 @@ fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
continue;
};
let key = format!("{:04}-{:02}-{:02}", timestamp.year(), timestamp.month(), timestamp.day());
let key = format!(
"{:04}-{:02}-{:02}",
timestamp.year(),
timestamp.month(),
timestamp.day()
);
if current_key.as_deref() != Some(key.as_str()) {
if let Some(last) = blocks.last_mut() {
last.show_separator = true;
@@ -385,7 +426,12 @@ fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
current_key = Some(key);
blocks.push(DayBlockContext {
show_date_marker: true,
date_label: format!("{:02}.{:02}.{:04}", timestamp.day(), timestamp.month(), timestamp.year()),
date_label: format!(
"{:02}.{:02}.{:04}",
timestamp.day(),
timestamp.month(),
timestamp.year()
),
posts: Vec::new(),
show_separator: false,
});
@@ -456,19 +502,39 @@ mod tests {
#[test]
fn canonical_post_paths_follow_language_prefix_rule() {
let post = make_post();
assert_eq!(build_canonical_post_path(&post, "en", "en"), "/2024/03/09/hello");
assert_eq!(build_canonical_post_path(&post, "de", "en"), "/de/2024/03/09/hello");
assert_eq!(
build_canonical_post_path(&post, "en", "en"),
"/2024/03/09/hello"
);
assert_eq!(
build_canonical_post_path(&post, "de", "en"),
"/de/2024/03/09/hello"
);
}
#[test]
fn starter_single_post_renderer_uses_canonical_route_and_language_links() {
let post = make_post();
let metadata = make_metadata();
let rendered = render_starter_single_post_page(&post, "Body with [link](/posts/hello)", &metadata, "en").unwrap();
let rendered = render_starter_single_post_page(
&post,
"Body with [link](/posts/hello)",
&metadata,
"en",
)
.unwrap();
assert_eq!(rendered.relative_path, "2024/03/09/hello/index.html");
assert!(rendered.html.contains("https://example.com/2024/03/09/hello"));
assert!(rendered.html.contains("https://example.com/de/2024/03/09/hello"));
assert!(
rendered
.html
.contains("https://example.com/2024/03/09/hello")
);
assert!(
rendered
.html
.contains("https://example.com/de/2024/03/09/hello")
);
assert!(rendered.html.contains("href=\"/2024/03/09/hello\""));
}
@@ -515,4 +581,4 @@ mod tests {
assert!(rendered.html.contains("10.03.2024"));
assert!(rendered.html.contains("href=\"/de/2024/03/10/next\""));
}
}
}

View File

@@ -10,17 +10,28 @@ use serde_json::{Value, json};
use crate::db::queries;
use crate::engine::menu::{self, MenuItemKind};
use crate::model::{CategorySettings, Media, Post, ProjectMetadata, Tag, Template, TemplateKind, TemplateStatus};
use crate::render::{RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path, render_liquid_template, resolve_post_template};
use crate::model::{
CategorySettings, Media, Post, ProjectMetadata, Tag, Template, TemplateKind, TemplateStatus,
};
use crate::render::{
RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path,
render_liquid_template, resolve_post_template,
};
use crate::util::frontmatter::{read_template_file, read_translation_file};
use crate::util::slugify;
const STARTER_SINGLE_POST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/single-post.liquid");
const STARTER_POST_LIST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/post-list.liquid");
const STARTER_NOT_FOUND_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/not-found.liquid");
const STARTER_HEAD_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/head.liquid");
const STARTER_MENU_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/menu.liquid");
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
const STARTER_SINGLE_POST_TEMPLATE: &str =
include_str!("../../../../assets/starter-templates/single-post.liquid");
const STARTER_POST_LIST_TEMPLATE: &str =
include_str!("../../../../assets/starter-templates/post-list.liquid");
const STARTER_NOT_FOUND_TEMPLATE: &str =
include_str!("../../../../assets/starter-templates/not-found.liquid");
const STARTER_HEAD_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/head.liquid");
const STARTER_MENU_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/menu.liquid");
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SitePage {
@@ -100,9 +111,16 @@ pub fn build_site_render_artifacts(
let mut artifacts = SiteRenderArtifacts::default();
for language in languages {
let localized_posts = load_language_posts(conn, data_dir, published_posts, &language, &main_language)?;
let localized_posts =
load_language_posts(conn, data_dir, published_posts, &language, &main_language)?;
let localized_list_posts = filter_posts_for_lists(&localized_posts, &category_settings);
let routes = build_language_routes(&localized_list_posts, metadata, &language, &tags, &category_settings);
let routes = build_language_routes(
&localized_list_posts,
metadata,
&language,
&tags,
&category_settings,
);
let post_data_json_by_id = build_post_data_json_by_id(&localized_posts);
let menu_items = build_menu_items(data_dir, &language, &main_language)?;
let rendered_list_pages = routes
@@ -138,7 +156,8 @@ pub fn build_site_render_artifacts(
artifacts.pages.push(page);
}
let canonical_map = canonical_post_path_by_slug(&localized_posts, &language, &main_language);
let canonical_map =
canonical_post_path_by_slug(&localized_posts, &language, &main_language);
for record in &localized_posts {
let html = render_post_route(
conn,
@@ -183,9 +202,14 @@ pub fn build_preview_response(
published_posts: &[(Post, String)],
requested_path: &str,
) -> Result<PreviewRenderResult, Box<dyn Error + Send + Sync>> {
let artifacts = build_site_render_artifacts(conn, data_dir, project_id, metadata, published_posts)?;
let artifacts =
build_site_render_artifacts(conn, data_dir, project_id, metadata, published_posts)?;
let normalized = normalize_request_path(requested_path);
if let Some(page) = artifacts.pages.iter().find(|page| page.url_path == normalized) {
if let Some(page) = artifacts
.pages
.iter()
.find(|page| page.url_path == normalized)
{
return Ok(PreviewRenderResult {
status_code: 200,
html: page.html.clone(),
@@ -207,7 +231,8 @@ fn load_template_bundle(
data_dir: &Path,
project_id: &str,
) -> Result<TemplateBundle, Box<dyn Error + Send + Sync>> {
let templates = queries::template::list_templates_by_project(conn, project_id).unwrap_or_default();
let templates =
queries::template::list_templates_by_project(conn, project_id).unwrap_or_default();
let mut template_source_by_slug = HashMap::new();
let mut post_templates = Vec::new();
let mut list_template_sources = HashMap::new();
@@ -237,7 +262,10 @@ fn load_template_bundle(
}
}
TemplateKind::NotFound => {
if template.slug == "not-found" || template.slug == "not_found" || not_found_template == STARTER_NOT_FOUND_TEMPLATE {
if template.slug == "not-found"
|| template.slug == "not_found"
|| not_found_template == STARTER_NOT_FOUND_TEMPLATE
{
not_found_template = source;
}
}
@@ -258,7 +286,10 @@ fn load_template_bundle(
.entry("list".to_string())
.or_insert_with(|| STARTER_POST_LIST_TEMPLATE.to_string());
if !post_templates.iter().any(|template| template.slug == "post") {
if !post_templates
.iter()
.any(|template| template.slug == "post")
{
post_templates.push(Template {
id: "starter-post-template".to_string(),
project_id: project_id.to_string(),
@@ -273,7 +304,8 @@ fn load_template_bundle(
created_at: 0,
updated_at: 0,
});
template_source_by_slug.insert("post".to_string(), STARTER_SINGLE_POST_TEMPLATE.to_string());
template_source_by_slug
.insert("post".to_string(), STARTER_SINGLE_POST_TEMPLATE.to_string());
}
Ok(TemplateBundle {
@@ -318,7 +350,11 @@ fn load_language_posts(
continue;
}
if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(conn, &post.id, language) {
if let Ok(translation) =
queries::post_translation::get_post_translation_by_post_and_language(
conn, &post.id, language,
)
{
let raw = fs::read_to_string(data_dir.join(&translation.file_path))?;
let (_, translated_body) = read_translation_file(&raw)?;
let mut translated_post = post.clone();
@@ -336,7 +372,10 @@ fn load_language_posts(
}
posts.sort_by(|left, right| {
right.post.published_at.unwrap_or(right.post.created_at)
right
.post
.published_at
.unwrap_or(right.post.created_at)
.cmp(&left.post.published_at.unwrap_or(left.post.created_at))
});
Ok(posts)
@@ -367,14 +406,29 @@ fn build_language_routes(
for record in posts {
for category in &record.post.categories {
category_posts.entry(category.clone()).or_default().push(record.clone());
category_posts
.entry(category.clone())
.or_default()
.push(record.clone());
}
for tag in &record.post.tags {
tag_posts.entry(tag.clone()).or_default().push(record.clone());
tag_posts
.entry(tag.clone())
.or_default()
.push(record.clone());
}
if let Some(timestamp) = Utc.timestamp_millis_opt(record.post.published_at.unwrap_or(record.post.created_at)).single() {
year_posts.entry(timestamp.year()).or_default().push(record.clone());
month_posts.entry((timestamp.year(), timestamp.month())).or_default().push(record.clone());
if let Some(timestamp) = Utc
.timestamp_millis_opt(record.post.published_at.unwrap_or(record.post.created_at))
.single()
{
year_posts
.entry(timestamp.year())
.or_default()
.push(record.clone());
month_posts
.entry((timestamp.year(), timestamp.month()))
.or_default()
.push(record.clone());
}
}
@@ -383,7 +437,10 @@ fn build_language_routes(
routes.extend(paginated_route_specs(
&records,
per_page,
format!("{}/category/{slug}", language_root_prefix(language, metadata)),
format!(
"{}/category/{slug}",
language_root_prefix(language, metadata)
),
category.clone(),
Some(json!({"kind": "category", "name": category})),
category_settings
@@ -424,7 +481,10 @@ fn build_language_routes(
routes.extend(paginated_route_specs(
&records,
per_page,
format!("{}/{year}/{month:02}", language_root_prefix(language, metadata)),
format!(
"{}/{year}/{month:02}",
language_root_prefix(language, metadata)
),
format!("{} {year}-{month:02}", metadata.name),
Some(json!({"kind": "month", "year": year, "month": month})),
None,
@@ -449,7 +509,11 @@ fn paginated_route_specs(
let current_page = page_index + 1;
let start = page_index * per_page;
let end = (start + per_page).min(total_items);
let slice = if start < end { posts[start..end].to_vec() } else { Vec::new() };
let slice = if start < end {
posts[start..end].to_vec()
} else {
Vec::new()
};
let relative_base = base_path.trim_matches('/');
let relative_path = if current_page == 1 {
if relative_base.is_empty() {
@@ -479,6 +543,10 @@ fn paginated_route_specs(
pages
}
#[expect(
clippy::too_many_arguments,
reason = "render inputs are existing domain data with distinct lifetimes"
)]
fn render_list_route(
route: &RouteSpec,
metadata: &ProjectMetadata,
@@ -536,7 +604,11 @@ fn render_list_route(
"not_found_back_label": serde_json::Value::Null,
});
Ok(render_liquid_template(list_template, &bundle.partials, &context)?)
Ok(render_liquid_template(
list_template,
&bundle.partials,
&context,
)?)
}
#[allow(clippy::too_many_arguments)]
@@ -558,9 +630,12 @@ fn render_post_route(
let render_categories = category_settings
.iter()
.map(|(name, settings)| {
(name.clone(), RenderCategorySettings {
post_template_slug: settings.post_template_slug.clone(),
})
(
name.clone(),
RenderCategorySettings {
post_template_slug: settings.post_template_slug.clone(),
},
)
})
.collect::<HashMap<_, _>>();
let resolved = resolve_post_template(RenderTemplateLookup {
@@ -583,12 +658,26 @@ fn render_post_route(
.filter_map(|link| media_by_id.get(&link.media_id))
.map(media_context)
.collect::<Vec<_>>();
let outgoing_links = queries::post_link::list_links_by_source(conn, &record.post.id).unwrap_or_default();
let incoming_links = queries::post_link::list_links_by_target(conn, &record.post.id).unwrap_or_default();
let post_by_id = all_posts.iter().map(|item| (item.post.id.clone(), item)).collect::<HashMap<_, _>>();
let outgoing_link_context = outgoing_links.iter().map(|link| link_context(link, &post_by_id, language, main_language)).collect::<Vec<_>>();
let incoming_link_context = incoming_links.iter().map(|link| link_context(link, &post_by_id, language, main_language)).collect::<Vec<_>>();
let backlinks = incoming_links.iter().map(|link| backlink_context(link, &post_by_id, language, main_language)).collect::<Vec<_>>();
let outgoing_links =
queries::post_link::list_links_by_source(conn, &record.post.id).unwrap_or_default();
let incoming_links =
queries::post_link::list_links_by_target(conn, &record.post.id).unwrap_or_default();
let post_by_id = all_posts
.iter()
.map(|item| (item.post.id.clone(), item))
.collect::<HashMap<_, _>>();
let outgoing_link_context = outgoing_links
.iter()
.map(|link| link_context(link, &post_by_id, language, main_language))
.collect::<Vec<_>>();
let incoming_link_context = incoming_links
.iter()
.map(|link| link_context(link, &post_by_id, language, main_language))
.collect::<Vec<_>>();
let backlinks = incoming_links
.iter()
.map(|link| backlink_context(link, &post_by_id, language, main_language))
.collect::<Vec<_>>();
let taxonomy_counts = build_taxonomy_counts(all_posts, tags);
let context = json!({
@@ -626,7 +715,11 @@ fn render_post_route(
"not_found_back_label": serde_json::Value::Null,
});
Ok(render_liquid_template(&template_source, &bundle.partials, &context)?)
Ok(render_liquid_template(
&template_source,
&bundle.partials,
&context,
)?)
}
fn render_not_found_route(
@@ -670,7 +763,11 @@ fn render_not_found_route(
"canonical_media_path_by_source_path": HashMap::<String, String>::new(),
"post_data_json_by_id": HashMap::<String, Value>::new(),
});
Ok(render_liquid_template(&bundle.not_found_template, &bundle.partials, &context)?)
Ok(render_liquid_template(
&bundle.not_found_template,
&bundle.partials,
&context,
)?)
}
fn build_menu_items(
@@ -728,17 +825,17 @@ fn prefixed_slug_path(prefix: &str, slug: &str) -> String {
}
fn prefix_or_root(prefix: &str) -> &str {
if prefix.is_empty() {
"/"
} else {
prefix
}
if prefix.is_empty() { "/" } else { prefix }
}
fn route_href(route: &RouteSpec, page: usize) -> String {
let base = route.url_path.trim_end_matches('/');
if page <= 1 {
if base.is_empty() { "/".to_string() } else { base.to_string() }
if base.is_empty() {
"/".to_string()
} else {
base.to_string()
}
} else if base.is_empty() || base == "/" {
format!("/page/{page}")
} else {
@@ -760,7 +857,12 @@ fn build_day_blocks(
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
continue;
};
let key = format!("{:04}-{:02}-{:02}", timestamp.year(), timestamp.month(), timestamp.day());
let key = format!(
"{:04}-{:02}-{:02}",
timestamp.year(),
timestamp.month(),
timestamp.day()
);
if !current_key.is_empty() && current_key != key {
blocks.push(json!({
"show_date_marker": true,
@@ -771,7 +873,12 @@ fn build_day_blocks(
current_posts = Vec::new();
}
current_key = key;
current_label = format!("{:02}.{:02}.{:04}", timestamp.day(), timestamp.month(), timestamp.year());
current_label = format!(
"{:02}.{:02}.{:04}",
timestamp.day(),
timestamp.month(),
timestamp.year()
);
let show_title = should_show_list_title(&record.post, category_settings);
current_posts.push(json!({
"id": record.post.id,
@@ -797,7 +904,8 @@ fn filter_posts_for_lists(
posts: &[RenderPostRecord],
category_settings: &HashMap<String, CategorySettings>,
) -> Vec<RenderPostRecord> {
posts.iter()
posts
.iter()
.filter(|record| !is_post_excluded_from_lists(&record.post, category_settings))
.cloned()
.collect()
@@ -850,11 +958,14 @@ fn canonical_post_path_by_slug(
language: &str,
main_language: &str,
) -> HashMap<String, String> {
posts.iter()
.map(|record| (
record.post.slug.clone(),
build_canonical_post_path(&record.post, language, main_language),
))
posts
.iter()
.map(|record| {
(
record.post.slug.clone(),
build_canonical_post_path(&record.post, language, main_language),
)
})
.collect()
}
@@ -874,7 +985,9 @@ fn extract_media_refs(markdown: &str) -> Vec<String> {
let mut remainder = markdown;
while let Some(index) = remainder.find("bds-media://") {
let rest = &remainder[index..];
let end = rest.find(|ch: char| ch == ')' || ch == ']' || ch.is_whitespace()).unwrap_or(rest.len());
let end = rest
.find(|ch: char| ch == ')' || ch == ']' || ch.is_whitespace())
.unwrap_or(rest.len());
refs.push(rest[..end].to_string());
remainder = &rest[end..];
}
@@ -927,29 +1040,50 @@ fn build_taxonomy_counts(
}
}
let categories = category_counts.into_iter().map(|(name, count)| json!({
"name": name,
"slug": slugify(&name),
"post_count": count,
})).collect::<Vec<_>>();
let tag_items = tag_counts.into_iter().map(|(name, count)| {
let color = tags.iter().find(|tag| tag.name.eq_ignore_ascii_case(&name)).and_then(|tag| tag.color.clone());
json!({
"name": name,
"slug": slugify(&name),
"color": color,
"post_count": count,
let categories = category_counts
.into_iter()
.map(|(name, count)| {
json!({
"name": name,
"slug": slugify(&name),
"post_count": count,
})
})
}).collect::<Vec<_>>();
.collect::<Vec<_>>();
let tag_items = tag_counts
.into_iter()
.map(|(name, count)| {
let color = tags
.iter()
.find(|tag| tag.name.eq_ignore_ascii_case(&name))
.and_then(|tag| tag.color.clone());
json!({
"name": name,
"slug": slugify(&name),
"color": color,
"post_count": count,
})
})
.collect::<Vec<_>>();
(categories, tag_items, tag_colors)
}
fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -> Vec<Value> {
names.iter()
names
.iter()
.map(|name| {
let count = posts.iter().filter(|record| record.post.categories.iter().any(|category| category.eq_ignore_ascii_case(name))).count();
let count = posts
.iter()
.filter(|record| {
record
.post
.categories
.iter()
.any(|category| category.eq_ignore_ascii_case(name))
})
.count();
json!({
"name": name,
"slug": slugify(name),
@@ -959,11 +1093,28 @@ fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -
.collect()
}
fn taxonomy_items_for_tags(names: &[String], posts: &[RenderPostRecord], tags: &[Tag]) -> Vec<Value> {
names.iter()
fn taxonomy_items_for_tags(
names: &[String],
posts: &[RenderPostRecord],
tags: &[Tag],
) -> Vec<Value> {
names
.iter()
.map(|name| {
let count = posts.iter().filter(|record| record.post.tags.iter().any(|tag| tag.eq_ignore_ascii_case(name))).count();
let color = tags.iter().find(|tag| tag.name.eq_ignore_ascii_case(name)).and_then(|tag| tag.color.clone());
let count = posts
.iter()
.filter(|record| {
record
.post
.tags
.iter()
.any(|tag| tag.eq_ignore_ascii_case(name))
})
.count();
let color = tags
.iter()
.find(|tag| tag.name.eq_ignore_ascii_case(name))
.and_then(|tag| tag.color.clone());
json!({
"name": name,
"slug": slugify(name),
@@ -1076,7 +1227,11 @@ fn build_alternate_post_links(post: &Post, metadata: &ProjectMetadata) -> Vec<Va
links
}
fn build_post_blog_languages(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<Value> {
fn build_post_blog_languages(
post: &Post,
metadata: &ProjectMetadata,
current_language: &str,
) -> Vec<Value> {
let main_language = main_language(metadata);
render_languages(metadata)
.into_iter()
@@ -1090,7 +1245,11 @@ fn build_post_blog_languages(post: &Post, metadata: &ProjectMetadata, current_la
.collect()
}
fn build_list_blog_languages(metadata: &ProjectMetadata, current_language: &str, current_url_path: &str) -> Vec<Value> {
fn build_list_blog_languages(
metadata: &ProjectMetadata,
current_language: &str,
current_url_path: &str,
) -> Vec<Value> {
let main_language = main_language(metadata);
render_languages(metadata)
.into_iter()
@@ -1126,7 +1285,11 @@ fn build_alternate_list_links(metadata: &ProjectMetadata, current_url_path: &str
links
}
fn relocalize_url_path(current_url_path: &str, target_language: &str, main_language: &str) -> String {
fn relocalize_url_path(
current_url_path: &str,
target_language: &str,
main_language: &str,
) -> String {
let stripped = strip_language_prefix(current_url_path, main_language);
if target_language.eq_ignore_ascii_case(main_language) {
stripped
@@ -1140,10 +1303,11 @@ fn relocalize_url_path(current_url_path: &str, target_language: &str, main_langu
fn strip_language_prefix(path: &str, main_language: &str) -> String {
let normalized = normalize_request_path(path);
let trimmed = normalized.trim_start_matches('/');
if let Some((first, remainder)) = trimmed.split_once('/') {
if first.len() == 2 && !first.eq_ignore_ascii_case(main_language) {
return format!("/{}", remainder.trim_start_matches('/'));
}
if let Some((first, remainder)) = trimmed.split_once('/')
&& first.len() == 2
&& !first.eq_ignore_ascii_case(main_language)
{
return format!("/{}", remainder.trim_start_matches('/'));
}
normalized
}
@@ -1161,16 +1325,20 @@ fn relative_to_url_path(relative_path: &str) -> String {
if relative_path == "index.html" {
return "/".to_string();
}
let trimmed = relative_path.trim_end_matches("index.html").trim_end_matches('/');
let trimmed = relative_path
.trim_end_matches("index.html")
.trim_end_matches('/');
format!("/{}", trimmed.trim_start_matches('/'))
}
fn language_from_path(path: &str, metadata: &ProjectMetadata) -> String {
let trimmed = path.trim_start_matches('/');
if let Some((candidate, _)) = trimmed.split_once('/') {
if render_languages(metadata).iter().any(|language| language == candidate) {
return candidate.to_string();
}
if let Some((candidate, _)) = trimmed.split_once('/')
&& render_languages(metadata)
.iter()
.any(|language| language == candidate)
{
return candidate.to_string();
}
main_language(metadata).to_string()
}
@@ -1179,7 +1347,10 @@ fn render_languages(metadata: &ProjectMetadata) -> Vec<String> {
let main = main_language(metadata).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());
}
}
@@ -1213,18 +1384,31 @@ fn normalize_partial_slug(slug: &str) -> String {
fn starter_partials() -> HashMap<String, String> {
HashMap::from([
("partials/head".to_string(), STARTER_HEAD_PARTIAL.to_string()),
("partials/menu".to_string(), STARTER_MENU_PARTIAL.to_string()),
("partials/language-switcher".to_string(), STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string()),
(
"partials/head".to_string(),
STARTER_HEAD_PARTIAL.to_string(),
),
(
"partials/menu".to_string(),
STARTER_MENU_PARTIAL.to_string(),
),
(
"partials/language-switcher".to_string(),
STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string(),
),
(
"partials/menu-items".to_string(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}".to_string(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
.to_string(),
),
])
}
fn pico_stylesheet_href(metadata: &ProjectMetadata) -> Option<String> {
metadata.pico_theme.as_ref().map(|_| "/assets/pico.min.css".to_string())
metadata
.pico_theme
.as_ref()
.map(|_| "/assets/pico.min.css".to_string())
}
fn calendar_initial_parts(post: &Post) -> (i32, u32) {
@@ -1247,4 +1431,4 @@ fn queries_category_settings(
data_dir: &Path,
) -> Result<HashMap<String, CategorySettings>, Box<dyn Error + Send + Sync>> {
Ok(crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default())
}
}

View File

@@ -21,13 +21,17 @@ pub enum TemplateLookupError {
MissingDefaultTemplate,
}
pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a Template, TemplateLookupError> {
pub fn resolve_post_template<'a>(
lookup: RenderTemplateLookup<'a>,
) -> Result<&'a Template, TemplateLookupError> {
if let Some(explicit_slug) = lookup.post.template_slug.as_deref() {
return lookup
.templates
.iter()
.find(|template| is_enabled_post_template(template, explicit_slug))
.ok_or_else(|| TemplateLookupError::MissingExplicitTemplate(explicit_slug.to_string()));
.ok_or_else(|| {
TemplateLookupError::MissingExplicitTemplate(explicit_slug.to_string())
});
}
for post_tag in &lookup.post.tags {
@@ -36,14 +40,12 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
.iter()
.find(|tag| tag.name.eq_ignore_ascii_case(post_tag))
.and_then(|tag| tag.post_template_slug.as_deref())
{
if let Some(template) = lookup
&& let Some(template) = lookup
.templates
.iter()
.find(|template| is_enabled_post_template(template, template_slug))
{
return Ok(template);
}
{
return Ok(template);
}
}
@@ -52,14 +54,12 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
.category_settings
.get(category_name)
.and_then(|settings| settings.post_template_slug.as_deref())
{
if let Some(template) = lookup
&& let Some(template) = lookup
.templates
.iter()
.find(|template| is_enabled_post_template(template, template_slug))
{
return Ok(template);
}
{
return Ok(template);
}
}
@@ -72,4 +72,4 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
fn is_enabled_post_template(template: &Template, slug: &str) -> bool {
template.enabled && template.kind == TemplateKind::Post && template.slug == slug
}
}

View File

@@ -1,5 +1,5 @@
use crate::model::{Post, PostTranslation};
use crate::util::timestamp::{unix_ms_to_iso, iso_to_unix_ms};
use crate::util::timestamp::{iso_to_unix_ms, unix_ms_to_iso};
/// Split content at `---` delimiters into (yaml, body).
/// Returns `None` if the content does not start with `---`.
@@ -118,28 +118,28 @@ impl PostFrontmatter {
}
// Conditional fields (only when truthy)
if let Some(ref excerpt) = self.excerpt {
if !excerpt.is_empty() {
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
}
if let Some(ref excerpt) = self.excerpt
&& !excerpt.is_empty()
{
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
}
if let Some(ref author) = self.author {
if !author.is_empty() {
lines.push(format!("author: {}", yaml_string_value(author)));
}
if let Some(ref author) = self.author
&& !author.is_empty()
{
lines.push(format!("author: {}", yaml_string_value(author)));
}
if let Some(ref language) = self.language {
if !language.is_empty() {
lines.push(format!("language: {language}"));
}
if let Some(ref language) = self.language
&& !language.is_empty()
{
lines.push(format!("language: {language}"));
}
if self.do_not_translate {
lines.push("doNotTranslate: true".to_string());
}
if let Some(ref template_slug) = self.template_slug {
if !template_slug.is_empty() {
lines.push(format!("templateSlug: {template_slug}"));
}
if let Some(ref template_slug) = self.template_slug
&& !template_slug.is_empty()
{
lines.push(format!("templateSlug: {template_slug}"));
}
if let Some(published_at) = self.published_at {
lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at)));
@@ -157,7 +157,7 @@ impl PostFrontmatter {
.ok_or("frontmatter is not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
@@ -167,7 +167,7 @@ impl PostFrontmatter {
};
let get_string_list = |key: &str| -> Vec<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
@@ -198,8 +198,7 @@ impl PostFrontmatter {
do_not_translate: get_str("doNotTranslate")
.map(|s| s == "true")
.unwrap_or(false),
published_at: get_str("publishedAt")
.and_then(|s| iso_to_unix_ms(&s).ok()),
published_at: get_str("publishedAt").and_then(|s| iso_to_unix_ms(&s).ok()),
})
}
}
@@ -212,8 +211,7 @@ pub fn write_post_file(post: &Post, body: &str) -> String {
/// Read a complete post file, returning frontmatter + body.
pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content)
.ok_or("no frontmatter delimiters found")?;
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
let fm = PostFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
}
@@ -223,31 +221,61 @@ pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String
/// Parsed translation frontmatter.
#[derive(Debug, Clone)]
pub struct TranslationFrontmatter {
pub id: Option<String>,
pub translation_for: String,
pub language: String,
pub title: String,
pub excerpt: Option<String>,
pub status: Option<String>,
pub created_at: Option<i64>,
pub updated_at: Option<i64>,
pub published_at: Option<i64>,
}
impl TranslationFrontmatter {
pub fn from_translation(t: &PostTranslation) -> Self {
Self {
id: Some(t.id.clone()),
translation_for: t.translation_for.clone(),
language: t.language.clone(),
title: t.title.clone(),
excerpt: t.excerpt.clone(),
status: Some(
serde_json::to_string(&t.status)
.unwrap_or_default()
.trim_matches('"')
.to_string(),
),
created_at: Some(t.created_at),
updated_at: Some(t.updated_at),
published_at: t.published_at,
}
}
pub fn to_yaml(&self) -> String {
let mut lines = Vec::new();
if let Some(id) = &self.id {
lines.push(format!("id: {id}"));
}
lines.push(format!("translationFor: {}", self.translation_for));
lines.push(format!("language: {}", self.language));
lines.push(format!("title: {}", yaml_string_value(&self.title)));
if let Some(ref excerpt) = self.excerpt {
if !excerpt.is_empty() {
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
}
if let Some(ref excerpt) = self.excerpt
&& !excerpt.is_empty()
{
lines.push(format!("excerpt: {}", yaml_string_value(excerpt)));
}
if let Some(status) = &self.status {
lines.push(format!("status: {status}"));
}
if let Some(created_at) = self.created_at {
lines.push(format!("createdAt: '{}'", unix_ms_to_iso(created_at)));
}
if let Some(updated_at) = self.updated_at {
lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(updated_at)));
}
if let Some(published_at) = self.published_at {
lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at)));
}
lines.join("\n")
}
@@ -260,16 +288,21 @@ impl TranslationFrontmatter {
.ok_or("frontmatter is not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
};
Ok(Self {
id: get_str("id"),
translation_for: get_str("translationFor").ok_or("missing 'translationFor'")?,
language: get_str("language").ok_or("missing 'language'")?,
title: get_str("title").ok_or("missing 'title'")?,
excerpt: get_str("excerpt").filter(|s| !s.is_empty()),
status: get_str("status"),
created_at: get_str("createdAt").and_then(|value| iso_to_unix_ms(&value).ok()),
updated_at: get_str("updatedAt").and_then(|value| iso_to_unix_ms(&value).ok()),
published_at: get_str("publishedAt").and_then(|value| iso_to_unix_ms(&value).ok()),
})
}
}
@@ -282,8 +315,7 @@ pub fn write_translation_file(translation: &PostTranslation, body: &str) -> Stri
/// Read a complete translation file.
pub fn read_translation_file(content: &str) -> Result<(TranslationFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content)
.ok_or("no frontmatter delimiters found")?;
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
let fm = TranslationFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
}
@@ -317,8 +349,14 @@ impl TemplateFrontmatter {
lines.push(format!("kind: \"{}\"", self.kind));
lines.push(format!("enabled: {}", self.enabled));
lines.push(format!("version: {}", self.version));
lines.push(format!("createdAt: \"{}\"", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: \"{}\"", unix_ms_to_iso(self.updated_at)));
lines.push(format!(
"createdAt: \"{}\"",
unix_ms_to_iso(self.created_at)
));
lines.push(format!(
"updatedAt: \"{}\"",
unix_ms_to_iso(self.updated_at)
));
lines.join("\n")
}
@@ -328,7 +366,7 @@ impl TemplateFrontmatter {
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
@@ -359,8 +397,7 @@ impl TemplateFrontmatter {
/// Read a template file (frontmatter + body).
pub fn read_template_file(content: &str) -> Result<(TemplateFrontmatter, String), String> {
let (yaml, body) = split_frontmatter(content)
.ok_or("no frontmatter delimiters found")?;
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
let fm = TemplateFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
}
@@ -401,8 +438,14 @@ impl ScriptFrontmatter {
lines.push(format!("entrypoint: \"{}\"", self.entrypoint));
lines.push(format!("enabled: {}", self.enabled));
lines.push(format!("version: {}", self.version));
lines.push(format!("createdAt: \"{}\"", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: \"{}\"", unix_ms_to_iso(self.updated_at)));
lines.push(format!(
"createdAt: \"{}\"",
unix_ms_to_iso(self.created_at)
));
lines.push(format!(
"updatedAt: \"{}\"",
unix_ms_to_iso(self.updated_at)
));
lines.join("\n")
}
@@ -412,7 +455,7 @@ impl ScriptFrontmatter {
let map = doc.as_mapping().ok_or("not a YAML mapping")?;
let get_str = |key: &str| -> Option<String> {
map.get(&serde_yaml::Value::String(key.to_string()))
map.get(serde_yaml::Value::String(key.to_string()))
.and_then(|v| match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
@@ -450,8 +493,7 @@ pub fn read_script_file(content: &str) -> Result<(ScriptFrontmatter, String), St
return Ok((fm, body.to_string()));
}
// Fall back to standard --- format (Lua scripts)
let (yaml, body) = split_frontmatter(content)
.ok_or("no frontmatter delimiters found")?;
let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?;
let fm = ScriptFrontmatter::from_yaml(yaml)?;
Ok((fm, body.to_string()))
}
@@ -513,6 +555,7 @@ fn yaml_string_value(s: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::model::PostStatus;
use std::fs;
use std::path::PathBuf;
@@ -551,7 +594,10 @@ mod tests {
assert_eq!(fm.title, "Esmeralda");
assert_eq!(fm.slug, "esmeralda");
assert_eq!(fm.status, "published");
assert_eq!(fm.tags, vec!["fotografie", "makro", "natur", "spinne", "tiere"]);
assert_eq!(
fm.tags,
vec!["fotografie", "makro", "natur", "spinne", "tiere"]
);
assert_eq!(fm.categories, vec!["picture"]);
assert_eq!(fm.language.as_deref(), Some("es"));
assert_eq!(fm.published_at, Some(1131883200000));
@@ -655,7 +701,36 @@ mod tests {
let yaml = fm.to_yaml();
let actual = format_frontmatter(&yaml, &body);
assert_eq!(actual, expected, "golden output mismatch for esmeralda.en.md");
assert_eq!(
actual, expected,
"golden output mismatch for esmeralda.en.md"
);
}
#[test]
fn current_translation_output_carries_full_metadata() {
let translation = PostTranslation {
id: "translation-1".into(),
project_id: "project-1".into(),
translation_for: "post-1".into(),
language: "de".into(),
title: "Titel".into(),
excerpt: None,
content: None,
status: PostStatus::Published,
file_path: "posts/2026/07/post.de.md".into(),
checksum: None,
created_at: 1_751_328_000_000,
updated_at: 1_751_414_400_000,
published_at: Some(1_751_414_400_000),
};
let output = write_translation_file(&translation, "Inhalt");
assert!(output.contains("id: translation-1"));
assert!(output.contains("status: published"));
assert!(output.contains("createdAt:"));
assert!(output.contains("updatedAt:"));
assert!(output.contains("publishedAt:"));
}
#[test]
@@ -723,7 +798,10 @@ mod tests {
let content = fs::read_to_string(path).unwrap();
let (fm, body) = read_template_file(&content).unwrap();
assert_eq!(fm.id, "38704737-b7e7-4dd4-b010-9208bcf80ef6");
assert_eq!(fm.project_id.as_deref(), Some("1979237c-034d-41f6-99a0-f35eb57b3f6c"));
assert_eq!(
fm.project_id.as_deref(),
Some("1979237c-034d-41f6-99a0-f35eb57b3f6c")
);
assert_eq!(fm.slug, "testvorlage");
assert_eq!(fm.title, "Testvorlage");
assert_eq!(fm.kind, "post");
@@ -738,7 +816,10 @@ mod tests {
let expected = fs::read_to_string(&path).unwrap();
let (fm, body) = read_template_file(&expected).unwrap();
let actual = write_template_file(&fm, &body);
assert_eq!(actual, expected, "golden output mismatch for testvorlage.liquid");
assert_eq!(
actual, expected,
"golden output mismatch for testvorlage.liquid"
);
}
// --- Script frontmatter tests ---

View File

@@ -1,14 +1,17 @@
mod slug;
mod checksum;
pub mod timestamp;
pub mod atomic_write;
pub mod paths;
mod checksum;
pub mod frontmatter;
pub mod paths;
pub mod sidecar;
mod slug;
pub mod thumbnail;
pub mod timestamp;
pub use slug::{slugify, ensure_unique};
pub use checksum::{content_hash, file_hash};
pub use timestamp::{unix_ms_to_iso, iso_to_unix_ms, year_month_from_unix_ms, year_month_day_from_unix_ms, now_unix_ms};
pub use atomic_write::{atomic_write, atomic_write_str};
pub use checksum::{content_hash, file_hash};
pub use paths::*;
pub use slug::{ensure_unique, slugify};
pub use timestamp::{
calendar_range_unix_ms, iso_to_unix_ms, now_unix_ms, unix_ms_to_iso,
year_month_day_from_unix_ms, year_month_from_unix_ms,
};

View File

@@ -8,7 +8,11 @@ pub fn post_file_path(created_at_ms: i64, slug: &str) -> String {
/// Translation file path: `posts/YYYY/MM/{slug}.{lang}.md` from canonical
/// post's `created_at`.
pub fn translation_file_path(canonical_created_at_ms: i64, canonical_slug: &str, language: &str) -> String {
pub fn translation_file_path(
canonical_created_at_ms: i64,
canonical_slug: &str,
language: &str,
) -> String {
let (y, m) = year_month_from_unix_ms(canonical_created_at_ms);
format!("posts/{y}/{m}/{canonical_slug}.{language}.md")
}
@@ -101,7 +105,10 @@ mod tests {
#[test]
fn template_and_script_paths() {
assert_eq!(template_file_path("testvorlage"), "templates/testvorlage.liquid");
assert_eq!(
template_file_path("testvorlage"),
"templates/testvorlage.liquid"
);
assert_eq!(script_file_path("bgg_link"), "scripts/bgg_link.lua");
}
}

View File

@@ -1,5 +1,7 @@
use std::fmt::{self, Display};
use crate::model::Media;
use crate::util::timestamp::{unix_ms_to_iso, iso_to_unix_ms};
use crate::util::timestamp::{iso_to_unix_ms, unix_ms_to_iso};
/// Parsed media sidecar fields.
#[derive(Debug, Clone)]
@@ -53,11 +55,14 @@ impl MediaSidecar {
}
/// Serialize to the hand-built YAML-like format matching TypeScript output.
pub fn to_string(&self) -> String {
fn serialize(&self) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("---".into());
lines.push(format!("id: {}", self.id));
lines.push(format!("originalName: \"{}\"", escape_double_quotes(&self.original_name)));
lines.push(format!(
"originalName: \"{}\"",
escape_double_quotes(&self.original_name)
));
lines.push(format!("mimeType: {}", self.mime_type));
lines.push(format!("size: {}", self.size));
if let Some(w) = self.width {
@@ -66,30 +71,30 @@ impl MediaSidecar {
if let Some(h) = self.height {
lines.push(format!("height: {h}"));
}
if let Some(ref title) = self.title {
if !title.is_empty() {
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
}
if let Some(ref title) = self.title
&& !title.is_empty()
{
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
}
if let Some(ref alt) = self.alt {
if !alt.is_empty() {
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
}
if let Some(ref alt) = self.alt
&& !alt.is_empty()
{
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
}
if let Some(ref caption) = self.caption {
if !caption.is_empty() {
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
}
if let Some(ref caption) = self.caption
&& !caption.is_empty()
{
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
}
if let Some(ref author) = self.author {
if !author.is_empty() {
lines.push(format!("author: \"{}\"", escape_double_quotes(author)));
}
if let Some(ref author) = self.author
&& !author.is_empty()
{
lines.push(format!("author: \"{}\"", escape_double_quotes(author)));
}
if let Some(ref language) = self.language {
if !language.is_empty() {
lines.push(format!("language: {language}"));
}
if let Some(ref language) = self.language
&& !language.is_empty()
{
lines.push(format!("language: {language}"));
}
lines.push(format!("createdAt: {}", unix_ms_to_iso(self.created_at)));
lines.push(format!("updatedAt: {}", unix_ms_to_iso(self.updated_at)));
@@ -98,13 +103,21 @@ impl MediaSidecar {
if self.tags.is_empty() {
lines.push("tags: []".into());
} else {
let quoted: Vec<String> = self.tags.iter().map(|t| format!("\"{}\"", escape_double_quotes(t))).collect();
let quoted: Vec<String> = self
.tags
.iter()
.map(|t| format!("\"{}\"", escape_double_quotes(t)))
.collect();
lines.push(format!("tags: [{}]", quoted.join(", ")));
}
// linkedPostIds: only if non-empty
if !self.linked_post_ids.is_empty() {
let quoted: Vec<String> = self.linked_post_ids.iter().map(|id| format!("\"{id}\"")).collect();
let quoted: Vec<String> = self
.linked_post_ids
.iter()
.map(|id| format!("\"{id}\""))
.collect();
lines.push(format!("linkedPostIds: [{}]", quoted.join(", ")));
}
@@ -113,33 +126,45 @@ impl MediaSidecar {
}
}
impl Display for MediaSidecar {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.serialize())
}
}
impl MediaTranslationSidecar {
/// Serialize to the hand-built format.
pub fn to_string(&self) -> String {
fn serialize(&self) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("---".into());
lines.push(format!("translationFor: {}", self.translation_for));
lines.push(format!("language: {}", self.language));
if let Some(ref title) = self.title {
if !title.is_empty() {
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
}
if let Some(ref title) = self.title
&& !title.is_empty()
{
lines.push(format!("title: \"{}\"", escape_double_quotes(title)));
}
if let Some(ref alt) = self.alt {
if !alt.is_empty() {
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
}
if let Some(ref alt) = self.alt
&& !alt.is_empty()
{
lines.push(format!("alt: \"{}\"", escape_double_quotes(alt)));
}
if let Some(ref caption) = self.caption {
if !caption.is_empty() {
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
}
if let Some(ref caption) = self.caption
&& !caption.is_empty()
{
lines.push(format!("caption: \"{}\"", escape_double_quotes(caption)));
}
lines.push("---".into());
lines.join("\n")
}
}
impl Display for MediaTranslationSidecar {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.serialize())
}
}
/// Parse a canonical media sidecar.
pub fn read_sidecar(content: &str) -> Result<MediaSidecar, String> {
// Strip --- delimiters
@@ -301,7 +326,8 @@ mod tests {
#[test]
fn parse_fixture_sidecar() {
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let path =
fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let content = fs::read_to_string(path).unwrap();
let sc = read_sidecar(&content).unwrap();
assert_eq!(sc.id, "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240");
@@ -314,17 +340,25 @@ mod tests {
assert!(sc.alt.as_ref().unwrap().contains("Spinnenfrau"));
assert!(sc.caption.as_ref().unwrap().contains("Handwerkskunst"));
assert!(sc.tags.is_empty());
assert_eq!(sc.linked_post_ids, vec!["40a83ab1-423d-4310-aac4-642d84675007"]);
assert_eq!(
sc.linked_post_ids,
vec!["40a83ab1-423d-4310-aac4-642d84675007"]
);
}
#[test]
fn golden_output_sidecar() {
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let path =
fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let expected = fs::read_to_string(&path).unwrap();
let sc = read_sidecar(&expected).unwrap();
let actual = sc.to_string();
// Compare trimmed (fixture may or may not have trailing newline)
assert_eq!(actual.trim(), expected.trim(), "golden output mismatch for media sidecar");
assert_eq!(
actual.trim(),
expected.trim(),
"golden output mismatch for media sidecar"
);
}
#[test]
@@ -378,10 +412,7 @@ mod tests {
#[test]
fn inline_json_array_parsing() {
assert_eq!(parse_inline_json_array("[]"), Vec::<String>::new());
assert_eq!(
parse_inline_json_array("[\"a\", \"b\"]"),
vec!["a", "b"]
);
assert_eq!(parse_inline_json_array("[\"a\", \"b\"]"), vec!["a", "b"]);
}
#[test]

View File

@@ -1,5 +1,4 @@
use deunicode::deunicode;
use std::time::{SystemTime, UNIX_EPOCH};
/// Pre-process German characters to match TypeScript `transliteration` npm output.
/// deunicode maps ä→a, ö→o, ü→u but TypeScript produces ä→ae, ö→oe, ü→ue.
@@ -50,7 +49,7 @@ pub fn slugify(input: &str) -> String {
}
/// Ensure a slug is unique within a project, using the spec's algorithm:
/// tries base, then {slug}-2 .. {slug}-999, then {slug}-{timestamp}.
/// tries base, then unbounded numeric suffixes `{slug}-2`, `{slug}-3`, and so on.
///
/// `exists` is a predicate that returns true if the candidate slug is already taken.
pub fn ensure_unique<F>(base: &str, exists: F) -> String
@@ -60,17 +59,13 @@ where
if !exists(base) {
return base.to_string();
}
for n in 2..=999 {
for n in 2_u64.. {
let candidate = format!("{base}-{n}");
if !exists(&candidate) {
return candidate;
}
}
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{base}-{ts}")
unreachable!()
}
#[cfg(test)]
@@ -121,7 +116,9 @@ mod tests {
#[test]
fn ensure_unique_sequential_taken() {
let slug = ensure_unique("hello", |s| s == "hello" || s == "hello-2" || s == "hello-3");
let slug = ensure_unique("hello", |s| {
s == "hello" || s == "hello-2" || s == "hello-3"
});
assert_eq!(slug, "hello-4");
}
@@ -178,20 +175,18 @@ mod tests {
}
#[test]
fn ensure_unique_all_999_taken() {
fn ensure_unique_continues_after_999() {
let slug = ensure_unique("x", |s| {
if s == "x" { return true; }
if let Some(suffix) = s.strip_prefix("x-") {
if let Ok(n) = suffix.parse::<u32>() {
return n <= 999;
}
if s == "x" {
return true;
}
if let Some(suffix) = s.strip_prefix("x-")
&& let Ok(n) = suffix.parse::<u32>()
{
return n <= 999;
}
false
});
// Should fall back to timestamp-based slug
assert!(slug.starts_with("x-"));
let suffix = slug.strip_prefix("x-").unwrap();
let ts: u64 = suffix.parse().expect("should be a timestamp");
assert!(ts > 1_000_000_000);
assert_eq!(slug, "x-1000");
}
}

View File

@@ -32,10 +32,34 @@ pub enum ThumbnailFit {
/// Standard thumbnail sizes matching spec: small/medium/large are
/// width-constrained aspect-preserving; AI is letterboxed on black.
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
ThumbnailSize { name: "small", width: 150, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "medium", width: 400, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "large", width: 800, height: u32::MAX, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "ai", width: 448, height: 448, format: ThumbnailFormat::Jpeg, fit: ThumbnailFit::Contain },
ThumbnailSize {
name: "small",
width: 150,
height: u32::MAX,
format: ThumbnailFormat::Webp,
fit: ThumbnailFit::Inside,
},
ThumbnailSize {
name: "medium",
width: 400,
height: u32::MAX,
format: ThumbnailFormat::Webp,
fit: ThumbnailFit::Inside,
},
ThumbnailSize {
name: "large",
width: 800,
height: u32::MAX,
format: ThumbnailFormat::Webp,
fit: ThumbnailFit::Inside,
},
ThumbnailSize {
name: "ai",
width: 448,
height: 448,
format: ThumbnailFormat::Jpeg,
fit: ThumbnailFit::Contain,
},
];
/// Generate a single thumbnail from a source image file.
@@ -57,9 +81,7 @@ pub fn generate_thumbnail(
img.resize(size.width, size.height, FilterType::Lanczos3)
}
}
ThumbnailFit::Cover => {
img.resize_to_fill(size.width, size.height, FilterType::Lanczos3)
}
ThumbnailFit::Cover => img.resize_to_fill(size.width, size.height, FilterType::Lanczos3),
ThumbnailFit::Contain => {
// Resize to fit, then place on black background
let resized = img.resize(size.width, size.height, FilterType::Lanczos3);
@@ -118,7 +140,11 @@ pub fn generate_all_thumbnails(
let dest = thumbnails_dir
.join(prefix)
.join(format!("{media_id}-{}.{ext}", size.name));
let quality = if size.format == ThumbnailFormat::Jpeg { 85 } else { 80 };
let quality = if size.format == ThumbnailFormat::Jpeg {
85
} else {
80
};
generate_thumbnail(source, &dest, size, quality)?;
paths.push(dest.to_string_lossy().to_string());
}
@@ -136,10 +162,10 @@ fn load_and_orient(path: &Path) -> Result<DynamicImage, String> {
let mut img = reader.decode().map_err(|e| format!("decode image: {e}"))?;
// Try to read EXIF orientation from JPEG files
if let Ok(data) = fs::read(path) {
if let Some(orientation) = read_exif_orientation(&data) {
img = apply_orientation(img, orientation);
}
if let Ok(data) = fs::read(path)
&& let Some(orientation) = read_exif_orientation(&data)
{
img = apply_orientation(img, orientation);
}
Ok(img)
@@ -180,7 +206,9 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
}
let is_le = data[tiff_start] == b'I' && data[tiff_start + 1] == b'I';
let read_u16 = |offset: usize| -> Option<u16> {
if offset + 2 > data.len() { return None; }
if offset + 2 > data.len() {
return None;
}
if is_le {
Some(u16::from_le_bytes([data[offset], data[offset + 1]]))
} else {
@@ -188,11 +216,23 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
}
};
let read_u32 = |offset: usize| -> Option<u32> {
if offset + 4 > data.len() { return None; }
if offset + 4 > data.len() {
return None;
}
if is_le {
Some(u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]))
Some(u32::from_le_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]))
} else {
Some(u32::from_be_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]))
Some(u32::from_be_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]))
}
};
@@ -202,7 +242,9 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
for i in 0..entry_count {
let entry_pos = ifd_pos + 2 + i * 12;
if entry_pos + 12 > data.len() { break; }
if entry_pos + 12 > data.len() {
break;
}
let tag = read_u16(entry_pos)?;
if tag == 0x0112 {
// Orientation tag
@@ -214,14 +256,14 @@ fn parse_tiff_orientation(data: &[u8], tiff_start: usize) -> Option<u16> {
fn apply_orientation(img: DynamicImage, orientation: u16) -> DynamicImage {
match orientation {
1 => img, // Normal
2 => img.fliph(), // Mirrored horizontal
3 => img.rotate180(), // Rotated 180
4 => img.flipv(), // Mirrored vertical
5 => img.rotate90().fliph(), // Mirrored horizontal + 270 CW
6 => img.rotate90(), // Rotated 90 CW
7 => img.rotate270().fliph(), // Mirrored horizontal + 90 CW
8 => img.rotate270(), // Rotated 270 CW
1 => img, // Normal
2 => img.fliph(), // Mirrored horizontal
3 => img.rotate180(), // Rotated 180
4 => img.flipv(), // Mirrored vertical
5 => img.rotate90().fliph(), // Mirrored horizontal + 270 CW
6 => img.rotate90(), // Rotated 90 CW
7 => img.rotate270().fliph(), // Mirrored horizontal + 90 CW
8 => img.rotate270(), // Rotated 270 CW
_ => img,
}
}

View File

@@ -1,4 +1,4 @@
use chrono::{DateTime, Utc, TimeZone};
use chrono::{DateTime, NaiveDate, TimeZone, Utc};
/// Convert Unix milliseconds to ISO 8601 string (e.g., `2005-11-13T12:00:00.000Z`).
pub fn unix_ms_to_iso(ms: i64) -> String {
@@ -35,6 +35,24 @@ pub fn now_unix_ms() -> i64 {
Utc::now().timestamp_millis()
}
/// Return the inclusive start and exclusive end of a calendar year or month.
pub fn calendar_range_unix_ms(year: i32, month: Option<u32>) -> Option<(i64, i64)> {
let start_month = month.unwrap_or(1);
let (end_year, end_month) = match month {
Some(12) | None => (year.checked_add(1)?, 1),
Some(month) => (year, month.checked_add(1)?),
};
let start = NaiveDate::from_ymd_opt(year, start_month, 1)?
.and_hms_opt(0, 0, 0)?
.and_utc()
.timestamp_millis();
let end = NaiveDate::from_ymd_opt(end_year, end_month, 1)?
.and_hms_opt(0, 0, 0)?
.and_utc()
.timestamp_millis();
Some((start, end))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -83,4 +101,16 @@ mod tests {
fn invalid_iso_returns_error() {
assert!(iso_to_unix_ms("not a date").is_err());
}
#[test]
fn calendar_range_rejects_invalid_month() {
assert_eq!(calendar_range_unix_ms(2026, Some(13)), None);
}
#[test]
fn calendar_range_covers_requested_month() {
let (start, end) = calendar_range_unix_ms(2026, Some(7)).unwrap();
assert_eq!(unix_ms_to_iso(start), "2026-07-01T00:00:00.000Z");
assert_eq!(unix_ms_to_iso(end), "2026-08-01T00:00:00.000Z");
}
}