chore: cleanups and refactorings for cleaner code
This commit is contained in:
@@ -64,7 +64,7 @@ pub fn fill_missing_translations(
|
||||
main_language: &str,
|
||||
blog_languages: &[String],
|
||||
offline_mode: bool,
|
||||
mut on_progress: impl FnMut(f32, &str),
|
||||
mut on_progress: impl FnMut(f32, &str) -> bool,
|
||||
) -> EngineResult<FillMissingTranslationsReport> {
|
||||
fill_missing_translations_with(
|
||||
conn,
|
||||
@@ -90,7 +90,7 @@ fn fill_missing_translations_with(
|
||||
blog_languages: &[String],
|
||||
post_translator: &mut dyn FnMut(&Post, &str) -> EngineResult<TranslationResult>,
|
||||
media_translator: &mut dyn FnMut(&Media, &str) -> EngineResult<MediaTranslationResult>,
|
||||
on_progress: &mut dyn FnMut(f32, &str),
|
||||
on_progress: &mut dyn FnMut(f32, &str) -> bool,
|
||||
) -> EngineResult<FillMissingTranslationsReport> {
|
||||
let configured = configured_languages(main_language, blog_languages);
|
||||
if configured.len() <= 1 {
|
||||
@@ -100,12 +100,17 @@ fn fill_missing_translations_with(
|
||||
});
|
||||
}
|
||||
let posts = qp::list_posts_by_project(conn, project_id)?;
|
||||
on_progress(0.0, "Scanning published posts");
|
||||
if !on_progress(0.0, "Scanning published posts") {
|
||||
return Err(EngineError::Validation("cancelled".to_string()));
|
||||
}
|
||||
let mut work = Vec::new();
|
||||
for post in posts
|
||||
.into_iter()
|
||||
.filter(|post| post.status == PostStatus::Published && !post.do_not_translate)
|
||||
{
|
||||
if !on_progress(0.0, "Scanning published posts") {
|
||||
return Err(EngineError::Validation("cancelled".to_string()));
|
||||
}
|
||||
for language in missing_languages(conn, &post, &configured)? {
|
||||
work.push((post.clone(), language));
|
||||
}
|
||||
@@ -119,10 +124,12 @@ fn fill_missing_translations_with(
|
||||
|
||||
let mut report = FillMissingTranslationsReport::default();
|
||||
for (index, (post, language)) in work.iter().enumerate() {
|
||||
on_progress(
|
||||
if !on_progress(
|
||||
0.15 + (index as f32 / work.len() as f32) * 0.85,
|
||||
&format!("{} → {language}", post.title),
|
||||
);
|
||||
) {
|
||||
return Err(EngineError::Validation("cancelled".to_string()));
|
||||
}
|
||||
match translate_one_post(
|
||||
conn,
|
||||
data_dir,
|
||||
@@ -144,7 +151,9 @@ fn fill_missing_translations_with(
|
||||
}
|
||||
}
|
||||
}
|
||||
on_progress(1.0, "Translation batch complete");
|
||||
if !on_progress(1.0, "Translation batch complete") {
|
||||
return Err(EngineError::Validation("cancelled".to_string()));
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
@@ -156,6 +165,7 @@ pub fn translate_missing_for_post(
|
||||
main_language: &str,
|
||||
blog_languages: &[String],
|
||||
offline_mode: bool,
|
||||
is_cancelled: impl Fn() -> bool,
|
||||
) -> EngineResult<FillMissingTranslationsReport> {
|
||||
let post = qp::get_post_by_id(conn, post_id)?;
|
||||
let configured = configured_languages(main_language, blog_languages);
|
||||
@@ -165,6 +175,9 @@ pub fn translate_missing_for_post(
|
||||
..Default::default()
|
||||
};
|
||||
for language in targets {
|
||||
if is_cancelled() {
|
||||
return Err(EngineError::Validation("cancelled".to_string()));
|
||||
}
|
||||
let result = translate_one_post(
|
||||
conn,
|
||||
data_dir,
|
||||
@@ -369,7 +382,7 @@ mod tests {
|
||||
})
|
||||
},
|
||||
&mut |_media, _language| unreachable!(),
|
||||
&mut |_, _| {},
|
||||
&mut |_, _| true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -433,9 +446,30 @@ mod tests {
|
||||
&["de".into()],
|
||||
&mut |_, _| panic!("translator must not run"),
|
||||
&mut |_, _| panic!("translator must not run"),
|
||||
&mut |_, _| {},
|
||||
&mut |_, _| true,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(report.nothing_to_do);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_stops_when_progress_callback_cancels() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
let result = fill_missing_translations_with(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"en",
|
||||
&["de".into()],
|
||||
&mut |_, _| panic!("translator must not run"),
|
||||
&mut |_, _| panic!("translator must not run"),
|
||||
&mut |_, _| false,
|
||||
);
|
||||
|
||||
assert!(matches!(result, Err(EngineError::Validation(message)) if message == "cancelled"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Shared context passed to engine operations.
|
||||
pub struct EngineContext<'a> {
|
||||
pub conn: &'a crate::db::DbConnection,
|
||||
pub project_id: String,
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn context_holds_references() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = EngineContext {
|
||||
conn: db.conn(),
|
||||
project_id: "p1".into(),
|
||||
data_dir: dir.path().to_path_buf(),
|
||||
};
|
||||
assert_eq!(ctx.project_id, "p1");
|
||||
assert!(ctx.data_dir.exists());
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,22 @@
|
||||
use std::fmt;
|
||||
|
||||
/// Errors produced by engine operations.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
Db(diesel::result::Error),
|
||||
DbConnection(diesel::ConnectionError),
|
||||
Io(std::io::Error),
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] diesel::result::Error),
|
||||
#[error("database connection error: {0}")]
|
||||
DbConnection(#[from] diesel::ConnectionError),
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("parse error: {0}")]
|
||||
Parse(String),
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("validation error: {0}")]
|
||||
Validation(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for EngineError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Db(e) => write!(f, "database error: {e}"),
|
||||
Self::DbConnection(e) => write!(f, "database connection error: {e}"),
|
||||
Self::Io(e) => write!(f, "I/O error: {e}"),
|
||||
Self::Parse(msg) => write!(f, "parse error: {msg}"),
|
||||
Self::NotFound(msg) => write!(f, "not found: {msg}"),
|
||||
Self::Conflict(msg) => write!(f, "conflict: {msg}"),
|
||||
Self::Validation(msg) => write!(f, "validation error: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EngineError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Db(e) => Some(e),
|
||||
Self::DbConnection(e) => Some(e),
|
||||
Self::Io(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<diesel::result::Error> for EngineError {
|
||||
fn from(e: diesel::result::Error) -> Self {
|
||||
Self::Db(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<diesel::ConnectionError> for EngineError {
|
||||
fn from(e: diesel::ConnectionError) -> Self {
|
||||
Self::DbConnection(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::db::DatabaseError> for EngineError {
|
||||
fn from(e: crate::db::DatabaseError) -> Self {
|
||||
match e {
|
||||
@@ -58,12 +26,6 @@ impl From<crate::db::DatabaseError> for EngineError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for EngineError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for EngineError {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
Self::Parse(e.to_string())
|
||||
@@ -83,39 +45,3 @@ impl From<serde_yaml::Error> for EngineError {
|
||||
}
|
||||
|
||||
pub type EngineResult<T> = Result<T, EngineError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_io_error() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
|
||||
let engine_err = EngineError::from(io_err);
|
||||
assert!(matches!(engine_err, EngineError::Io(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,75 +164,6 @@ pub fn remove_category(data_dir: &Path, category: &str) -> EngineResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── update helpers ──────────────────────────────────────────────────
|
||||
|
||||
/// Update the blog_languages list in project.json.
|
||||
/// Per metadata.allium UpdateProjectMetadata: writes ProjectJsonWritten.
|
||||
pub fn update_blog_languages(data_dir: &Path, languages: Vec<String>) -> EngineResult<()> {
|
||||
let mut meta = read_project_json(data_dir)?;
|
||||
meta.blog_languages = languages;
|
||||
write_project_json(data_dir, &meta)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update arbitrary fields of project metadata.
|
||||
/// Per metadata.allium UpdateProjectMetadata rule.
|
||||
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();
|
||||
}
|
||||
if let Some(desc) = changes.get("description") {
|
||||
meta.description = desc.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(url) = changes.get("publicUrl") {
|
||||
meta.public_url = url.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(lang) = changes.get("mainLanguage") {
|
||||
meta.main_language = lang.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(author) = changes.get("defaultAuthor") {
|
||||
meta.default_author = author.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(max) = changes.get("maxPostsPerPage").and_then(|v| v.as_i64()) {
|
||||
meta.max_posts_per_page = max as i32;
|
||||
}
|
||||
if let Some(concurrency) = changes.get("imageImportConcurrency") {
|
||||
meta.image_import_concurrency = concurrency
|
||||
.as_i64()
|
||||
.map(|value| value as i32)
|
||||
.or_else(|| {
|
||||
concurrency
|
||||
.as_str()
|
||||
.and_then(|value| value.parse::<i32>().ok())
|
||||
})
|
||||
.unwrap_or(4)
|
||||
.clamp(1, 8);
|
||||
}
|
||||
if let Some(cat) = changes.get("blogmarkCategory") {
|
||||
meta.blogmark_category = cat.as_str().map(|s| s.to_string());
|
||||
}
|
||||
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())
|
||||
{
|
||||
meta.semantic_similarity_enabled = enabled;
|
||||
}
|
||||
if let Some(langs) = changes.get("blogLanguages").and_then(|v| v.as_array()) {
|
||||
meta.blog_languages = langs
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
}
|
||||
meta.validate()
|
||||
.map_err(crate::engine::EngineError::Validation)?;
|
||||
write_project_json(data_dir, &meta)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── startup sync ────────────────────────────────────────────────────
|
||||
|
||||
/// Per metadata.allium StartupSync: loads metadata from filesystem, creating
|
||||
|
||||
@@ -5,7 +5,6 @@ use std::path::Path;
|
||||
use crate::db::DbConnection as Connection;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::db::from_row::{script_kind_to_str, template_kind_to_str};
|
||||
use crate::db::queries::media as qm;
|
||||
use crate::db::queries::media_translation as qmt;
|
||||
use crate::db::queries::post as qp;
|
||||
@@ -14,7 +13,7 @@ use crate::db::queries::project as qproject;
|
||||
use crate::db::queries::script as qs;
|
||||
use crate::db::queries::template as qtpl;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Media, MediaTranslation, Post, PostStatus, PostTranslation, Script, Template};
|
||||
use crate::model::{Media, MediaTranslation, Post, PostTranslation, Script, Template};
|
||||
use crate::util::frontmatter::{
|
||||
ScriptFrontmatter, TemplateFrontmatter, read_post_file, read_script_file, read_template_file,
|
||||
read_translation_file, write_post_file, write_script_file, write_template_file,
|
||||
@@ -371,7 +370,7 @@ fn rewrite_script_from_database(
|
||||
project_id: Some(script.project_id),
|
||||
slug: script.slug,
|
||||
title: script.title,
|
||||
kind: script_kind_to_str(&script.kind).to_owned(),
|
||||
kind: script.kind.as_str().to_owned(),
|
||||
entrypoint: script.entrypoint,
|
||||
enabled: script.enabled,
|
||||
version: script.version,
|
||||
@@ -399,7 +398,7 @@ fn rewrite_template_from_database(
|
||||
project_id: Some(template.project_id),
|
||||
slug: template.slug,
|
||||
title: template.title,
|
||||
kind: template_kind_to_str(&template.kind).to_owned(),
|
||||
kind: template.kind.as_str().to_owned(),
|
||||
enabled: template.enabled,
|
||||
version: template.version,
|
||||
created_at: template.created_at,
|
||||
@@ -427,14 +426,6 @@ fn tags_to_json(tags: &[String]) -> String {
|
||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
|
||||
fn status_to_str(s: &PostStatus) -> &'static str {
|
||||
match s {
|
||||
PostStatus::Draft => "draft",
|
||||
PostStatus::Published => "published",
|
||||
PostStatus::Archived => "archived",
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_field(fields: &mut Vec<DiffField>, name: &str, db_val: &str, file_val: &str) {
|
||||
if db_val != file_val {
|
||||
fields.push(DiffField {
|
||||
@@ -459,12 +450,7 @@ fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
|
||||
|
||||
compare_field(&mut fields, "title", &post.title, &fm.title);
|
||||
compare_field(&mut fields, "slug", &post.slug, &fm.slug);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"status",
|
||||
status_to_str(&post.status),
|
||||
&fm.status,
|
||||
);
|
||||
compare_field(&mut fields, "status", post.status.as_str(), &fm.status);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"tags",
|
||||
@@ -700,12 +686,7 @@ fn diff_template(data_dir: &Path, tpl: &Template) -> EngineResult<Option<EntityD
|
||||
let mut fields = Vec::new();
|
||||
|
||||
compare_field(&mut fields, "title", &tpl.title, &fm.title);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"kind",
|
||||
template_kind_to_str(&tpl.kind),
|
||||
&fm.kind,
|
||||
);
|
||||
compare_field(&mut fields, "kind", tpl.kind.as_str(), &fm.kind);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"enabled",
|
||||
@@ -743,12 +724,7 @@ fn diff_script(data_dir: &Path, script: &Script) -> EngineResult<Option<EntityDi
|
||||
let mut fields = Vec::new();
|
||||
|
||||
compare_field(&mut fields, "title", &script.title, &fm.title);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"kind",
|
||||
script_kind_to_str(&script.kind),
|
||||
&fm.kind,
|
||||
);
|
||||
compare_field(&mut fields, "kind", script.kind.as_str(), &fm.kind);
|
||||
compare_field(
|
||||
&mut fields,
|
||||
"entrypoint",
|
||||
|
||||
@@ -2,7 +2,6 @@ pub mod ai;
|
||||
pub mod auto_translation;
|
||||
pub mod blogmark;
|
||||
pub mod calendar;
|
||||
pub mod context;
|
||||
pub mod error;
|
||||
pub mod generation;
|
||||
pub mod media;
|
||||
@@ -27,5 +26,4 @@ pub mod validate_media;
|
||||
pub mod validate_site;
|
||||
pub mod validate_translations;
|
||||
|
||||
pub use context::EngineContext;
|
||||
pub use error::{EngineError, EngineResult};
|
||||
|
||||
@@ -1163,18 +1163,6 @@ pub fn post_insert_media(media_id: &str, is_image: bool, original_name: &str) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Get posts linked from a given post (outlinks).
|
||||
pub fn list_post_outlinks(conn: &Connection, post_id: &str) -> EngineResult<Vec<String>> {
|
||||
let links = ql::list_links_by_source(conn, post_id)?;
|
||||
Ok(links.into_iter().map(|l| l.target_post_id).collect())
|
||||
}
|
||||
|
||||
/// Get posts linking to a given post (backlinks).
|
||||
pub fn list_post_backlinks(conn: &Connection, post_id: &str) -> EngineResult<Vec<String>> {
|
||||
let links = ql::list_links_by_target(conn, post_id)?;
|
||||
Ok(links.into_iter().map(|l| l.source_post_id).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -6,7 +6,7 @@ use walkdir::WalkDir;
|
||||
|
||||
use crate::db::queries::script as qs;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Script, ScriptKind, ScriptStatus};
|
||||
use crate::model::{Script, ScriptStatus};
|
||||
use crate::util::frontmatter::read_script_file;
|
||||
use crate::util::now_unix_ms;
|
||||
|
||||
@@ -18,16 +18,6 @@ pub struct ScriptRebuildReport {
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parse a script kind string from frontmatter into a `ScriptKind`.
|
||||
fn parse_script_kind(s: &str) -> Result<ScriptKind, String> {
|
||||
match s {
|
||||
"macro" => Ok(ScriptKind::Macro),
|
||||
"utility" => Ok(ScriptKind::Utility),
|
||||
"transform" => Ok(ScriptKind::Transform),
|
||||
other => Err(format!("unknown script kind: '{other}'")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild scripts from the filesystem into the database.
|
||||
///
|
||||
/// Walks the `scripts/` directory for `*.lua` files, parses each
|
||||
@@ -92,7 +82,7 @@ pub(crate) fn rebuild_single_script(
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let kind = parse_script_kind(&fm.kind).map_err(EngineError::Parse)?;
|
||||
let kind = fm.kind.parse().map_err(EngineError::Parse)?;
|
||||
let now = now_unix_ms();
|
||||
|
||||
// File exists on disk -> Published; content is None in DB
|
||||
@@ -142,6 +132,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::model::ScriptKind;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Unique task identifier.
|
||||
@@ -15,14 +15,6 @@ pub enum TaskStatus {
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Progress update for a task.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskProgress {
|
||||
pub task_id: TaskId,
|
||||
pub message: String,
|
||||
pub percent: Option<f32>,
|
||||
}
|
||||
|
||||
/// Immutable task state exposed to UI consumers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskSnapshot {
|
||||
@@ -57,6 +49,7 @@ pub struct TaskManager {
|
||||
max_concurrent: usize,
|
||||
next_id: Mutex<TaskId>,
|
||||
tasks: Mutex<Vec<TaskEntry>>,
|
||||
state_changed: Condvar,
|
||||
}
|
||||
|
||||
impl TaskManager {
|
||||
@@ -66,6 +59,7 @@ impl TaskManager {
|
||||
max_concurrent,
|
||||
next_id: Mutex::new(1),
|
||||
tasks: Mutex::new(Vec::new()),
|
||||
state_changed: Condvar::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,24 +111,20 @@ impl TaskManager {
|
||||
id
|
||||
}
|
||||
|
||||
/// Try to start a queued task. Returns true if the task was moved to
|
||||
/// Running, false if concurrency is at capacity or the task is not Queued.
|
||||
pub fn try_start(&self, task_id: TaskId) -> bool {
|
||||
/// Block a worker until its task may run. Returns false if cancelled.
|
||||
pub fn wait_until_runnable(&self, task_id: TaskId) -> bool {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
let running = tasks
|
||||
.iter()
|
||||
.filter(|t| t.status == TaskStatus::Running)
|
||||
.count();
|
||||
if running >= self.max_concurrent {
|
||||
return false;
|
||||
loop {
|
||||
match tasks
|
||||
.iter()
|
||||
.find(|task| task.id == task_id)
|
||||
.map(|task| &task.status)
|
||||
{
|
||||
Some(TaskStatus::Running) => return true,
|
||||
Some(TaskStatus::Pending) => tasks = self.state_changed.wait(tasks).unwrap(),
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||
&& entry.status == TaskStatus::Pending
|
||||
{
|
||||
entry.status = TaskStatus::Running;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Mark a task as completed.
|
||||
@@ -148,6 +138,7 @@ impl TaskManager {
|
||||
entry.finished_at = Some(Instant::now());
|
||||
}
|
||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||
self.state_changed.notify_all();
|
||||
}
|
||||
|
||||
/// Mark a task as failed with an error message.
|
||||
@@ -161,6 +152,7 @@ impl TaskManager {
|
||||
entry.finished_at = Some(Instant::now());
|
||||
}
|
||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||
self.state_changed.notify_all();
|
||||
}
|
||||
|
||||
/// Cancel a task by setting its cancel flag and status.
|
||||
@@ -174,6 +166,7 @@ impl TaskManager {
|
||||
entry.finished_at = Some(Instant::now());
|
||||
}
|
||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||
self.state_changed.notify_all();
|
||||
}
|
||||
|
||||
/// Check whether a task has been cancelled.
|
||||
@@ -223,24 +216,6 @@ impl TaskManager {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -269,15 +244,6 @@ impl TaskManager {
|
||||
.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())
|
||||
}
|
||||
|
||||
/// Return a snapshot of all tasks for UI display.
|
||||
pub fn snapshots(&self) -> Vec<TaskSnapshot> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
@@ -331,35 +297,6 @@ 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 {
|
||||
interval_ms: u64,
|
||||
last_report: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl ProgressThrottle {
|
||||
/// Create a throttle with the given interval in milliseconds.
|
||||
pub fn new(interval_ms: u64) -> Self {
|
||||
Self {
|
||||
interval_ms,
|
||||
last_report: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if enough time has elapsed since the last report.
|
||||
pub fn should_report(&self) -> bool {
|
||||
let mut last = self.last_report.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
match *last {
|
||||
Some(prev) if now.duration_since(prev).as_millis() < self.interval_ms as u128 => false,
|
||||
_ => {
|
||||
*last = Some(now);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -390,11 +327,9 @@ mod tests {
|
||||
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
|
||||
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
|
||||
assert_eq!(mgr.next_queued(), Some(c));
|
||||
assert_eq!(mgr.status(c), Some(TaskStatus::Pending));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -481,6 +416,35 @@ mod tests {
|
||||
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_task_waits_for_a_slot() {
|
||||
let mgr = std::sync::Arc::new(TaskManager::new(1));
|
||||
let running = mgr.submit("running");
|
||||
let queued = mgr.submit("queued");
|
||||
let waiter = {
|
||||
let mgr = mgr.clone();
|
||||
std::thread::spawn(move || mgr.wait_until_runnable(queued))
|
||||
};
|
||||
|
||||
assert!(!waiter.is_finished());
|
||||
mgr.complete(running);
|
||||
assert!(waiter.join().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_queued_task_stops_its_waiter() {
|
||||
let mgr = std::sync::Arc::new(TaskManager::new(1));
|
||||
let _running = mgr.submit("running");
|
||||
let queued = mgr.submit("queued");
|
||||
let waiter = {
|
||||
let mgr = mgr.clone();
|
||||
std::thread::spawn(move || mgr.wait_until_runnable(queued))
|
||||
};
|
||||
|
||||
mgr.cancel(queued);
|
||||
assert!(!waiter.join().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_precondition_ignores_completed() {
|
||||
let mgr = TaskManager::default();
|
||||
@@ -496,19 +460,6 @@ mod tests {
|
||||
let id = mgr.submit("upload");
|
||||
mgr.report_progress(id, Some(0.5), Some("halfway".into()));
|
||||
assert_eq!(mgr.progress(id), Some(0.5));
|
||||
assert_eq!(mgr.message(id), Some("halfway".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_throttle_initial_reports() {
|
||||
let throttle = ProgressThrottle::new(PROGRESS_THROTTLE_MS);
|
||||
assert!(throttle.should_report());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_throttle_suppresses_rapid() {
|
||||
let throttle = ProgressThrottle::new(PROGRESS_THROTTLE_MS);
|
||||
assert!(throttle.should_report());
|
||||
assert!(!throttle.should_report());
|
||||
assert_eq!(mgr.snapshots()[0].message.as_deref(), Some("halfway"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use walkdir::WalkDir;
|
||||
|
||||
use crate::db::queries::template as qt;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Template, TemplateKind, TemplateStatus};
|
||||
use crate::model::{Template, TemplateStatus};
|
||||
use crate::util::frontmatter::read_template_file;
|
||||
use crate::util::now_unix_ms;
|
||||
|
||||
@@ -18,17 +18,6 @@ pub struct TemplateRebuildReport {
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parse a template kind string from frontmatter into a `TemplateKind`.
|
||||
fn parse_template_kind(s: &str) -> Result<TemplateKind, String> {
|
||||
match s {
|
||||
"post" => Ok(TemplateKind::Post),
|
||||
"list" => Ok(TemplateKind::List),
|
||||
"not_found" | "notFound" | "not-found" => Ok(TemplateKind::NotFound),
|
||||
"partial" => Ok(TemplateKind::Partial),
|
||||
other => Err(format!("unknown template kind: '{other}'")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild templates from the filesystem into the database.
|
||||
///
|
||||
/// Walks the `templates/` directory for `*.liquid` files, parses each via
|
||||
@@ -93,7 +82,7 @@ pub(crate) fn rebuild_single_template(
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let kind = parse_template_kind(&fm.kind).map_err(EngineError::Parse)?;
|
||||
let kind = fm.kind.parse().map_err(EngineError::Parse)?;
|
||||
let now = now_unix_ms();
|
||||
|
||||
// File exists on disk -> Published; content is None in DB
|
||||
@@ -141,6 +130,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::model::TemplateKind;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
|
||||
@@ -55,25 +55,7 @@ pub struct TranslationValidationReport {
|
||||
/// Per-item progress callback: (current_item, total_items, item_description).
|
||||
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
|
||||
|
||||
/// Validate all translations in a project against consistency rules.
|
||||
pub fn validate_translations(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
blog_languages: &[String],
|
||||
main_language: &str,
|
||||
) -> EngineResult<TranslationValidationReport> {
|
||||
validate_translations_with_progress(
|
||||
conn,
|
||||
data_dir,
|
||||
project_id,
|
||||
blog_languages,
|
||||
main_language,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Like `validate_translations` but with optional per-item progress.
|
||||
/// Validate all translations with optional per-item progress.
|
||||
pub fn validate_translations_with_progress(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
|
||||
Reference in New Issue
Block a user