fix: wired engines that exist to UI

This commit is contained in:
2026-04-04 21:03:42 +02:00
parent 1e6de6ac1c
commit 0cd3a00abf
10 changed files with 772 additions and 16 deletions

View File

@@ -0,0 +1,86 @@
//! Calendar JSON generation — counts posts by year, month, day.
use std::collections::BTreeMap;
use std::path::Path;
use rusqlite::Connection;
use crate::db::queries::post as post_q;
use crate::engine::EngineResult;
use crate::model::PostStatus;
use crate::util::{atomic_write, timestamp};
/// Generate `html/calendar.json` from published posts.
///
/// The JSON has three top-level objects: `years`, `months`, `days`,
/// each mapping a date key to a count of posts.
pub fn regenerate_calendar(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<()> {
let posts = post_q::list_posts_by_project(conn, project_id)?;
let mut years: BTreeMap<String, u32> = BTreeMap::new();
let mut months: BTreeMap<String, u32> = BTreeMap::new();
let mut days: BTreeMap<String, u32> = BTreeMap::new();
for post in &posts {
if post.status != PostStatus::Published {
continue;
}
let ts = post.created_at;
let (y, m, d) = timestamp::year_month_day_from_unix_ms(ts);
let year_key = y.clone();
let month_key = format!("{y}-{m}");
let day_key = format!("{y}-{m}-{d}");
*years.entry(year_key).or_insert(0) += 1;
*months.entry(month_key).or_insert(0) += 1;
*days.entry(day_key).or_insert(0) += 1;
}
let calendar = serde_json::json!({
"years": years,
"months": months,
"days": days,
});
let html_dir = data_dir.join("html");
std::fs::create_dir_all(&html_dir)?;
let json_str = serde_json::to_string_pretty(&calendar)?;
atomic_write::atomic_write(&html_dir.join("calendar.json"), json_str.as_bytes())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::engine;
#[test]
fn calendar_empty_project() {
let db = Database::open_in_memory().unwrap();
let _ = db.migrate();
let tmp = tempfile::tempdir().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();
assert!(data["years"].as_object().unwrap().is_empty());
}
}

View File

@@ -11,6 +11,9 @@ pub mod script_rebuild;
pub mod task;
pub mod metadata_diff;
pub mod rebuild;
pub mod search;
pub mod calendar;
pub mod validate_translations;
pub use error::{EngineError, EngineResult};
pub use context::EngineContext;

View File

@@ -0,0 +1,161 @@
//! Full-text search reindexing engine functions.
use rusqlite::Connection;
use crate::db::fts;
use crate::db::queries::{media as media_q, media_translation, post as post_q, post_translation};
use crate::engine::EngineResult;
/// Result of a full reindex operation.
pub struct ReindexReport {
pub posts_indexed: usize,
pub media_indexed: usize,
}
/// Drop and rebuild the entire FTS index for all posts and media in a project.
pub fn reindex_all(
conn: &Connection,
project_id: &str,
main_language: &str,
) -> EngineResult<ReindexReport> {
// Wipe existing FTS content
conn.execute("DELETE FROM posts_fts", [])?;
conn.execute("DELETE FROM media_fts", [])?;
// Reindex all posts
let posts = post_q::list_posts_by_project(conn, project_id)?;
let mut posts_indexed = 0;
for post in &posts {
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
let trans_pairs: Vec<(String, String)> = translations
.iter()
.map(|t| {
let text = [
t.title.as_str(),
t.excerpt.as_deref().unwrap_or(""),
t.content.as_deref().unwrap_or(""),
]
.join(" ");
(text, t.language.clone())
})
.collect();
let language = post.language.as_deref().unwrap_or(main_language);
fts::index_post(
conn,
&post.id,
&post.title,
post.excerpt.as_deref(),
post.content.as_deref(),
&post.tags,
&post.categories,
&trans_pairs,
language,
)?;
posts_indexed += 1;
}
// Reindex all media
let media_items = media_q::list_media_by_project(conn, project_id)?;
let mut media_indexed = 0;
for m in &media_items {
let translations = media_translation::list_media_translations_by_media(conn, &m.id)?;
let trans_pairs: Vec<(String, String)> = translations
.iter()
.map(|t| {
let text = [
t.title.as_deref().unwrap_or(""),
t.alt.as_deref().unwrap_or(""),
t.caption.as_deref().unwrap_or(""),
]
.join(" ");
(text, t.language.clone())
})
.collect();
let language = m.language.as_deref().unwrap_or(main_language);
fts::index_media(
conn,
&m.id,
m.title.as_deref(),
m.alt.as_deref(),
m.caption.as_deref(),
&m.original_name,
&m.tags,
&trans_pairs,
language,
)?;
media_indexed += 1;
}
Ok(ReindexReport {
posts_indexed,
media_indexed,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::db::fts::ensure_fts_tables;
use crate::engine;
fn setup() -> (Database, String) {
let db = Database::open_in_memory().unwrap();
let _ = db.migrate();
ensure_fts_tables(db.conn()).unwrap();
let tmp = tempfile::tempdir().unwrap();
let project = engine::project::create_project(
db.conn(),
"Test Project",
Some(tmp.path().to_str().unwrap()),
)
.unwrap();
(db, project.id)
}
#[test]
fn reindex_empty_project() {
let (db, project_id) = setup();
let report = reindex_all(db.conn(), &project_id, "en").unwrap();
assert_eq!(report.posts_indexed, 0);
assert_eq!(report.media_indexed, 0);
}
#[test]
fn reindex_with_posts() {
let (db, project_id) = setup();
let tmp = tempfile::tempdir().unwrap();
engine::post::create_post(
db.conn(),
tmp.path(),
&project_id,
"Test Post",
Some("Body content"),
vec!["tag1".into()],
vec![],
None,
None,
None,
)
.unwrap();
let report = reindex_all(db.conn(), &project_id, "en").unwrap();
assert_eq!(report.posts_indexed, 1);
assert_eq!(report.media_indexed, 0);
// Verify searchable
let results = crate::db::fts::search_posts(db.conn(), "test", "en").unwrap();
assert_eq!(results.len(), 1);
}
}

View File

@@ -0,0 +1,196 @@
//! Translation validation — checks DB rows and filesystem files for issues.
use std::path::Path;
use rusqlite::Connection;
use crate::db::queries::{post as post_q, post_translation};
use crate::engine::EngineResult;
use crate::model::PostStatus;
/// Normalize a language code for comparison (lowercase, strip region).
fn norm_lang(code: &str) -> String {
code.split(['-', '_']).next().unwrap_or("").to_lowercase()
}
/// Check if a file stem looks like a translation (e.g. "slug.de").
fn is_translation_stem(stem: &str) -> bool {
if let Some(dot_pos) = stem.rfind('.') {
let suffix = &stem[dot_pos + 1..];
suffix.len() == 2 && suffix.chars().all(|c| c.is_ascii_lowercase())
} else {
false
}
}
/// A single validation issue.
#[derive(Debug, Clone)]
pub struct TranslationIssue {
pub post_id: String,
pub translation_id: Option<String>,
pub file_path: Option<String>,
pub language: String,
pub kind: TranslationIssueKind,
}
#[derive(Debug, Clone)]
pub enum TranslationIssueKind {
MissingSourcePost,
SameLanguageAsCanonical,
DoNotTranslateHasTranslations,
ContentInDatabase,
}
/// Result of translation validation.
#[derive(Debug)]
pub struct TranslationValidationReport {
pub db_issues: Vec<TranslationIssue>,
pub fs_issues: Vec<TranslationIssue>,
pub checked_db_rows: usize,
pub checked_fs_files: usize,
}
/// Validate all translations in a project against consistency rules.
pub fn validate_translations(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<TranslationValidationReport> {
let posts = post_q::list_posts_by_project(conn, project_id)?;
let post_map: std::collections::HashMap<String, &crate::model::Post> =
posts.iter().map(|p| (p.id.clone(), p)).collect();
// Phase 1: database validation
let mut db_issues = Vec::new();
let mut checked_db_rows = 0usize;
for post in &posts {
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
for t in &translations {
checked_db_rows += 1;
// Check: translation language != canonical language
let post_lang = post.language.as_deref().unwrap_or("en");
if norm_lang(post_lang) == norm_lang(&t.language) {
db_issues.push(TranslationIssue {
post_id: post.id.clone(),
translation_id: Some(t.id.clone()),
file_path: Some(t.file_path.clone()),
language: t.language.clone(),
kind: TranslationIssueKind::SameLanguageAsCanonical,
});
}
// Check: do_not_translate
if post.do_not_translate {
db_issues.push(TranslationIssue {
post_id: post.id.clone(),
translation_id: Some(t.id.clone()),
file_path: Some(t.file_path.clone()),
language: t.language.clone(),
kind: TranslationIssueKind::DoNotTranslateHasTranslations,
});
}
// Check: published translation should not have content in DB
if t.status == PostStatus::Published && t.content.is_some() {
db_issues.push(TranslationIssue {
post_id: post.id.clone(),
translation_id: Some(t.id.clone()),
file_path: Some(t.file_path.clone()),
language: t.language.clone(),
kind: TranslationIssueKind::ContentInDatabase,
});
}
}
}
// Phase 2: filesystem validation
let mut fs_issues = Vec::new();
let mut checked_fs_files = 0usize;
let posts_dir = data_dir.join("posts");
if posts_dir.exists() {
for entry in walkdir::WalkDir::new(&posts_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let path = entry.path();
let Some(ext) = path.extension() else { continue };
if ext != "md" { continue; }
// Check if this is a translation file (has language code before .md)
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if !is_translation_stem(stem) {
continue;
}
checked_fs_files += 1;
// Parse frontmatter to check translation_for
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
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 {
continue;
};
let translation_for = &fm.translation_for;
let language = &fm.language;
if translation_for.is_empty() {
continue;
}
// Check source post exists
if !post_map.contains_key(translation_for.as_str()) {
fs_issues.push(TranslationIssue {
post_id: translation_for.clone(),
translation_id: None,
file_path: Some(path.to_string_lossy().to_string()),
language: language.clone(),
kind: TranslationIssueKind::MissingSourcePost,
});
} else if let Some(source) = post_map.get(translation_for.as_str()) {
// Check same language
let post_lang = source.language.as_deref().unwrap_or("en");
if norm_lang(post_lang) == norm_lang(language) {
fs_issues.push(TranslationIssue {
post_id: translation_for.clone(),
translation_id: None,
file_path: Some(path.to_string_lossy().to_string()),
language: language.clone(),
kind: TranslationIssueKind::SameLanguageAsCanonical,
});
}
// Check do_not_translate
if source.do_not_translate {
fs_issues.push(TranslationIssue {
post_id: translation_for.clone(),
translation_id: None,
file_path: Some(path.to_string_lossy().to_string()),
language: language.clone(),
kind: TranslationIssueKind::DoNotTranslateHasTranslations,
});
}
}
}
}
Ok(TranslationValidationReport {
db_issues,
fs_issues,
checked_db_rows,
checked_fs_files,
})
}