diff --git a/README.md b/README.md index ac6af48..0fc243e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The project is under active development. Core blogging workflows are broadly ava ## Available Features -- Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars with locale-aware post dates, calendar months, and relative-dated entity lists, row deletion, dialogs, tasks, embedded Wry previews, a live Pico CSS theme editor, and per-project restart restoration of the active activity, shell visibility, and open editor tabs. +- Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars with locale-aware post dates, calendar months, and relative-dated entity lists, row deletion, dialogs, embedded Wry previews, a live Pico CSS theme editor, and per-project restart restoration of the active activity, shell visibility, and open editor tabs. Its shared task UI keeps queued work off blocking workers, shows every active local or remote task plus recent history, and supports cooperative cancellation without oversubscribing background work. - Post and translation authoring with change-aware draft/published/archive lifecycle, file-backed change discard, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, live link/backlink graphs, media, and batch gallery-image import. - Media import including HEIC/HEIF decoding, q80 WebP thumbnails (plus q85 AI JPEGs), metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors. - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. diff --git a/crates/bds-cli/src/lib.rs b/crates/bds-cli/src/lib.rs index b966af3..68052b0 100644 --- a/crates/bds-cli/src/lib.rs +++ b/crates/bds-cli/src/lib.rs @@ -355,7 +355,7 @@ fn rebuild(db: &Database, incremental: bool) -> Result { let progress = Arc::new(Mutex::new(Vec::new())); let progress_sink = Arc::clone(&progress); - let (report, thumbnails) = cli_sync::run_cli_mutation(db.conn(), || { + let report = cli_sync::run_cli_mutation(db.conn(), || { let report = engine::rebuild::rebuild_from_filesystem_with_progress( db.conn(), &data_dir, @@ -369,12 +369,11 @@ fn rebuild(db: &Database, incremental: bool) -> Result { if progress.last() != Some(&line) { progress.push(line); } + true })), )?; - let thumbnails = - engine::media::regenerate_missing_thumbnails(db.conn(), &data_dir, &project.id)?; emit_bulk(&project.id); - Ok((report, thumbnails)) + Ok(report) })?; let mut result = output( "Rebuild complete", @@ -389,8 +388,8 @@ fn rebuild(db: &Database, incremental: bool) -> Result { "templates_updated": report.templates_updated, "scripts_created": report.scripts_created, "scripts_updated": report.scripts_updated, - "thumbnails_generated": thumbnails.thumbnails_generated, - "thumbnail_media_failed": thumbnails.media_failed, + "thumbnails_generated": report.thumbnails_generated, + "thumbnail_media_failed": report.thumbnail_media_failed, }), ); result.progress = progress diff --git a/crates/bds-core/src/engine/calendar.rs b/crates/bds-core/src/engine/calendar.rs index 315a555..c4b0f53 100644 --- a/crates/bds-core/src/engine/calendar.rs +++ b/crates/bds-core/src/engine/calendar.rs @@ -6,7 +6,7 @@ use std::path::Path; use crate::db::DbConnection as Connection; use crate::db::queries::post as post_q; -use crate::engine::EngineResult; +use crate::engine::{EngineError, EngineResult}; use crate::model::PostStatus; use crate::util::{atomic_write, timestamp}; @@ -18,6 +18,15 @@ pub fn regenerate_calendar( conn: &Connection, data_dir: &Path, project_id: &str, +) -> EngineResult<()> { + regenerate_calendar_with_progress(conn, data_dir, project_id, |_, _, _| true) +} + +pub fn regenerate_calendar_with_progress( + conn: &Connection, + data_dir: &Path, + project_id: &str, + mut on_item: impl FnMut(usize, usize, &str) -> bool, ) -> EngineResult<()> { let posts = post_q::list_posts_by_project(conn, project_id)?; @@ -25,7 +34,11 @@ pub fn regenerate_calendar( let mut months: BTreeMap = BTreeMap::new(); let mut days: BTreeMap = BTreeMap::new(); - for post in &posts { + let total = posts.len() + 1; + for (index, post) in posts.iter().enumerate() { + if !on_item(index + 1, total, &post.title) { + return Err(EngineError::Cancelled); + } if post.status != PostStatus::Published { continue; } @@ -41,6 +54,10 @@ pub fn regenerate_calendar( *days.entry(day_key).or_insert(0) += 1; } + if !on_item(total, total, "calendar.json") { + return Err(EngineError::Cancelled); + } + let calendar = serde_json::json!({ "years": years, "months": months, @@ -82,4 +99,20 @@ mod tests { serde_json::from_str(&std::fs::read_to_string(&cal_path).unwrap()).unwrap(); assert!(data["years"].as_object().unwrap().is_empty()); } + + #[test] + fn cancellation_before_write_leaves_no_calendar() { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let project = + engine::project::create_project(db.conn(), "Test", Some(dir.path().to_str().unwrap())) + .unwrap(); + + let result = + regenerate_calendar_with_progress(db.conn(), dir.path(), &project.id, |_, _, _| false); + + assert!(matches!(result, Err(EngineError::Cancelled))); + assert!(!dir.path().join("html/calendar.json").exists()); + } } diff --git a/crates/bds-core/src/engine/error.rs b/crates/bds-core/src/engine/error.rs index 2cb88eb..20548d2 100644 --- a/crates/bds-core/src/engine/error.rs +++ b/crates/bds-core/src/engine/error.rs @@ -15,6 +15,8 @@ pub enum EngineError { Conflict(String), #[error("validation error: {0}")] Validation(String), + #[error("operation cancelled")] + Cancelled, } impl From for EngineError { diff --git a/crates/bds-core/src/engine/generation.rs b/crates/bds-core/src/engine/generation.rs index 5b8a17f..4c035b1 100644 --- a/crates/bds-core/src/engine/generation.rs +++ b/crates/bds-core/src/engine/generation.rs @@ -63,6 +63,15 @@ pub struct PreparedSiteGeneration { generated_hashes: Arc>, } +impl std::fmt::Debug for PreparedSiteGeneration { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PreparedSiteGeneration") + .field("source_count", &self.sources.len()) + .finish_non_exhaustive() + } +} + pub fn prepare_site_generation( conn: &Connection, data_dir: &Path, @@ -91,6 +100,34 @@ pub fn prepare_site_generation( }) } +pub fn prepared_section_page_count( + prepared: &PreparedSiteGeneration, + validation: Option<&SiteValidationReport>, + section: GenerationSection, +) -> usize { + let requested = validation.map(|validation| { + validation + .missing_pages + .iter() + .chain(validation.stale_pages.iter()) + .cloned() + .collect::>() + }); + let fallback = validation.is_some_and(|validation| { + validation + .missing_pages + .iter() + .chain(validation.extra_pages.iter()) + .chain(validation.stale_pages.iter()) + .any(|path| classify_generated_path(path, &prepared.metadata).is_none()) + }); + crate::render::count_site_render_pages_from_context( + &prepared.render, + section, + (!fallback).then_some(requested.as_ref()).flatten(), + ) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum GenerationSection { Core, diff --git a/crates/bds-core/src/engine/media.rs b/crates/bds-core/src/engine/media.rs index fc49704..d4425e5 100644 --- a/crates/bds-core/src/engine/media.rs +++ b/crates/bds-core/src/engine/media.rs @@ -53,9 +53,18 @@ pub fn rebuild_media_links( conn: &Connection, data_dir: &Path, project_id: &str, +) -> EngineResult { + rebuild_media_links_with_progress(conn, data_dir, project_id, None) +} + +pub fn rebuild_media_links_with_progress( + conn: &Connection, + data_dir: &Path, + project_id: &str, + on_item: Option, ) -> EngineResult { conn.begin_savepoint()?; - match rebuild_media_links_inner(conn, data_dir, project_id) { + match rebuild_media_links_inner(conn, data_dir, project_id, on_item.as_ref()) { Ok(report) => { conn.release_savepoint()?; Ok(report) @@ -71,9 +80,16 @@ fn rebuild_media_links_inner( conn: &Connection, data_dir: &Path, project_id: &str, + on_item: Option<&ItemProgressFn>, ) -> EngineResult { let mut report = MediaLinkRebuildReport::default(); - for item in qm::list_media_by_project(conn, project_id)? { + let items = qm::list_media_by_project(conn, project_id)?; + for (index, item) in items.iter().enumerate() { + if let Some(callback) = on_item + && !callback(index + 1, items.len(), &item.original_name) + { + return Err(EngineError::Cancelled); + } let sidecar = read_sidecar(&fs::read_to_string(data_dir.join(&item.sidecar_path))?) .map_err(EngineError::Parse)?; for link in qpm::list_post_media_by_media(conn, &item.id)? { @@ -110,9 +126,22 @@ pub fn regenerate_missing_thumbnails( conn: &Connection, data_dir: &Path, project_id: &str, +) -> EngineResult { + regenerate_missing_thumbnails_with_progress(conn, data_dir, project_id, |_, _, _| true) +} + +pub fn regenerate_missing_thumbnails_with_progress( + conn: &Connection, + data_dir: &Path, + project_id: &str, + mut on_item: impl FnMut(usize, usize, &str) -> bool, ) -> EngineResult { let mut report = ThumbnailRepairReport::default(); - for item in qm::list_media_by_project(conn, project_id)? { + let items = qm::list_media_by_project(conn, project_id)?; + for (index, item) in items.iter().enumerate() { + if !on_item(index + 1, items.len(), &item.original_name) { + return Err(EngineError::Cancelled); + } if !item.mime_type.starts_with("image/") || item.mime_type.contains("svg") { continue; } @@ -653,7 +682,7 @@ pub fn rebuild_media_from_filesystem( } /// Per-item progress callback: (current_item, total_items, item_description). -pub type ItemProgressFn = Box; +pub type ItemProgressFn = Box bool + Send>; /// Like `rebuild_media_from_filesystem` but with optional per-item progress. pub fn rebuild_media_from_filesystem_with_progress( @@ -699,7 +728,9 @@ pub fn rebuild_media_from_filesystem_with_progress( for (i, path) in canonical_sidecars.iter().enumerate() { if let Some(ref cb) = on_item { let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?"); - cb(i + 1, total, name); + if !cb(i + 1, total, name) { + return Err(EngineError::Cancelled); + } } match rebuild_canonical_media(conn, data_dir, project_id, path) { Ok(created) => { @@ -720,7 +751,9 @@ pub fn rebuild_media_from_filesystem_with_progress( for (i, path) in translation_sidecars.iter().enumerate() { if let Some(ref cb) = on_item { let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?"); - cb(offset + i + 1, total, name); + if !cb(offset + i + 1, total, name) { + return Err(EngineError::Cancelled); + } } match rebuild_translation_sidecar(conn, data_dir, project_id, path) { Ok(created) => { diff --git a/crates/bds-core/src/engine/metadata_diff.rs b/crates/bds-core/src/engine/metadata_diff.rs index 2a06939..031552d 100644 --- a/crates/bds-core/src/engine/metadata_diff.rs +++ b/crates/bds-core/src/engine/metadata_diff.rs @@ -70,8 +70,27 @@ pub fn compute_metadata_diff( data_dir: &Path, project_id: &str, ) -> EngineResult { + compute_metadata_diff_with_progress(conn, data_dir, project_id, |_, _| true) +} + +pub fn compute_metadata_diff_with_progress( + conn: &Connection, + data_dir: &Path, + project_id: &str, + mut on_progress: impl FnMut(usize, usize) -> bool, +) -> EngineResult { + const PHASES: usize = 8; + let mut phase = 0; + let mut next_phase = || -> EngineResult<()> { + if !on_progress(phase, PHASES) { + return Err(crate::engine::EngineError::Cancelled); + } + phase += 1; + Ok(()) + }; let mut report = DiffReport::default(); + next_phase()?; if let Ok(project) = qproject::get_project_by_id(conn, project_id) { match diff_project(conn, data_dir, &project) { Ok(Some(diff)) => report.diffs.push(diff), @@ -94,6 +113,7 @@ pub fn compute_metadata_diff( } // 1. Diff posts + next_phase()?; let posts = qp::list_posts_by_project(conn, project_id)?; for post in &posts { if post.file_path.is_empty() { @@ -107,6 +127,7 @@ pub fn compute_metadata_diff( } // 2. Diff translations + next_phase()?; for post in &posts { let translations = qt::list_post_translations_by_post(conn, &post.id)?; for t in &translations { @@ -122,6 +143,7 @@ pub fn compute_metadata_diff( } // 3. Diff media + next_phase()?; let media_items = qm::list_media_by_project(conn, project_id)?; for m in &media_items { if m.sidecar_path.is_empty() { @@ -146,6 +168,7 @@ pub fn compute_metadata_diff( } // 4. Diff templates + next_phase()?; let templates = qtpl::list_templates_by_project(conn, project_id)?; for t in &templates { if t.file_path.is_empty() { @@ -159,6 +182,7 @@ pub fn compute_metadata_diff( } // 5. Diff scripts + next_phase()?; let scripts = qs::list_scripts_by_project(conn, project_id)?; for s in &scripts { if s.file_path.is_empty() { @@ -172,6 +196,7 @@ pub fn compute_metadata_diff( } // 6. Detect orphans + next_phase()?; if crate::engine::meta::read_project_json(data_dir) .is_ok_and(|metadata| metadata.semantic_similarity_enabled) { @@ -215,9 +240,14 @@ pub fn compute_metadata_diff( } // 7. Detect orphans + next_phase()?; let orphans = detect_orphan_files(conn, data_dir, project_id)?; report.orphans = orphans; + if !on_progress(PHASES, PHASES) { + return Err(crate::engine::EngineError::Cancelled); + } + Ok(report) } diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index 3464d71..6efce31 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -716,7 +716,7 @@ pub fn rebuild_posts_from_filesystem( } /// Per-item progress callback: (current_item, total_items, item_description). -pub type ItemProgressFn = Box; +pub type ItemProgressFn = Box bool + Send>; /// Like `rebuild_posts_from_filesystem` but with optional per-item progress. pub fn rebuild_posts_from_filesystem_with_progress( @@ -760,7 +760,9 @@ pub fn rebuild_posts_from_filesystem_with_progress( for (i, path) in canonical_files.iter().enumerate() { if let Some(ref cb) = on_item { let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?"); - cb(i + 1, total, name); + if !cb(i + 1, total, name) { + return Err(EngineError::Cancelled); + } } match rebuild_canonical_post(conn, data_dir, project_id, path) { Ok(created) => { @@ -781,7 +783,9 @@ pub fn rebuild_posts_from_filesystem_with_progress( for (i, path) in translation_files.iter().enumerate() { if let Some(ref cb) = on_item { let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?"); - cb(offset + i + 1, total, name); + if !cb(offset + i + 1, total, name) { + return Err(EngineError::Cancelled); + } } match rebuild_translation(conn, data_dir, project_id, path) { Ok(created) => { @@ -816,11 +820,25 @@ pub fn rebuild_all_links( conn: &Connection, data_dir: &Path, project_id: &str, +) -> EngineResult { + rebuild_all_links_with_progress(conn, data_dir, project_id, None) +} + +pub fn rebuild_all_links_with_progress( + conn: &Connection, + data_dir: &Path, + project_id: &str, + on_item: Option, ) -> EngineResult { let posts = qp::list_posts_by_project(conn, project_id)?; let mut link_count = 0; - for post in &posts { + for (index, post) in posts.iter().enumerate() { + if let Some(ref callback) = on_item + && !callback(index + 1, posts.len(), &post.title) + { + return Err(EngineError::Cancelled); + } // Get post content: from DB or filesystem let content = if let Some(ref content) = post.content { content.clone() diff --git a/crates/bds-core/src/engine/publishing.rs b/crates/bds-core/src/engine/publishing.rs index 6a59dde..23546c5 100644 --- a/crates/bds-core/src/engine/publishing.rs +++ b/crates/bds-core/src/engine/publishing.rs @@ -1,7 +1,10 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; use serde::{Deserialize, Serialize}; use walkdir::WalkDir; @@ -124,6 +127,26 @@ pub fn upload_site( private_cache_dir: &Path, preferences: &PublishingPreferences, mut on_progress: impl FnMut(usize, usize, UploadTargetKind), +) -> EngineResult { + let cancelled = AtomicBool::new(false); + upload_site_cancellable( + data_dir, + private_cache_dir, + preferences, + &cancelled, + move |current, total, kind| { + on_progress(current, total, kind); + true + }, + ) +} + +pub fn upload_site_cancellable( + data_dir: &Path, + private_cache_dir: &Path, + preferences: &PublishingPreferences, + cancel_flag: &AtomicBool, + mut on_progress: impl FnMut(usize, usize, UploadTargetKind) -> bool, ) -> EngineResult { if std::env::var_os("SSH_AUTH_SOCK").is_none() { return Err(EngineError::Validation( @@ -134,7 +157,7 @@ pub fn upload_site( data_dir, private_cache_dir, preferences, - &mut |program, args| run_command(program, args), + &mut |program, args| run_command_cancellable(program, args, cancel_flag), &mut on_progress, ) } @@ -144,7 +167,7 @@ fn upload_site_with_runner( private_cache_dir: &Path, preferences: &PublishingPreferences, runner: &mut CommandRunner<'_>, - on_progress: &mut dyn FnMut(usize, usize, UploadTargetKind), + on_progress: &mut dyn FnMut(usize, usize, UploadTargetKind) -> bool, ) -> EngineResult { let credentials = Credentials::from_preferences(preferences)?; let targets = build_upload_targets(data_dir, &credentials); @@ -162,7 +185,9 @@ fn upload_site_with_runner( let cache_path = private_cache_dir.join("publishing-scp-mtimes.json"); let mut cache = read_cache(&cache_path); for (index, target) in targets.iter().enumerate() { - on_progress(index + 1, targets.len(), target.kind); + if !on_progress(index + 1, targets.len(), target.kind) { + return Err(EngineError::Cancelled); + } let result = match credentials.mode { SshMode::Rsync => upload_rsync(target, &credentials, runner), SshMode::Scp => upload_scp(target, &credentials, &mut cache, runner), @@ -328,21 +353,49 @@ fn write_cache(path: &Path, cache: &ScpMtimeCache) -> EngineResult<()> { Ok(()) } -fn run_command(program: &str, args: &[String]) -> Result<(), String> { - let output = Command::new(program) +fn run_command_cancellable( + program: &str, + args: &[String], + cancel_flag: &AtomicBool, +) -> Result<(), String> { + let mut child = Command::new(program) .args(args) - .output() + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() .map_err(|error| format!("failed to start {program}: {error}"))?; - if output.status.success() { - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - Err(if stderr.is_empty() { - format!("{program} exited with {}", output.status) - } else { - stderr - }) + let stderr = child.stderr.take(); + let stderr_reader = std::thread::spawn(move || { + let mut output = String::new(); + if let Some(mut stderr) = stderr { + let _ = stderr.read_to_string(&mut output); + } + output + }); + let status = loop { + if cancel_flag.load(Ordering::Acquire) { + let _ = child.kill(); + let _ = child.wait(); + let _ = stderr_reader.join(); + return Err("operation cancelled".into()); + } + if let Some(status) = child + .try_wait() + .map_err(|error| format!("failed to wait for {program}: {error}"))? + { + break status; + } + std::thread::sleep(Duration::from_millis(50)); + }; + let stderr = stderr_reader.join().unwrap_or_default().trim().to_string(); + if status.success() { + return Ok(()); } + Err(if stderr.is_empty() { + format!("{program} exited with {status}") + } else { + stderr + }) } #[cfg(test)] @@ -376,7 +429,7 @@ mod tests { commands.push((program.to_owned(), args.to_vec())); Ok(()) }, - &mut |_, _, _| {}, + &mut |_, _, _| true, ) .unwrap(); @@ -397,6 +450,28 @@ mod tests { ); } + #[test] + fn cancellation_stops_before_starting_the_next_target() { + let dir = TempDir::new().unwrap(); + let cache = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join("html")).unwrap(); + let mut commands = 0; + + let result = upload_site_with_runner( + dir.path(), + cache.path(), + &preferences(SshMode::Rsync), + &mut |_, _| { + commands += 1; + Ok(()) + }, + &mut |_, _, _| false, + ); + + assert!(matches!(result, Err(EngineError::Cancelled))); + assert_eq!(commands, 0); + } + #[test] fn scp_excludes_sidecars_and_skips_unchanged_files() { let dir = TempDir::new().unwrap(); @@ -415,7 +490,7 @@ mod tests { first.push((program.to_owned(), args.to_vec())); Ok(()) }, - &mut |_, _, _| {}, + &mut |_, _, _| true, ) .unwrap(); assert_eq!( @@ -437,7 +512,7 @@ mod tests { second.push(program.to_owned()); Ok(()) }, - &mut |_, _, _| {}, + &mut |_, _, _| true, ) .unwrap(); assert!(second.is_empty()); @@ -454,7 +529,7 @@ mod tests { cache.path(), &preferences(SshMode::Rsync), &mut |_, _| Err("network down".into()), - &mut |_, _, _| {}, + &mut |_, _, _| true, ) .unwrap_err(); assert!(error.to_string().contains("network down")); diff --git a/crates/bds-core/src/engine/rebuild.rs b/crates/bds-core/src/engine/rebuild.rs index cda69be..3d8eb17 100644 --- a/crates/bds-core/src/engine/rebuild.rs +++ b/crates/bds-core/src/engine/rebuild.rs @@ -27,6 +27,8 @@ pub struct FullRebuildReport { pub templates_updated: usize, pub scripts_created: usize, pub scripts_updated: usize, + pub thumbnails_generated: usize, + pub thumbnail_media_failed: usize, pub errors: Vec, } @@ -91,8 +93,19 @@ pub enum RebuildProgress { name: String, }, RebuildingTemplates, + TemplateItem { + current: usize, + total: usize, + name: String, + }, RebuildingScripts, + ScriptItem { + current: usize, + total: usize, + name: String, + }, ImportingTags, + RebuildingThumbnails, RefreshingSemanticIndex, Complete, } @@ -116,8 +129,19 @@ impl RebuildProgress { name, } => localize_item(locale, "engine.progress.mediaItem", *current, *total, name), Self::RebuildingTemplates => translate(locale, "engine.progress.rebuildingTemplates"), + Self::TemplateItem { + current, + total, + name, + } => localize_item(locale, "engine.checkingItem", *current, *total, name), Self::RebuildingScripts => translate(locale, "engine.progress.rebuildingScripts"), + Self::ScriptItem { + current, + total, + name, + } => localize_item(locale, "engine.checkingItem", *current, *total, name), Self::ImportingTags => translate(locale, "engine.progress.importingTags"), + Self::RebuildingThumbnails => translate(locale, "engine.progress.rebuildingThumbnails"), Self::RefreshingSemanticIndex => { translate(locale, "engine.progress.refreshingSemanticIndex") } @@ -137,7 +161,7 @@ fn localize_item(locale: UiLocale, key: &str, current: usize, total: usize, name } /// Progress callback: (percent 0.0..1.0, semantic progress event). -pub type ProgressFn = Arc; +pub type ProgressFn = Arc bool + Send + Sync>; /// Orchestrate a full rebuild from filesystem into the database. /// @@ -178,23 +202,26 @@ fn rebuild_from_filesystem_inner( on_progress: Option, ) -> EngineResult { let mut report = FullRebuildReport::default(); - let progress = |pct: f32, event: RebuildProgress| { - if let Some(ref f) = on_progress { - f(pct, &event); + let progress = |pct: f32, event: RebuildProgress| -> EngineResult<()> { + if let Some(ref f) = on_progress + && !f(pct, &event) + { + return Err(crate::engine::EngineError::Cancelled); } + Ok(()) }; // Phase weights: posts 0.0..0.35, media 0.35..0.70, templates 0.70..0.85, scripts 0.85..1.0 // 1. Load portable project metadata and clear all reconstructible rows. - progress(0.0, RebuildProgress::LoadingProjectMetadata); + progress(0.0, RebuildProgress::LoadingProjectMetadata)?; fts::ensure_fts_tables(conn)?; crate::engine::meta::startup_sync(data_dir)?; crate::engine::meta::sync_metadata_from_filesystem(conn, data_dir, project_id)?; clear_project_rows(conn, project_id)?; // 2. Rebuild posts (0.00 .. 0.35) - progress(0.01, RebuildProgress::ScanningPosts); + progress(0.01, RebuildProgress::ScanningPosts)?; let post_item_cb: Option = on_progress.as_ref().map(|cb| { let cb = Arc::clone(cb); let f: post::ItemProgressFn = Box::new(move |current, total, name| { @@ -211,7 +238,7 @@ fn rebuild_from_filesystem_inner( total, name: name.to_string(), }, - ); + ) }); f }); @@ -228,7 +255,7 @@ fn rebuild_from_filesystem_inner( report.errors.extend(post_report.errors); // 3. Rebuild media (0.35 .. 0.70) - progress(0.35, RebuildProgress::ScanningMedia); + progress(0.35, RebuildProgress::ScanningMedia)?; let media_item_cb: Option = on_progress.as_ref().map(|cb| { let cb = Arc::clone(cb); let f: media::ItemProgressFn = Box::new(move |current, total, name| { @@ -245,7 +272,7 @@ fn rebuild_from_filesystem_inner( total, name: name.to_string(), }, - ); + ) }); f }); @@ -262,26 +289,87 @@ fn rebuild_from_filesystem_inner( report.errors.extend(media_report.errors); // 4. Rebuild templates (0.70 .. 0.85) - progress(0.70, RebuildProgress::RebuildingTemplates); - let tpl_report = - template_rebuild::rebuild_templates_from_filesystem(conn, data_dir, project_id)?; + progress(0.70, RebuildProgress::RebuildingTemplates)?; + let template_progress = on_progress.as_ref().map(|callback| { + let callback = Arc::clone(callback); + Box::new(move |current: usize, total: usize, name: &str| { + callback( + 0.70 + current as f32 / total.max(1) as f32 * 0.15, + &RebuildProgress::TemplateItem { + current, + total, + name: name.to_string(), + }, + ) + }) as template_rebuild::ItemProgressFn + }); + let tpl_report = template_rebuild::rebuild_templates_from_filesystem_with_progress( + conn, + data_dir, + project_id, + template_progress, + )?; report.templates_created = tpl_report.created; report.templates_updated = tpl_report.updated; report.errors.extend(tpl_report.errors); // 5. Rebuild scripts (0.85 .. 0.95) - progress(0.85, RebuildProgress::RebuildingScripts); - let script_report = - script_rebuild::rebuild_scripts_from_filesystem(conn, data_dir, project_id)?; + progress(0.85, RebuildProgress::RebuildingScripts)?; + let script_progress = on_progress.as_ref().map(|callback| { + let callback = Arc::clone(callback); + Box::new(move |current: usize, total: usize, name: &str| { + callback( + 0.85 + current as f32 / total.max(1) as f32 * 0.10, + &RebuildProgress::ScriptItem { + current, + total, + name: name.to_string(), + }, + ) + }) as script_rebuild::ItemProgressFn + }); + let script_report = script_rebuild::rebuild_scripts_from_filesystem_with_progress( + conn, + data_dir, + project_id, + script_progress, + )?; report.scripts_created = script_report.created; report.scripts_updated = script_report.updated; report.errors.extend(script_report.errors); // 6. Restore relationships and tags (0.95 .. 1.0) - progress(0.95, RebuildProgress::ImportingTags); + progress(0.95, RebuildProgress::ImportingTags)?; super::tag::import_tags_from_file(conn, data_dir, project_id)?; super::tag::sync_tags_from_posts(conn, project_id)?; - post::rebuild_all_links(conn, data_dir, project_id)?; + let link_progress = on_progress.as_ref().map(|callback| { + let callback = Arc::clone(callback); + Box::new(move |current: usize, total: usize, name: &str| { + callback( + 0.95 + current as f32 / total.max(1) as f32 * 0.01, + &RebuildProgress::PostItem { + current, + total, + name: name.to_string(), + }, + ) + }) as post::ItemProgressFn + }); + post::rebuild_all_links_with_progress(conn, data_dir, project_id, link_progress)?; + let media_link_progress = on_progress.as_ref().map(|callback| { + let callback = Arc::clone(callback); + Box::new(move |current: usize, total: usize, name: &str| { + callback( + 0.96 + current as f32 / total.max(1) as f32 * 0.01, + &RebuildProgress::MediaItem { + current, + total, + name: name.to_string(), + }, + ) + }) as media::ItemProgressFn + }); + media::rebuild_media_links_with_progress(conn, data_dir, project_id, media_link_progress)?; if !report.errors.is_empty() { return Err(crate::engine::EngineError::Validation(format!( @@ -290,11 +378,39 @@ fn rebuild_from_filesystem_inner( ))); } - progress(0.98, RebuildProgress::RefreshingSemanticIndex); - crate::engine::embedding::EmbeddingService::production(conn, data_dir) - .index_unindexed(project_id)?; + progress(0.97, RebuildProgress::RebuildingThumbnails)?; + let thumbnail_report = media::regenerate_missing_thumbnails_with_progress( + conn, + data_dir, + project_id, + |current, total, name| { + on_progress.as_ref().is_none_or(|callback| { + callback( + 0.97 + current as f32 / total.max(1) as f32 * 0.02, + &RebuildProgress::MediaItem { + current, + total, + name: name.to_string(), + }, + ) + }) + }, + )?; + report.thumbnails_generated = thumbnail_report.thumbnails_generated; + report.thumbnail_media_failed = thumbnail_report.media_failed; - progress(1.0, RebuildProgress::Complete); + progress(0.99, RebuildProgress::RefreshingSemanticIndex)?; + crate::engine::embedding::EmbeddingService::production(conn, data_dir) + .index_unindexed_with_progress(project_id, |current, total| { + on_progress.as_ref().is_none_or(|callback| { + callback( + 0.99 + current as f32 / total.max(1) as f32 * 0.01, + &RebuildProgress::RefreshingSemanticIndex, + ) + }) + })?; + + progress(1.0, RebuildProgress::Complete)?; Ok(report) } @@ -445,6 +561,36 @@ mod tests { assert!(report.errors.is_empty()); } + #[test] + fn cancellation_rolls_back_the_full_rebuild_savepoint() { + let (db, dir) = setup(); + let post = crate::engine::post::create_post( + db.conn(), + dir.path(), + "p1", + "Preserved", + Some("body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + + let result = rebuild_from_filesystem_with_progress( + db.conn(), + dir.path(), + "p1", + Some(Arc::new(|_, event| { + !matches!(event, RebuildProgress::ScanningPosts) + })), + ); + + assert!(matches!(result, Err(crate::engine::EngineError::Cancelled))); + assert!(qp::get_post_by_id(db.conn(), &post.id).is_ok()); + } + #[test] fn rebuild_creates_posts_and_media() { let (db, dir) = setup(); diff --git a/crates/bds-core/src/engine/script_rebuild.rs b/crates/bds-core/src/engine/script_rebuild.rs index 69840ee..750be04 100644 --- a/crates/bds-core/src/engine/script_rebuild.rs +++ b/crates/bds-core/src/engine/script_rebuild.rs @@ -17,6 +17,8 @@ pub struct ScriptRebuildReport { pub errors: Vec, } +pub type ItemProgressFn = Box bool + Send>; + /// Rebuild scripts from the filesystem into the database. /// /// Walks the `scripts/` directory for `*.lua` files, parses each @@ -26,6 +28,15 @@ pub fn rebuild_scripts_from_filesystem( conn: &Connection, data_dir: &Path, project_id: &str, +) -> EngineResult { + rebuild_scripts_from_filesystem_with_progress(conn, data_dir, project_id, None) +} + +pub fn rebuild_scripts_from_filesystem_with_progress( + conn: &Connection, + data_dir: &Path, + project_id: &str, + on_item: Option, ) -> EngineResult { let mut report = ScriptRebuildReport::default(); let scripts_dir = data_dir.join("scripts"); @@ -34,17 +45,23 @@ pub fn rebuild_scripts_from_filesystem( return Ok(report); } - for entry in WalkDir::new(&scripts_dir) + let files = WalkDir::new(&scripts_dir) .into_iter() .filter_map(|e| e.ok()) - { + .filter(|entry| entry.path().is_file()) + .filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("lua")) + .collect::>(); + + for (index, entry) in files.iter().enumerate() { let path = entry.path(); - if !path.is_file() { - continue; - } - let ext = path.extension().and_then(|e| e.to_str()); - if ext != Some("lua") { - continue; + let name = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("?"); + if let Some(ref callback) = on_item + && !callback(index + 1, files.len(), name) + { + return Err(EngineError::Cancelled); } match rebuild_single_script(conn, data_dir, project_id, path) { diff --git a/crates/bds-core/src/engine/search.rs b/crates/bds-core/src/engine/search.rs index b0dbdfc..7403dea 100644 --- a/crates/bds-core/src/engine/search.rs +++ b/crates/bds-core/src/engine/search.rs @@ -67,7 +67,7 @@ pub struct ReindexReport { } /// Per-item progress callback: (current_item, total_items, item_description). -pub type ItemProgressFn = Box; +pub type ItemProgressFn = Box bool + Send>; /// Repair a missing or previously deployed FTS schema and report whether its /// derived content still needs to be rebuilt. @@ -215,8 +215,10 @@ fn index_project( for post in post_q::list_posts_by_project(conn, project_id)? { *current += 1; - if let Some(callback) = on_item { - callback(*current, total, &post.title); + if let Some(callback) = on_item + && !callback(*current, total, &post.title) + { + return Err(EngineError::Cancelled); } let translations = post_translation::list_post_translations_by_post(conn, &post.id)?; let translation_data = translations @@ -255,8 +257,10 @@ fn index_project( for media in media_q::list_media_by_project(conn, project_id)? { *current += 1; - if let Some(callback) = on_item { - callback(*current, total, &media.original_name); + if let Some(callback) = on_item + && !callback(*current, total, &media.original_name) + { + return Err(EngineError::Cancelled); } let translations = media_translation::list_media_translations_by_media(conn, &media.id)?; let translation_data = translations @@ -374,6 +378,29 @@ mod tests { assert_eq!(results.len(), 1); } + #[test] + fn cancellation_rolls_back_shared_index_rebuild() { + let (db, project_id) = setup(); + let dir = tempfile::tempdir().unwrap(); + engine::post::create_post( + db.conn(), + dir.path(), + &project_id, + "Cancel Me", + Some("body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + + let result = rebuild_search_index(db.conn(), Some(Box::new(|_, _, _| false))); + + assert!(matches!(result, Err(EngineError::Cancelled))); + } + #[test] fn rebuild_indexes_published_post_and_translation_bodies_from_files() { let db = Database::open_in_memory().unwrap(); diff --git a/crates/bds-core/src/engine/task.rs b/crates/bds-core/src/engine/task.rs index 1dc80c6..0c1bcc9 100644 --- a/crates/bds-core/src/engine/task.rs +++ b/crates/bds-core/src/engine/task.rs @@ -1,7 +1,10 @@ +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::time::{Duration, Instant}; +use tokio::sync::Notify; + /// Unique task identifier. pub type TaskId = u64; @@ -25,6 +28,7 @@ pub struct TaskSnapshot { pub status: TaskStatus, pub progress: Option, pub message: Option, + pub cancellation_requested: bool, pub created_at: Instant, } @@ -42,228 +46,340 @@ struct TaskEntry { created_at: Instant, finished_at: Option, last_progress_report: Option, + worker_active: bool, + worker_started: bool, } /// Manages concurrent tasks with a max concurrency limit and FIFO queue. pub struct TaskManager { max_concurrent: usize, - next_id: Mutex, - tasks: Mutex>, + state: Mutex, state_changed: Condvar, + async_changed: Notify, +} + +struct TaskState { + next_id: TaskId, + tasks: HashMap, + order: VecDeque, + pending: VecDeque, + worker_count: usize, +} + +/// Capacity reservation held from asynchronous admission until the worker exits. +pub struct TaskWorker { + manager: Arc, + task_id: TaskId, +} + +impl Drop for TaskWorker { + fn drop(&mut self) { + self.manager.worker_exited(self.task_id); + } } impl TaskManager { /// Create a new task manager with the given concurrency limit. pub fn new(max_concurrent: usize) -> Self { Self { - max_concurrent, - next_id: Mutex::new(1), - tasks: Mutex::new(Vec::new()), + max_concurrent: max_concurrent.max(1), + state: Mutex::new(TaskState { + next_id: 1, + tasks: HashMap::new(), + order: VecDeque::new(), + pending: VecDeque::new(), + worker_count: 0, + }), state_changed: Condvar::new(), + async_changed: Notify::new(), } } /// Submit a new task. Returns its unique identifier. pub fn submit(&self, label: &str) -> TaskId { - let mut next = self.next_id.lock().unwrap(); - let id = *next; - *next += 1; + self.submit_with_group(label, None, None) + } - let entry = TaskEntry { + fn submit_with_group( + &self, + label: &str, + group_id: Option<&str>, + group_name: Option<&str>, + ) -> TaskId { + let mut state = self.state.lock().unwrap(); + Self::prune_expired(&mut state); + let id = state.next_id; + state.next_id += 1; + state.tasks.insert( id, - label: label.to_owned(), - group_id: None, - group_name: None, - status: TaskStatus::Pending, - cancel_flag: Arc::new(AtomicBool::new(false)), - 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 - && let Some(t) = tasks - .iter_mut() - .find(|t| t.id == id && t.status == TaskStatus::Pending) - { - t.status = TaskStatus::Running; - } + TaskEntry { + id, + label: label.to_owned(), + group_id: group_id.map(str::to_owned), + group_name: group_name.map(str::to_owned), + status: TaskStatus::Pending, + cancel_flag: Arc::new(AtomicBool::new(false)), + progress: None, + message: None, + created_at: Instant::now(), + finished_at: None, + last_progress_report: None, + worker_active: false, + worker_started: false, + }, + ); + state.order.push_back(id); + state.pending.push_back(id); + Self::promote_next(&mut state, self.max_concurrent); + drop(state); + self.notify_changed(); id } /// Submit a new task within a group. Returns its unique identifier. pub fn submit_grouped(&self, label: &str, group_id: &str, group_name: &str) -> TaskId { - let id = self.submit(label); - let mut tasks = self.tasks.lock().unwrap(); - if let Some(entry) = tasks.iter_mut().find(|t| t.id == id) { - entry.group_id = Some(group_id.to_owned()); - entry.group_name = Some(group_name.to_owned()); - } - id + self.submit_with_group(label, Some(group_id), Some(group_name)) } - /// Block a worker until its task may run. Returns false if cancelled. + /// Wait synchronously for admission. Prefer [`Self::admit`] before spawning workers. pub fn wait_until_runnable(&self, task_id: TaskId) -> bool { - let mut tasks = self.tasks.lock().unwrap(); + let mut state = self.state.lock().unwrap(); 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(), + match state.tasks.get(&task_id).map(|task| &task.status) { + Some(TaskStatus::Running) => { + if let Some(task) = state.tasks.get_mut(&task_id) { + task.worker_started = true; + } + return true; + } + Some(TaskStatus::Pending) => state = self.state_changed.wait(state).unwrap(), _ => return false, } } } + /// Admit without occupying a blocking-pool thread while queued. + pub async fn admit(self: &Arc, task_id: TaskId) -> Option { + loop { + let notified = self.async_changed.notified(); + { + let mut state = self.state.lock().unwrap(); + match state.tasks.get_mut(&task_id) { + Some(task) if task.status == TaskStatus::Running => { + task.worker_started = true; + return Some(TaskWorker { + manager: Arc::clone(self), + task_id, + }); + } + Some(task) if task.status == TaskStatus::Pending => {} + _ => return None, + } + } + notified.await; + } + } + + /// Admit a non-Tokio worker while retaining capacity until its guard drops. + pub fn admit_blocking(self: &Arc, task_id: TaskId) -> Option { + if self.wait_until_runnable(task_id) { + Some(TaskWorker { + manager: Arc::clone(self), + task_id, + }) + } else { + None + } + } + /// 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) - && 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); - self.state_changed.notify_all(); + let mut state = self.state.lock().unwrap(); + let released = if let Some(entry) = state.tasks.get_mut(&task_id) { + if matches!(entry.status, TaskStatus::Running) { + entry.status = TaskStatus::Completed; + entry.progress = Some(1.0); + entry.finished_at = Some(Instant::now()); + } + let released = entry.worker_active; + entry.worker_active = false; + entry.worker_started = false; + released + } else { + false + }; + state.worker_count = state.worker_count.saturating_sub(usize::from(released)); + Self::promote_next(&mut state, self.max_concurrent); + drop(state); + self.notify_changed(); } /// 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) - && 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); - self.state_changed.notify_all(); + let mut state = self.state.lock().unwrap(); + let released = if let Some(entry) = state.tasks.get_mut(&task_id) { + if matches!(entry.status, TaskStatus::Running) { + if entry.cancel_flag.load(Ordering::Acquire) { + entry.status = TaskStatus::Cancelled; + } else { + entry.message = Some(error.clone()); + entry.status = TaskStatus::Failed(error); + } + entry.finished_at = Some(Instant::now()); + } + let released = entry.worker_active; + entry.worker_active = false; + entry.worker_started = false; + released + } else { + false + }; + state.worker_count = state.worker_count.saturating_sub(usize::from(released)); + Self::promote_next(&mut state, self.max_concurrent); + drop(state); + self.notify_changed(); } - /// 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) + /// Cancel queued work immediately, or request a cooperative stop from a worker. + pub fn cancel(&self, task_id: TaskId) -> bool { + let mut state = self.state.lock().unwrap(); + let mut cancelled = false; + let mut released = false; + if let Some(entry) = state.tasks.get_mut(&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()); + if !entry.worker_started { + if entry.worker_active { + entry.worker_active = false; + released = true; + } + entry.status = TaskStatus::Cancelled; + entry.finished_at = Some(Instant::now()); + } + cancelled = true; } - Self::promote_next(&mut tasks, self.max_concurrent); - self.state_changed.notify_all(); + state.worker_count = state.worker_count.saturating_sub(usize::from(released)); + Self::promote_next(&mut state, self.max_concurrent); + drop(state); + self.notify_changed(); + cancelled } /// Cancel every active task in a group and release their workers. pub fn cancel_group(&self, group_id: &str) { - let mut tasks = self.tasks.lock().unwrap(); + let mut state = self.state.lock().unwrap(); let now = Instant::now(); - for entry in tasks.iter_mut().filter(|task| { - task.group_id.as_deref() == Some(group_id) - && matches!(task.status, TaskStatus::Running | TaskStatus::Pending) - }) { + let group_ids = state + .tasks + .values() + .filter(|task| { + task.group_id.as_deref() == Some(group_id) + && matches!(task.status, TaskStatus::Running | TaskStatus::Pending) + }) + .map(|task| task.id) + .collect::>(); + let mut released = 0; + for id in group_ids { + let entry = state.tasks.get_mut(&id).unwrap(); entry.cancel_flag.store(true, Ordering::Release); - entry.status = TaskStatus::Cancelled; - entry.finished_at = Some(now); + if !entry.worker_started { + if entry.worker_active { + entry.worker_active = false; + released += 1; + } + entry.status = TaskStatus::Cancelled; + entry.finished_at = Some(now); + } } - Self::promote_next(&mut tasks, self.max_concurrent); - self.state_changed.notify_all(); + state.worker_count = state.worker_count.saturating_sub(released); + Self::promote_next(&mut state, self.max_concurrent); + drop(state); + self.notify_changed(); } /// Return the group containing a task, if any. pub fn group_id(&self, task_id: TaskId) -> Option { - self.tasks - .lock() - .unwrap() - .iter() - .find(|task| task.id == task_id) + let mut state = self.state.lock().unwrap(); + Self::prune_expired(&mut state); + state + .tasks + .get(&task_id) .and_then(|task| task.group_id.clone()) } /// Check whether a task has been cancelled. pub fn is_cancelled(&self, task_id: TaskId) -> bool { - let tasks = self.tasks.lock().unwrap(); - tasks - .iter() - .find(|t| t.id == task_id) + let mut state = self.state.lock().unwrap(); + Self::prune_expired(&mut state); + state + .tasks + .get(&task_id) .map(|t| t.cancel_flag.load(Ordering::Acquire)) .unwrap_or(false) } /// Shared cancellation flag for a worker owned by this task. pub fn cancellation_flag(&self, task_id: TaskId) -> Option> { - self.tasks - .lock() - .unwrap() - .iter() - .find(|task| task.id == task_id) + let mut state = self.state.lock().unwrap(); + Self::prune_expired(&mut state); + state + .tasks + .get(&task_id) .map(|task| Arc::clone(&task.cancel_flag)) } /// Return the current status of a task. pub fn status(&self, task_id: TaskId) -> Option { - let tasks = self.tasks.lock().unwrap(); - tasks - .iter() - .find(|t| t.id == task_id) - .map(|t| t.status.clone()) + self.get(task_id).map(|task| task.status) } /// Count tasks that are still queued. pub fn pending_count(&self) -> usize { - let tasks = self.tasks.lock().unwrap(); - tasks - .iter() + let mut state = self.state.lock().unwrap(); + Self::prune_expired(&mut state); + state + .tasks + .values() .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() + let mut state = self.state.lock().unwrap(); + Self::prune_expired(&mut state); + state + .tasks + .values() .filter(|t| t.status == TaskStatus::Running) .count() } /// 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(|task| { - task.finished_at - .is_none_or(|finished_at| finished_at > cutoff) - }); + let mut state = self.state.lock().unwrap(); + Self::prune_expired(&mut state); } /// Remove every finished task while preserving running and queued work. pub fn clear_completed(&self) { - self.tasks - .lock() - .unwrap() - .retain(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running)); + let mut state = self.state.lock().unwrap(); + Self::remove_where(&mut state, |task| task.status == TaskStatus::Completed); + } + + /// Remove all terminal task results while preserving active work. + pub fn clear_finished(&self) { + let mut state = self.state.lock().unwrap(); + Self::remove_where(&mut state, |task| { + task.finished_at.is_some() && !task.worker_active + }); } /// Update progress for a running task. Throttled to at most once per 250ms. pub fn report_progress(&self, task_id: TaskId, progress: Option, message: Option) { - let mut tasks = self.tasks.lock().unwrap(); - if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) + let mut state = self.state.lock().unwrap(); + if let Some(entry) = state.tasks.get_mut(&task_id) && entry.status == TaskStatus::Running { let now = Instant::now(); @@ -271,7 +387,7 @@ impl TaskManager { Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128, None => true, }; - if should_report { + if should_report || progress.is_some_and(|value| value >= 1.0) { entry.progress = progress; entry.message = message; entry.last_progress_report = Some(now); @@ -281,58 +397,157 @@ impl TaskManager { /// Return the current progress of a task. pub fn progress(&self, task_id: TaskId) -> Option { - let tasks = self.tasks.lock().unwrap(); - tasks - .iter() - .find(|t| t.id == task_id) - .and_then(|t| t.progress) + self.get(task_id).and_then(|task| task.progress) } - /// Return a snapshot of all tasks for UI display. + /// Return active tasks plus ten recent finished tasks for status surfaces. pub fn snapshots(&self) -> Vec { - let tasks = self.tasks.lock().unwrap(); - let mut snapshots = tasks - .iter() + let mut state = self.state.lock().unwrap(); + Self::prune_expired(&mut state); + let mut active = state + .tasks + .values() .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::>(); - snapshots.sort_by_key(|snapshot| snapshot.created_at); - snapshots + active.sort_by_key(|task| (task.status != TaskStatus::Running, task.created_at)); + let active_groups = active + .iter() + .filter_map(|task| task.group_id.as_deref()) + .collect::>(); + let mut finished = state + .tasks + .values() + .filter(|task| task.finished_at.is_some()) + .collect::>(); + finished.sort_by_key(|task| std::cmp::Reverse(task.finished_at)); + let recent_ids = finished + .iter() + .take(RECENT_FINISHED_LIMIT) + .map(|task| task.id) + .collect::>(); + active + .into_iter() + .chain(finished.into_iter().filter(|task| { + recent_ids.contains(&task.id) + || task + .group_id + .as_deref() + .is_some_and(|group| active_groups.contains(group)) + })) + .map(Self::snapshot) + .collect() } - /// Promote the next queued task to running if capacity allows. - fn promote_next(tasks: &mut [TaskEntry], max_concurrent: usize) { - while tasks + /// Return one retained task by id. + pub fn get(&self, task_id: TaskId) -> Option { + let mut state = self.state.lock().unwrap(); + Self::prune_expired(&mut state); + state.tasks.get(&task_id).map(Self::snapshot) + } + + /// Return every retained task, newest first. + pub fn all(&self) -> Vec { + let mut state = self.state.lock().unwrap(); + Self::prune_expired(&mut state); + state + .order .iter() + .rev() + .filter_map(|id| state.tasks.get(id).map(Self::snapshot)) + .collect() + } + + /// Return running tasks in start order. + pub fn running(&self) -> Vec { + let mut state = self.state.lock().unwrap(); + Self::prune_expired(&mut state); + state + .order + .iter() + .filter_map(|id| state.tasks.get(id)) .filter(|task| task.status == TaskStatus::Running) - .count() - < max_concurrent - { - let Some(task) = tasks - .iter_mut() - .find(|task| task.status == TaskStatus::Pending) - else { + .map(Self::snapshot) + .collect() + } + + fn worker_exited(&self, task_id: TaskId) { + let mut state = self.state.lock().unwrap(); + let released = if let Some(task) = state.tasks.get_mut(&task_id) { + if task.cancel_flag.load(Ordering::Acquire) { + task.worker_started = false; + drop(state); + self.notify_changed(); + return; + } + let released = task.worker_active; + task.worker_active = false; + task.worker_started = false; + released + } else { + false + }; + state.worker_count = state.worker_count.saturating_sub(usize::from(released)); + Self::promote_next(&mut state, self.max_concurrent); + drop(state); + self.notify_changed(); + } + + fn promote_next(state: &mut TaskState, max_concurrent: usize) { + while state.worker_count < max_concurrent { + let Some(id) = state.pending.pop_front() else { break; }; + let Some(task) = state.tasks.get_mut(&id) else { + continue; + }; + if task.status != TaskStatus::Pending { + continue; + } task.status = TaskStatus::Running; + task.worker_active = true; + state.worker_count += 1; } } + + fn snapshot(task: &TaskEntry) -> TaskSnapshot { + 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(), + cancellation_requested: task.cancel_flag.load(Ordering::Acquire), + created_at: task.created_at, + } + } + + fn prune_expired(state: &mut TaskState) { + let cutoff = Instant::now() - FINISHED_TASK_TTL; + Self::remove_where(state, |task| { + task.finished_at + .is_some_and(|finished_at| finished_at <= cutoff) + && !task.worker_active + }); + } + + fn remove_where(state: &mut TaskState, predicate: impl Fn(&TaskEntry) -> bool) { + let removed = state + .tasks + .values() + .filter(|task| predicate(task)) + .map(|task| task.id) + .collect::>(); + state.tasks.retain(|id, _| !removed.contains(id)); + state.order.retain(|id| !removed.contains(id)); + state.pending.retain(|id| !removed.contains(id)); + } + + fn notify_changed(&self) { + self.state_changed.notify_all(); + self.async_changed.notify_waiters(); + } } impl Default for TaskManager { @@ -399,7 +614,7 @@ mod tests { } #[test] - fn cancel_sets_flag() { + fn cancelling_unstarted_task_settles_immediately() { let mgr = TaskManager::default(); let id = mgr.submit("upload"); // Task is auto-started (Running) @@ -409,6 +624,35 @@ mod tests { assert_eq!(mgr.status(id), Some(TaskStatus::Cancelled)); } + #[tokio::test] + async fn cancelling_started_task_stays_running_until_worker_stops() { + let mgr = Arc::new(TaskManager::new(1)); + let id = mgr.submit("upload"); + let worker = mgr.admit(id).await.unwrap(); + + mgr.cancel(id); + + let snapshot = mgr.get(id).unwrap(); + assert_eq!(snapshot.status, TaskStatus::Running); + assert!(snapshot.cancellation_requested); + drop(worker); + mgr.fail(id, "operation cancelled".into()); + assert_eq!(mgr.status(id), Some(TaskStatus::Cancelled)); + } + + #[tokio::test] + async fn completed_work_wins_a_late_cancellation_request() { + let mgr = Arc::new(TaskManager::new(1)); + let id = mgr.submit("atomic update"); + let worker = mgr.admit(id).await.unwrap(); + mgr.cancel(id); + drop(worker); + + mgr.complete(id); + + assert_eq!(mgr.status(id), Some(TaskStatus::Completed)); + } + #[test] fn complete_and_fail() { let mgr = TaskManager::default(); @@ -444,8 +688,12 @@ mod tests { // After b fails: d promoted to running { - let mut tasks = mgr.tasks.lock().unwrap(); - for task in tasks.iter_mut().filter(|task| task.finished_at.is_some()) { + let mut state = mgr.state.lock().unwrap(); + for task in state + .tasks + .values_mut() + .filter(|task| task.finished_at.is_some()) + { task.finished_at = Some(Instant::now() - FINISHED_TASK_TTL - Duration::from_secs(1)); } @@ -469,6 +717,34 @@ mod tests { assert_eq!(mgr.snapshots().len(), 10); } + #[test] + fn snapshots_retain_every_active_task_and_complete_active_groups() { + let mgr = TaskManager::new(20); + let finished_group_member = mgr.submit_grouped("finished", "group", "Group"); + mgr.complete(finished_group_member); + for index in 0..12 { + mgr.submit_grouped(&format!("active {index}"), "group", "Group"); + } + for index in 0..12 { + let id = mgr.submit(&format!("history {index}")); + mgr.complete(id); + } + + let snapshots = mgr.snapshots(); + assert_eq!( + snapshots + .iter() + .filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running)) + .count(), + 12 + ); + assert!( + snapshots + .iter() + .any(|task| task.id == finished_group_member) + ); + } + #[test] fn clear_completed_preserves_active_tasks() { let mgr = TaskManager::new(2); @@ -555,4 +831,70 @@ mod tests { assert_eq!(mgr.progress(id), Some(0.5)); assert_eq!(mgr.snapshots()[0].message.as_deref(), Some("halfway")); } + + #[tokio::test] + async fn cancellation_holds_capacity_until_worker_exits() { + let mgr = Arc::new(TaskManager::new(1)); + let running = mgr.submit("running"); + let queued = mgr.submit("queued"); + let worker = mgr.admit(running).await.unwrap(); + + mgr.cancel(running); + + assert_eq!(mgr.status(running), Some(TaskStatus::Running)); + assert!(mgr.get(running).unwrap().cancellation_requested); + assert_eq!(mgr.status(queued), Some(TaskStatus::Pending)); + drop(worker); + assert_eq!(mgr.status(queued), Some(TaskStatus::Pending)); + mgr.fail(running, "operation cancelled".into()); + assert_eq!(mgr.status(queued), Some(TaskStatus::Running)); + } + + #[tokio::test] + async fn panicking_worker_guard_releases_capacity() { + let mgr = Arc::new(TaskManager::new(1)); + let panicking = mgr.submit("panicking"); + let queued = mgr.submit("queued"); + let worker = mgr.admit(panicking).await.unwrap(); + + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _worker = worker; + panic!("boom"); + })); + + assert_eq!(mgr.status(queued), Some(TaskStatus::Running)); + } + + #[test] + fn terminal_progress_bypasses_throttle() { + let mgr = TaskManager::new(1); + let id = mgr.submit("work"); + mgr.report_progress(id, Some(0.5), Some("working".into())); + mgr.report_progress(id, Some(1.0), Some("done".into())); + + let task = mgr + .snapshots() + .into_iter() + .find(|task| task.id == id) + .unwrap(); + assert_eq!(task.progress, Some(1.0)); + assert_eq!(task.message.as_deref(), Some("done")); + } + + #[test] + fn clear_completed_keeps_other_terminal_results() { + let mgr = TaskManager::new(3); + let completed = mgr.submit("completed"); + let failed = mgr.submit("failed"); + let cancelled = mgr.submit("cancelled"); + mgr.complete(completed); + mgr.fail(failed, "failed".into()); + mgr.cancel(cancelled); + + mgr.clear_completed(); + + assert_eq!(mgr.status(completed), None); + assert!(matches!(mgr.status(failed), Some(TaskStatus::Failed(_)))); + assert_eq!(mgr.status(cancelled), Some(TaskStatus::Cancelled)); + } } diff --git a/crates/bds-core/src/engine/template_rebuild.rs b/crates/bds-core/src/engine/template_rebuild.rs index 3cf1ee0..41caa35 100644 --- a/crates/bds-core/src/engine/template_rebuild.rs +++ b/crates/bds-core/src/engine/template_rebuild.rs @@ -17,6 +17,8 @@ pub struct TemplateRebuildReport { pub errors: Vec, } +pub type ItemProgressFn = Box bool + Send>; + /// Rebuild templates from the filesystem into the database. /// /// Walks the `templates/` directory for `*.liquid` files, parses each via @@ -26,6 +28,15 @@ pub fn rebuild_templates_from_filesystem( conn: &Connection, data_dir: &Path, project_id: &str, +) -> EngineResult { + rebuild_templates_from_filesystem_with_progress(conn, data_dir, project_id, None) +} + +pub fn rebuild_templates_from_filesystem_with_progress( + conn: &Connection, + data_dir: &Path, + project_id: &str, + on_item: Option, ) -> EngineResult { let mut report = TemplateRebuildReport::default(); let templates_dir = data_dir.join("templates"); @@ -34,17 +45,23 @@ pub fn rebuild_templates_from_filesystem( return Ok(report); } - for entry in WalkDir::new(&templates_dir) + let files = WalkDir::new(&templates_dir) .into_iter() .filter_map(|e| e.ok()) - { + .filter(|entry| entry.path().is_file()) + .filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("liquid")) + .collect::>(); + + for (index, entry) in files.iter().enumerate() { let path = entry.path(); - if !path.is_file() { - continue; - } - let ext = path.extension().and_then(|e| e.to_str()); - if ext != Some("liquid") { - continue; + let name = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("?"); + if let Some(ref callback) = on_item + && !callback(index + 1, files.len(), name) + { + return Err(EngineError::Cancelled); } match rebuild_single_template(conn, data_dir, project_id, path) { diff --git a/crates/bds-core/src/engine/validate_site.rs b/crates/bds-core/src/engine/validate_site.rs index 7f323cc..fb4efa4 100644 --- a/crates/bds-core/src/engine/validate_site.rs +++ b/crates/bds-core/src/engine/validate_site.rs @@ -6,8 +6,8 @@ use crate::db::DbConnection as Connection; use walkdir::WalkDir; use crate::db::queries; -use crate::engine::EngineResult; use crate::engine::generation::has_published_snapshot; +use crate::engine::{EngineError, EngineResult}; use crate::model::Post; use crate::render::{build_canonical_post_path, build_site_route_manifest}; @@ -25,11 +25,27 @@ pub fn validate_site( data_dir: &Path, project_id: &str, ) -> EngineResult { + validate_site_with_progress(conn, data_dir, project_id, |_, _| true) +} + +pub fn validate_site_with_progress( + conn: &Connection, + data_dir: &Path, + project_id: &str, + mut on_progress: impl FnMut(usize, usize) -> bool, +) -> EngineResult { + const PHASES: usize = 4; + if !on_progress(0, PHASES) { + return Err(EngineError::Cancelled); + } let metadata = crate::engine::meta::read_project_json(data_dir)?; let output_dir = generated_output_dir(data_dir); let published_posts = load_published_posts(conn, project_id)?; let route_manifest = build_site_route_manifest(data_dir, &metadata, &published_posts) .map_err(|error| crate::engine::EngineError::Parse(error.to_string()))?; + if !on_progress(1, PHASES) { + return Err(EngineError::Cancelled); + } crate::engine::generation::refresh_validation_sitemap( conn, &output_dir, @@ -43,6 +59,9 @@ pub fn validate_site( .into_iter() .map(|page| page.relative_path) .collect::>(); + if !on_progress(2, PHASES) { + return Err(EngineError::Cancelled); + } let mut actual = HashSet::new(); let mut zero_byte = HashSet::new(); @@ -67,6 +86,9 @@ pub fn validate_site( .into_iter() .map(|file| (file.relative_path, file.updated_at)) .collect::>(); + if !on_progress(3, PHASES) { + return Err(EngineError::Cancelled); + } let mut stale_pages = stale_post_paths( data_dir, &output_dir, @@ -83,6 +105,10 @@ pub fn validate_site( stale_pages.sort(); stale_pages.dedup(); + if !on_progress(PHASES, PHASES) { + return Err(EngineError::Cancelled); + } + Ok(SiteValidationReport { missing_pages, extra_pages, diff --git a/crates/bds-core/src/engine/validate_translations.rs b/crates/bds-core/src/engine/validate_translations.rs index 63b78ca..9cf36c9 100644 --- a/crates/bds-core/src/engine/validate_translations.rs +++ b/crates/bds-core/src/engine/validate_translations.rs @@ -5,7 +5,7 @@ use std::path::Path; use crate::db::DbConnection as Connection; use crate::db::queries::{post as post_q, post_translation}; -use crate::engine::EngineResult; +use crate::engine::{EngineError, EngineResult}; use crate::model::PostStatus; /// Normalize a language code for comparison (lowercase, strip region). @@ -53,7 +53,7 @@ pub struct TranslationValidationReport { } /// Per-item progress callback: (current_item, total_items, item_description). -pub type ItemProgressFn = Box; +pub type ItemProgressFn = Box bool + Send>; /// Validate all translations with optional per-item progress. pub fn validate_translations_with_progress( @@ -75,8 +75,10 @@ pub fn validate_translations_with_progress( let post_count = posts.len(); for (i, post) in posts.iter().enumerate() { - if let Some(ref cb) = on_item { - cb(i + 1, post_count, &post.title); + if let Some(ref cb) = on_item + && !cb(i + 1, post_count, &post.title) + { + return Err(EngineError::Cancelled); } let translations = post_translation::list_post_translations_by_post(conn, &post.id)?; @@ -170,8 +172,10 @@ pub fn validate_translations_with_progress( let path = entry.path(); let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - if let Some(ref cb) = on_item { - cb(i + 1, fs_total, stem); + if let Some(ref cb) = on_item + && !cb(i + 1, fs_total, stem) + { + return Err(EngineError::Cancelled); } checked_fs_files += 1; diff --git a/crates/bds-core/src/render/mod.rs b/crates/bds-core/src/render/mod.rs index 8520a0e..3b83f06 100644 --- a/crates/bds-core/src/render/mod.rs +++ b/crates/bds-core/src/render/mod.rs @@ -21,6 +21,7 @@ pub use routes::{ render_starter_list_page_with_media_map, render_starter_single_post_page, render_starter_single_post_page_with_media_map, }; +pub(crate) use site::count_site_render_pages_from_context; pub use site::{ PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, SiteRenderContext, build_preview_response, build_site_render_artifacts, build_site_render_artifacts_from_context, diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index c31e093..e385983 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -391,12 +391,7 @@ pub fn prepare_site_render_context( let linked_media_by_post_id = build_linked_media_by_post_id(&posts, &linked_media_by_source_post); let post_data_json_by_id = build_post_data_json_by_id(&posts, &linked_media_by_post_id); - let menu_items = build_menu_items( - data_dir, - &language, - &main_language, - &category_settings, - )?; + let menu_items = build_menu_items(data_dir, &language, &main_language, &category_settings)?; let canonical_post_path_by_slug = canonical_post_path_by_slug(&posts, &language, &main_language); let taxonomy = build_taxonomy_context(&posts, &tags); @@ -590,6 +585,39 @@ pub fn build_site_render_artifacts_from_context( Ok(artifacts) } +pub(crate) fn count_site_render_pages_from_context( + context: &SiteRenderContext, + section: GenerationSection, + requested_paths: Option<&HashSet>, +) -> usize { + context + .languages + .iter() + .map(|language_context| { + let expanded = requested_paths.map(|requested| { + expand_requested_aggregate_paths( + requested, + &language_context.posts, + &language_context.routes, + &language_context.language, + &context.metadata, + ) + }); + language_context + .routes + .iter() + .filter(|route| { + classify_generated_path(&route.relative_path, &context.metadata) + == Some(section) + && expanded + .as_ref() + .is_none_or(|requested| requested.contains(&route.relative_path)) + }) + .count() + }) + .sum() +} + pub fn build_preview_response( conn: &Connection, data_dir: &Path, diff --git a/crates/bds-core/src/scripting/core_host.rs b/crates/bds-core/src/scripting/core_host.rs index 7e30706..6bde460 100644 --- a/crates/bds-core/src/scripting/core_host.rs +++ b/crates/bds-core/src/scripting/core_host.rs @@ -1,5 +1,6 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use base64::Engine as _; use chrono::{SecondsFormat, TimeZone, Utc}; @@ -1215,33 +1216,39 @@ impl CoreHost { "get" => { let id = task_id_arg(args, 0)?; manager - .snapshots() - .into_iter() - .find(|task| task.id == id) + .get(id) .map(public_task) .transpose() .map(|value| value.unwrap_or(Value::Null)) } - "get_all" => public_tasks(manager.snapshots()), - "get_running" => public_tasks( - manager - .snapshots() - .into_iter() - .filter(|task| task.status == TaskStatus::Running) - .collect(), - ), + "get_all" => public_tasks(manager.all()), + "get_running" => public_tasks(manager.running()), "status_snapshot" => { let tasks = manager.snapshots(); + let active = tasks + .iter() + .filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running)) + .collect::>(); + let running_task_message = active.first().map(|task| { + if task.status == TaskStatus::Pending { + format!("Queued: {}", task.label) + } else if let Some(message) = + task.message.as_deref().filter(|value| !value.is_empty()) + { + format!("{}: {message}", task.label) + } else { + task.label.clone() + } + }); Ok(json!({ - "active_count": tasks.iter().filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running)).count(), + "active_count": active.len(), "running_count": manager.running_count(), "pending_count": manager.pending_count(), + "running_task_message": running_task_message, + "running_task_overflow": active.len().saturating_sub(1), "tasks": public_tasks(tasks)?, })) } - "cancel" => { - manager.cancel(task_id_arg(args, 0)?); - Ok(Value::Bool(true)) - } + "cancel" => Ok(Value::Bool(manager.cancel(task_id_arg(args, 0)?))), "clear_completed" => { manager.clear_completed(); Ok(Value::Bool(true)) @@ -1355,10 +1362,17 @@ impl CoreHost { } let manager = self.task_manager.clone(); let task_id = self.task_id; - engine::publishing::upload_site( + let cancel_flag = manager + .as_ref() + .zip(task_id) + .and_then(|(manager, task_id)| manager.cancellation_flag(task_id)) + .unwrap_or_else(|| Arc::new(AtomicBool::new(false))); + let progress_cancel_flag = Arc::clone(&cancel_flag); + engine::publishing::upload_site_cancellable( &self.data_dir, &self.private_cache_dir, &preferences, + cancel_flag.as_ref(), move |current, total, _| { if let (Some(manager), Some(task_id)) = (&manager, task_id) { manager.report_progress( @@ -1367,6 +1381,7 @@ impl CoreHost { Some("uploading site".into()), ); } + !progress_cancel_flag.load(Ordering::Acquire) }, )?; match (&self.task_manager, self.task_id) { @@ -1659,6 +1674,8 @@ fn public_tag(value: Tag) -> HostResult { } fn public_task(value: TaskSnapshot) -> HostResult { + let cancellable = matches!(value.status, TaskStatus::Pending | TaskStatus::Running) + && !value.cancellation_requested; let (status, error) = match value.status { TaskStatus::Pending => ("pending", None), TaskStatus::Running => ("running", None), @@ -1668,6 +1685,9 @@ fn public_task(value: TaskSnapshot) -> HostResult { }; Ok( json!({"id":value.id.to_string(),"name":value.label,"status":status, + "group_id":value.group_id,"group_name":value.group_name, + "cancellable":cancellable, + "cancellation_requested":value.cancellation_requested, "progress":value.progress,"message":value.message.or(error)}), ) } diff --git a/crates/bds-core/src/scripting/manifest.rs b/crates/bds-core/src/scripting/manifest.rs index b81b292..79f0059 100644 --- a/crates/bds-core/src/scripting/manifest.rs +++ b/crates/bds-core/src/scripting/manifest.rs @@ -395,6 +395,10 @@ fn field_description(field: &str) -> &'static str { "updated" => "Whether the Git network command completed successfully.", "progress" => "Completion value reported by the task.", "message" => "Latest user-facing task message.", + "cancellable" => "Whether a cancellation request can still be submitted.", + "cancellation_requested" => "Whether cooperative cancellation is in progress.", + "group_id" => "Identifier shared by tasks in one workflow.", + "group_name" => "Human-readable workflow name.", "created_at" => "Creation timestamp.", "updated_at" => "Last-update timestamp.", "language" | "main_language" => "BCP 47 language code.", diff --git a/crates/bds-server/src/host.rs b/crates/bds-server/src/host.rs index 26da9a8..b1e61a3 100644 --- a/crates/bds-server/src/host.rs +++ b/crates/bds-server/src/host.rs @@ -408,6 +408,8 @@ impl ProtocolError { } fn remote_task(snapshot: TaskSnapshot) -> RemoteTask { + let cancellable = matches!(snapshot.status, TaskStatus::Pending | TaskStatus::Running) + && !snapshot.cancellation_requested; let (status, failure) = match snapshot.status { TaskStatus::Pending => ("pending", None), TaskStatus::Running => ("running", None), @@ -418,6 +420,10 @@ fn remote_task(snapshot: TaskSnapshot) -> RemoteTask { RemoteTask { id: snapshot.id, label: snapshot.label, + group_id: snapshot.group_id, + group_name: snapshot.group_name, + cancellation_requested: snapshot.cancellation_requested, + cancellable, status: status.to_owned(), progress: snapshot.progress, message: snapshot.message.or(failure), @@ -610,7 +616,11 @@ mod tests { let fixture = Fixture::new(); let mut session = fixture.session(); let _ = session.pending(); - let task = fixture.host.tasks().submit("Generate site"); + let task = + fixture + .host + .tasks() + .submit_grouped("Generate site", "generation-1", "Generate Site"); fixture .host .tasks() @@ -618,7 +628,11 @@ mod tests { let update = session.pending(); assert!(matches!( update.as_slice(), - [ServerMessage::Tasks { tasks, .. }] if tasks[0].progress == Some(0.5) + [ServerMessage::Tasks { tasks, .. }] + if tasks[0].progress == Some(0.5) + && tasks[0].group_id.as_deref() == Some("generation-1") + && tasks[0].group_name.as_deref() == Some("Generate Site") + && tasks[0].cancellable )); assert!( session diff --git a/crates/bds-server/src/protocol.rs b/crates/bds-server/src/protocol.rs index c136425..c70f114 100644 --- a/crates/bds-server/src/protocol.rs +++ b/crates/bds-server/src/protocol.rs @@ -57,7 +57,12 @@ pub enum ServerMessage { pub struct RemoteTask { pub id: u64, pub label: String, + pub group_id: Option, + pub group_name: Option, pub status: String, pub progress: Option, pub message: Option, + #[serde(default)] + pub cancellation_requested: bool, + pub cancellable: bool, } diff --git a/crates/bds-server/src/tui.rs b/crates/bds-server/src/tui.rs index bf28aa8..46cf037 100644 --- a/crates/bds-server/src/tui.rs +++ b/crates/bds-server/src/tui.rs @@ -2,12 +2,14 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::io; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::sync::mpsc; use std::time::Duration; use anyhow::{Result, anyhow, bail}; use bds_core::db::Database; use bds_core::engine::ai::{AiEndpointConfig, AiEndpointKind}; +use bds_core::engine::task::{TaskId, TaskManager}; use bds_core::engine::{self, domain_events}; use bds_core::i18n::{UiLocale, normalize_language}; use bds_core::model::metadata::ProjectMetadata; @@ -600,7 +602,7 @@ impl TuiApp { fn queue_task( &mut self, label: &str, - work: impl FnOnce() -> Result + Send + 'static, + work: impl FnOnce(Arc, TaskId) -> Result + Send + 'static, ) { let tasks = self.host.tasks(); let task_id = tasks.submit(label); @@ -608,22 +610,32 @@ impl TuiApp { self.started_task_ids.insert(task_id); self.status = self.tr_with("tui.taskRunning", &[("label", label)]); std::thread::spawn(move || { - if !tasks.wait_until_runnable(task_id) { + let Some(worker) = tasks.admit_blocking(task_id) else { return; - } - match work() { + }; + let work_tasks = Arc::clone(&tasks); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + work(work_tasks, task_id) + })) + .map_err(|_| anyhow!("background task panicked")) + .and_then(|result| result); + drop(worker); + match result { Ok(result) => { tasks.complete(task_id); let _ = sender.send(result); } Err(error) => { + let cancelled = tasks.is_cancelled(task_id); let message = error.to_string(); tasks.fail(task_id, message.clone()); - let _ = sender.send(BackgroundResult { - status: message, - panel: None, - reload: false, - }); + if !cancelled { + let _ = sender.send(BackgroundResult { + status: message, + panel: None, + reload: false, + }); + } } } }); @@ -636,15 +648,19 @@ impl TuiApp { } match task.status { engine::task::TaskStatus::Pending | engine::task::TaskStatus::Running => { - self.status = match task.progress { - Some(progress) => self.tr_with( - "tui.taskProgress", - &[ - ("label", &task.label), - ("percent", &format!("{:.0}", progress * 100.0)), - ], - ), - None => task.label, + self.status = if task.cancellation_requested { + self.tr_with("tui.taskCancelling", &[("label", &task.label)]) + } else { + match task.progress { + Some(progress) => self.tr_with( + "tui.taskProgress", + &[ + ("label", &task.label), + ("percent", &format!("{:.0}", progress * 100.0)), + ], + ), + None => task.label, + } }; } engine::task::TaskStatus::Completed if self.completed_task_ids.insert(task.id) => { @@ -1550,7 +1566,10 @@ impl TuiApp { let message = prompt.value; let locale = self.locale; let label = self.tr("tui.taskGitCommit"); - self.queue_task(&label, move || { + self.queue_task(&label, move |tasks, task_id| { + if tasks.is_cancelled(task_id) { + return Err(anyhow!("operation cancelled")); + } let output = engine::git::GitEngine::new(&data_dir) .commit_all(&message)? .output; @@ -2730,10 +2749,12 @@ impl TuiApp { let data_dir = self.data_dir()?.to_owned(); let locale = self.locale; let label = self.tr("tui.taskGitPull"); - self.queue_task(&label, move || { + self.queue_task(&label, move |tasks, task_id| { let mut output = Vec::new(); - let result = engine::git::GitEngine::new(&data_dir) - .pull(|| false, |line| output.push(line.text))?; + let result = engine::git::GitEngine::new(&data_dir).pull( + || tasks.is_cancelled(task_id), + |line| output.push(line.text), + )?; output.push(result.output); Ok(BackgroundResult { status: bds_core::i18n::translate_with( @@ -2759,10 +2780,12 @@ impl TuiApp { let data_dir = self.data_dir()?.to_owned(); let locale = self.locale; let label = self.tr("tui.taskGitPush"); - self.queue_task(&label, move || { + self.queue_task(&label, move |tasks, task_id| { let mut output = Vec::new(); - let result = engine::git::GitEngine::new(&data_dir) - .push(|| false, |line| output.push(line.text))?; + let result = engine::git::GitEngine::new(&data_dir).push( + || tasks.is_cancelled(task_id), + |line| output.push(line.text), + )?; output.push(result.output); Ok(BackgroundResult { status: bds_core::i18n::translate_with( @@ -2807,13 +2830,26 @@ impl TuiApp { "metadata-diff" => { let locale = self.locale; let label = self.tr("tui.commandMetadataDiff"); - self.queue_task(&label, move || { + self.queue_task(&label, move |tasks, task_id| { let db = Database::open(&database_path)?; + let report = engine::metadata_diff::compute_metadata_diff_with_progress( + db.conn(), + &data_dir, + &project_id, + |current, total| { + tasks.report_progress( + task_id, + Some(current as f32 / total.max(1) as f32), + Some(bds_core::i18n::translate(locale, "tui.commandMetadataDiff")), + ); + !tasks.is_cancelled(task_id) + }, + )?; Ok(BackgroundResult { status: bds_core::i18n::translate(locale, "tui.metadataDiffComplete"), panel: Some(Panel::Report { title: bds_core::i18n::translate(locale, "menu.item.metadataDiff"), - body: metadata_report_body(db.conn(), &data_dir, &project_id, locale)?, + body: metadata_report_body_from_report(&report, locale), action: ReportAction::MetadataDiff, }), reload: false, @@ -2823,13 +2859,26 @@ impl TuiApp { "validate-site" => { let locale = self.locale; let label = self.tr("tui.commandValidateSite"); - self.queue_task(&label, move || { + self.queue_task(&label, move |tasks, task_id| { let db = Database::open(&database_path)?; + let report = engine::validate_site::validate_site_with_progress( + db.conn(), + &data_dir, + &project_id, + |current, total| { + tasks.report_progress( + task_id, + Some(current as f32 / total.max(1) as f32), + Some(bds_core::i18n::translate(locale, "tui.commandValidateSite")), + ); + !tasks.is_cancelled(task_id) + }, + )?; Ok(BackgroundResult { status: bds_core::i18n::translate(locale, "tui.siteValidationComplete"), panel: Some(Panel::Report { title: bds_core::i18n::translate(locale, "menu.item.validateSite"), - body: site_validation_body(db.conn(), &data_dir, &project_id, locale)?, + body: site_validation_body_from_report(&report, locale), action: ReportAction::SiteValidation, }), reload: false, @@ -2839,18 +2888,86 @@ impl TuiApp { "force-render" => { let locale = self.locale; let label = self.tr("tui.commandForceRender"); - self.queue_task(&label, move || { + self.queue_task(&label, move |tasks, task_id| { let db = Database::open(&database_path)?; let metadata = engine::meta::read_project_json(&data_dir)?; - let posts = published_sources(db.conn(), &data_dir, &project_id)?; - let report = engine::generation::generate_starter_site_forced( + let posts = + bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)?; + let total_posts = posts.len(); + let mut sources = Vec::new(); + for (index, post) in posts.into_iter().enumerate() { + tasks.report_progress( + task_id, + Some((index + 1) as f32 / total_posts.max(1) as f32 * 0.1), + Some(post.title.clone()), + ); + if tasks.is_cancelled(task_id) { + return Err(anyhow!("operation cancelled")); + } + if let Some(source) = + engine::generation::load_published_post_source(&data_dir, post)? + { + sources.push(source); + } + } + let prepared = engine::generation::prepare_site_generation( db.conn(), - &data_dir.join("html"), + &data_dir, &project_id, &metadata, - &posts, - metadata.main_language.as_deref().unwrap_or("en"), + &sources, )?; + let output_dir = data_dir.join("html"); + let mut report = engine::generation::GenerationReport::default(); + for (index, section) in engine::generation::GenerationSection::ALL + .into_iter() + .enumerate() + { + let progress_tasks = Arc::clone(&tasks); + let cancel_tasks = Arc::clone(&tasks); + report.append( + engine::generation::render_prepared_site_section_with_progress( + db.conn(), + &output_dir, + &project_id, + &prepared, + section, + true, + &|_| {}, + move |current, total, path| { + progress_tasks.report_progress( + task_id, + Some( + 0.1 + (index as f32 + + current as f32 / total.max(1) as f32) + / 6.0 + * 0.9, + ), + Some(path.to_string()), + ); + }, + move || cancel_tasks.is_cancelled(task_id), + )?, + ); + } + let progress_tasks = Arc::clone(&tasks); + let cancel_tasks = Arc::clone(&tasks); + report.append( + engine::generation::build_site_search_index_forced_with_progress( + db.conn(), + &output_dir, + &project_id, + &metadata, + move |current, total, path| { + progress_tasks.report_progress( + task_id, + Some(0.85 + current as f32 / total.max(1) as f32 * 0.15), + Some(path.to_string()), + ); + }, + move || cancel_tasks.is_cancelled(task_id), + )?, + ); Ok(BackgroundResult { status: bds_core::i18n::translate_with( locale, @@ -2865,12 +2982,21 @@ impl TuiApp { "rebuild-database" => { let locale = self.locale; let label = self.tr("tui.commandRebuildDatabase"); - self.queue_task(&label, move || { + self.queue_task(&label, move |tasks, task_id| { let db = Database::open(&database_path)?; - let report = engine::rebuild::rebuild_from_filesystem( + let progress_tasks = Arc::clone(&tasks); + let report = engine::rebuild::rebuild_from_filesystem_with_progress( db.conn(), &data_dir, &project_id, + Some(Arc::new(move |progress, event| { + progress_tasks.report_progress( + task_id, + Some(progress), + Some(event.localized(locale)), + ); + !progress_tasks.is_cancelled(task_id) + })), )?; let count = report.posts_created + report.posts_updated @@ -2894,9 +3020,21 @@ impl TuiApp { "reindex-search" => { let locale = self.locale; let label = self.tr("tui.commandReindexSearch"); - self.queue_task(&label, move || { + self.queue_task(&label, move |tasks, task_id| { let db = Database::open(&database_path)?; - let report = engine::search::reindex_project(db.conn(), &project_id, None)?; + let progress_tasks = Arc::clone(&tasks); + let report = engine::search::reindex_project( + db.conn(), + &project_id, + Some(Box::new(move |current, total, name| { + progress_tasks.report_progress( + task_id, + Some(current as f32 / total.max(1) as f32), + Some(name.to_string()), + ); + !progress_tasks.is_cancelled(task_id) + })), + )?; Ok(BackgroundResult { status: bds_core::i18n::translate_with( locale, @@ -3027,6 +3165,13 @@ fn metadata_report_body( locale: UiLocale, ) -> Result { let report = engine::metadata_diff::compute_metadata_diff(conn, data_dir, project_id)?; + Ok(metadata_report_body_from_report(&report, locale)) +} + +fn metadata_report_body_from_report( + report: &engine::metadata_diff::DiffReport, + locale: UiLocale, +) -> String { let mut lines = report .diffs .iter() @@ -3058,7 +3203,7 @@ fn metadata_report_body( "tui.noMetadataDifferences", )); } - Ok(lines.join("\n")) + lines.join("\n") } fn site_validation_body( @@ -3068,7 +3213,14 @@ fn site_validation_body( locale: UiLocale, ) -> Result { let report = engine::validate_site::validate_site(conn, data_dir, project_id)?; - Ok(bds_core::i18n::translate_with( + Ok(site_validation_body_from_report(&report, locale)) +} + +fn site_validation_body_from_report( + report: &engine::validate_site::SiteValidationReport, + locale: UiLocale, +) -> String { + bds_core::i18n::translate_with( locale, "tui.siteValidationReport", &[ @@ -3076,7 +3228,7 @@ fn site_validation_body( ("extra", &report.extra_pages.join("\n")), ("stale", &report.stale_pages.join("\n")), ], - )) + ) } impl TuiApp { diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 1a54d11..8b9b591 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -23,7 +23,7 @@ use crate::components::webview::{self, WebViewConfig, WebViewController}; use crate::i18n::{t, tw}; use crate::platform::menu::{self, MenuAction, MenuRegistry}; use crate::state::navigation::{ - OutputEntry, PanelTab, SidebarView, TaskSnapshot, handle_activity_click, + OutputEntry, PanelTab, SidebarView, TaskSnapshot, TaskSource, handle_activity_click, }; use crate::state::sidebar_filter::{CalendarMonth, CalendarYear, MediaFilter, PostFilter}; use crate::state::tabs::{self, Tab, TabType}; @@ -177,8 +177,22 @@ pub enum Message { // Tasks TaskTick, DomainEventsTick, - CancelTask(TaskId), + CancelTask(TaskSource, TaskId), + RemoteTaskCancelled(Result<(), String>), ToggleTaskGroup(String), + TagDeleted { + task_id: TaskId, + tag_id: String, + result: Result<(), String>, + }, + TagsMerged { + task_id: TaskId, + result: Result<(), String>, + }, + TagSaved { + task_id: TaskId, + result: Result<(), String>, + }, // macOS lifecycle FileOpenRequested(PathBuf), @@ -244,6 +258,7 @@ pub enum Message { ApplyAiSuggestions(modal::AiEntityTarget, Vec), OneShotAiFinished { + task_id: Option, entity_id: String, action: OneShotAiAction, result: Result, @@ -254,19 +269,31 @@ pub enum Message { ReindexText, RegenerateCalendar, ValidateTranslations, - TranslationValidationLoaded( - Result, - ), + TranslationValidationLoaded { + task_id: TaskId, + result: Result, + }, ValidateMedia, GenerateSite, ForceGenerateSite, RunMetadataDiff, - MetadataDiffLoaded(Result), + MetadataDiffLoaded { + task_id: TaskId, + result: Result, + }, RepairMetadataDiffItem { index: usize, direction: engine::metadata_diff::RepairDirection, }, - MetadataDiffItemRepaired(Result<(), String>), + MetadataDiffItemRepaired { + task_id: TaskId, + result: Result<(), String>, + }, + ImportMetadataOrphan(usize), + MetadataOrphanImported { + task_id: TaskId, + result: Result<(), String>, + }, RunSiteValidation, ApplySiteValidation, EngineTaskDone { @@ -280,14 +307,37 @@ pub enum Message { task_id: TaskId, result: Result, }, + SiteGenerationPrepared { + task_id: TaskId, + validation: Option, + force: bool, + result: Result< + ( + Arc, + HashMap, + ), + String, + >, + }, SiteGenerationIndexDone { group_id: String, task_id: TaskId, result: Result, }, - SiteValidationLoaded(Result), + SiteGenerationCalendarDone { + group_id: String, + task_id: TaskId, + result: Result<(), String>, + }, + SiteValidationLoaded { + task_id: TaskId, + result: Result, + }, DuplicatesRefresh, - DuplicatesLoaded(Result), + DuplicatesLoaded { + task_id: TaskId, + result: Result, + }, DuplicatesToggle(String, String), DuplicatesCheckAll, DuplicatesUncheckAll, @@ -434,7 +484,7 @@ enum SiteGenerationKind { Validation, } -#[derive(Debug, Clone)] +#[derive(Clone)] struct SiteGenerationWorkflow { kind: SiteGenerationKind, db_path: PathBuf, @@ -442,6 +492,8 @@ struct SiteGenerationWorkflow { data_dir: PathBuf, group_name: String, render_task_ids: Vec, + calendar_needed: bool, + calendar_task_id: Option, index_task_id: Option, report: engine::generation::GenerationReport, } @@ -987,6 +1039,7 @@ pub struct BdsApp { chat_events: std::sync::mpsc::Receiver, script_menu_actions: Arc>>, task_snapshots: Vec, + remote_task_snapshots: Vec, collapsed_task_groups: HashSet, output_entries: Vec, search_index_rebuild_required: bool, @@ -1198,6 +1251,7 @@ impl BdsApp { chat_events: engine::chat::subscribe_events(), script_menu_actions: Arc::new(Mutex::new(Vec::new())), task_snapshots: Vec::new(), + remote_task_snapshots: Vec::new(), collapsed_task_groups: HashSet::new(), output_entries: Vec::new(), search_index_rebuild_required, @@ -1301,6 +1355,7 @@ impl BdsApp { chat_events: engine::chat::subscribe_events(), script_menu_actions: Arc::new(Mutex::new(Vec::new())), task_snapshots: Vec::new(), + remote_task_snapshots: Vec::new(), collapsed_task_groups: HashSet::new(), output_entries: Vec::new(), search_index_rebuild_required: false, @@ -2429,8 +2484,13 @@ impl BdsApp { let next = self.duplicates_state.page.saturating_add(1); self.start_duplicate_search(next) } - Message::DuplicatesLoaded(result) => { + Message::DuplicatesLoaded { task_id, result } => { + let cancelled = self.finish_result_task(task_id, &result); + self.refresh_task_snapshots(); self.duplicates_state.is_loading = false; + if cancelled { + return Task::none(); + } self.duplicates_state.has_run = true; match result { Ok(result) => { @@ -2600,12 +2660,103 @@ impl BdsApp { Task::batch(tasks) } Message::DomainEventsTick => self.process_domain_events(), - Message::CancelTask(task_id) => { - if self.cancel_site_generation_task(task_id) { + Message::CancelTask(TaskSource::Local, task_id) => { + if !self.cancel_site_generation_task(task_id) { + self.task_manager.cancel(task_id); + if let Some(modal::ModalState::SearchIndexRebuilding { + task_id: rebuild_task_id, + cancellation_requested, + }) = self.active_modal.as_mut() + && *rebuild_task_id == task_id + { + *cancellation_requested = true; + } + self.refresh_task_snapshots(); + } + Task::none() + } + Message::CancelTask(TaskSource::Remote, task_id) => { + let Some(client) = self.remote_client.clone() else { + return Task::none(); + }; + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + client + .call("tasks", "cancel", vec![json!(task_id.to_string())]) + .map(|_| ()) + .map_err(|error| error.to_string()) + }) + .await + .unwrap_or_else(|error| Err(error.to_string())) + }, + Message::RemoteTaskCancelled, + ) + } + Message::RemoteTaskCancelled(result) => { + if let Err(error) = result { + self.notify(ToastLevel::Error, &error); + } + Task::none() + } + Message::TagDeleted { + task_id, + tag_id, + result, + } => { + let cancelled = self.finish_result_task(task_id, &result); + self.refresh_task_snapshots(); + if cancelled { return Task::none(); } - self.task_manager.cancel(task_id); + match result { + Ok(()) => { + self.reload_tags_state(); + if let Some(state) = self.tags_view_state.as_mut() + && state.editing_tag.as_ref().map(|tag| tag.id.as_str()) + == Some(tag_id.as_str()) + { + state.editing_tag = None; + } + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); + } + Err(error) => self.notify_operation_failed("modal.confirmDelete.delete", error), + } + Task::none() + } + Message::TagsMerged { task_id, result } => { + let cancelled = self.finish_result_task(task_id, &result); self.refresh_task_snapshots(); + if cancelled { + return Task::none(); + } + match result { + Ok(()) => { + self.reload_tags_state(); + if let Some(state) = self.tags_view_state.as_mut() { + state.selected_tags.clear(); + state.merge_target = None; + state.editing_tag = None; + } + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + Err(error) => self.notify_operation_failed("tags.merge", error), + } + Task::none() + } + Message::TagSaved { task_id, result } => { + let cancelled = self.finish_result_task(task_id, &result); + self.refresh_task_snapshots(); + if cancelled { + return Task::none(); + } + match result { + Ok(()) => { + self.reload_tags_state(); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + Err(error) => self.notify_operation_failed("common.save", error), + } Task::none() } Message::ToggleTaskGroup(group_id) => { @@ -2707,10 +2858,11 @@ impl BdsApp { self.refresh_task_snapshots(); let import_task = Task::perform( async move { + let Some(worker) = task_manager.admit(task_id).await else { + return Err("cancelled".to_string()); + }; tokio::task::spawn_blocking(move || { - if !task_manager.wait_until_runnable(task_id) { - return Err("cancelled".to_string()); - } + let _worker = worker; let db = Database::open(&db_path).map_err(|error| error.to_string())?; let host = bds_core::scripting::CoreHost::new(db_path, &project_id, &data_dir) @@ -2855,20 +3007,24 @@ impl BdsApp { | Message::ReindexText | Message::RegenerateCalendar | Message::ValidateTranslations - | Message::TranslationValidationLoaded(_) + | Message::TranslationValidationLoaded { .. } | Message::ValidateMedia | Message::GenerateSite | Message::ForceGenerateSite | Message::RunMetadataDiff - | Message::MetadataDiffLoaded(_) + | Message::MetadataDiffLoaded { .. } | Message::RepairMetadataDiffItem { .. } - | Message::MetadataDiffItemRepaired(_) + | Message::MetadataDiffItemRepaired { .. } + | Message::ImportMetadataOrphan(_) + | Message::MetadataOrphanImported { .. } | Message::RunSiteValidation | Message::ApplySiteValidation | Message::EngineTaskDone { .. } + | Message::SiteGenerationPrepared { .. } | Message::SiteGenerationSectionDone { .. } + | Message::SiteGenerationCalendarDone { .. } | Message::SiteGenerationIndexDone { .. } - | Message::SiteValidationLoaded(_)) => self.handle_engine_message(message), + | Message::SiteValidationLoaded { .. }) => self.handle_engine_message(message), // ── Git ── message @ (Message::GitRefresh @@ -3154,6 +3310,8 @@ impl BdsApp { client.close(); } self.remote_projects.clear(); + self.remote_task_snapshots.clear(); + self.refresh_task_snapshots(); self.remote_project = None; self.remote_display_name = None; self.active_modal = None; @@ -3237,10 +3395,17 @@ impl BdsApp { self.apply_ai_suggestions(target, &fields) } Message::OneShotAiFinished { + task_id, entity_id, action, result, - } => self.finish_one_shot_ai(&entity_id, action, result), + } => { + if let Some(task_id) = task_id { + self.finish_result_task(task_id, &result); + self.refresh_task_snapshots(); + } + self.finish_one_shot_ai(&entity_id, action, result) + } // ── Editor view messages ── Message::PostEditor(msg) => self.handle_post_editor_msg(msg), @@ -3350,7 +3515,7 @@ impl BdsApp { } fn start_duplicate_search(&mut self, page: usize) -> Task { - let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else { + let Some(data_dir) = &self.data_dir else { return Task::none(); }; self.duplicates_state.enabled = engine::meta::read_project_json(data_dir) @@ -3364,21 +3529,26 @@ impl BdsApp { return Task::none(); } self.duplicates_state.is_loading = true; - let db_path = self.db_path.clone(); - let data_dir = data_dir.clone(); - let project_id = project.id.clone(); - Task::perform( - async move { - tokio::task::spawn_blocking(move || { - let db = Database::open(&db_path).map_err(|error| error.to_string())?; - engine::embedding::EmbeddingService::production(db.conn(), &data_dir) - .find_duplicates(&project_id, page) - .map_err(|error| error.to_string()) - }) - .await - .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) + let locale = self.ui_locale; + self.spawn_result_task( + "tabBar.findDuplicates", + move |db_path, project_id, data_dir, tm, tid| { + tm.report_progress(tid, Some(0.0), Some(t(locale, "duplicates.searching"))); + if tm.is_cancelled(tid) { + return Err("operation cancelled".into()); + } + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + tm.report_progress(tid, Some(0.25), Some(t(locale, "duplicates.searching"))); + let result = engine::embedding::EmbeddingService::production(db.conn(), &data_dir) + .find_duplicates(&project_id, page) + .map_err(|error| error.to_string())?; + if tm.is_cancelled(tid) { + return Err("operation cancelled".into()); + } + tm.report_progress(tid, Some(1.0), Some(t(locale, "duplicates.complete"))); + Ok(result) }, - Message::DuplicatesLoaded, + |task_id, result| Message::DuplicatesLoaded { task_id, result }, ) } @@ -4039,11 +4209,34 @@ impl BdsApp { )); } bds_server::protocol::ServerMessage::Tasks { tasks, .. } => { - self.add_output(&tw( - self.ui_locale, - "remoteConnection.taskUpdate", - &[("count", &tasks.len().to_string())], - )); + self.remote_task_snapshots = tasks + .into_iter() + .filter_map(|task| { + let status = match task.status.as_str() { + "pending" => TaskStatus::Pending, + "running" => TaskStatus::Running, + "completed" => TaskStatus::Completed, + "cancelled" => TaskStatus::Cancelled, + "failed" => TaskStatus::Failed( + task.message.clone().unwrap_or_else(|| "failed".into()), + ), + _ => return None, + }; + Some(TaskSnapshot { + id: task.id, + source: TaskSource::Remote, + label: task.label, + group_id: task.group_id.map(|id| format!("remote:{id}")), + group_name: task.group_name, + status, + progress: task.progress, + message: task.message, + cancellation_requested: task.cancellation_requested, + is_cancellable: task.cancellable, + }) + }) + .collect(); + self.refresh_task_snapshots(); } bds_server::protocol::ServerMessage::Error { code, message, .. } => { self.notify(ToastLevel::Error, &message); @@ -4051,6 +4244,8 @@ impl BdsApp { self.remote_client = None; self.remote_project = None; self.remote_projects.clear(); + self.remote_task_snapshots.clear(); + self.refresh_task_snapshots(); self.remote_display_name = None; self.menu_registry .set_enabled(MenuAction::DisconnectServer, false); @@ -4502,10 +4697,14 @@ impl BdsApp { let cache_dir = db_path.parent().map(PathBuf::from).ok_or_else(|| { "private application directory unavailable".to_string() })?; - let job = engine::publishing::upload_site( + let cancel_flag = task_manager + .cancellation_flag(task_id) + .ok_or_else(|| "task cancellation unavailable".to_string())?; + let job = engine::publishing::upload_site_cancellable( &data_dir, &cache_dir, &preferences, + &cancel_flag, |current, total, kind| { let target = match kind { engine::publishing::UploadTargetKind::Html => "html", @@ -4523,6 +4722,7 @@ impl BdsApp { &[("target", target)], )), ); + !task_manager.is_cancelled(task_id) }, ) .map_err(|error| error.to_string())?; @@ -5399,61 +5599,80 @@ impl BdsApp { Some(t(self.ui_locale, "engine.generateSiteNoProject")); return Task::none(); }; - let Some(project_id) = self - .active_project - .as_ref() - .map(|project| project.id.clone()) - else { + if self.active_project.is_none() { self.site_validation_state.is_running = false; self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.generateSiteNoProject")); return Task::none(); - }; - let Some(data_dir) = self.data_dir.clone() else { + } + if self.data_dir.is_none() { self.site_validation_state.is_running = false; self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.previewDataDirUnavailable")); return Task::none(); - }; + } self.site_validation_state.is_running = true; self.site_validation_state.error_message = None; - let db_path = self.db_path.clone(); - - Task::perform( - async move { + let locale = self.ui_locale; + self.spawn_result_task( + "menu.item.validateSite", + move |db_path, project_id, data_dir, tm, tid| { + tm.report_progress(tid, Some(0.0), Some(t(locale, "siteValidation.running"))); let db = Database::open(&db_path).map_err(|error| error.to_string())?; - engine::validate_site::validate_site(db.conn(), &data_dir, &project_id) - .map_err(|error| error.to_string()) + let report = engine::validate_site::validate_site_with_progress( + db.conn(), + &data_dir, + &project_id, + |current, total| { + tm.report_progress( + tid, + Some(current as f32 / total.max(1) as f32), + Some(t(locale, "siteValidation.running")), + ); + !tm.is_cancelled(tid) + }, + ) + .map_err(|error| error.to_string())?; + tm.report_progress(tid, Some(1.0), Some(t(locale, "siteValidation.complete"))); + Ok(report) }, - Message::SiteValidationLoaded, + |task_id, result| Message::SiteValidationLoaded { task_id, result }, ) } fn start_metadata_diff(&mut self) -> Task { - let (Some(project), Some(data_dir)) = - (self.active_project.as_ref(), self.data_dir.as_ref()) - else { + if self.active_project.is_none() || self.data_dir.is_none() { self.metadata_diff_state.error_message = Some(t(self.ui_locale, "engine.generateSiteNoProject")); return Task::none(); - }; + } self.metadata_diff_state.is_running = true; self.metadata_diff_state.error_message = None; - let db_path = self.db_path.clone(); - let project_id = project.id.clone(); - let data_dir = data_dir.clone(); - Task::perform( - async move { - tokio::task::spawn_blocking(move || { - let db = Database::open(&db_path).map_err(|error| error.to_string())?; - engine::metadata_diff::compute_metadata_diff(db.conn(), &data_dir, &project_id) - .map_err(|error| error.to_string()) - }) - .await - .map_err(|error| error.to_string())? + let locale = self.ui_locale; + self.spawn_result_task( + "menu.item.metadataDiff", + move |db_path, project_id, data_dir, tm, tid| { + tm.report_progress(tid, Some(0.0), Some(t(locale, "metadataDiff.running"))); + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + let report = engine::metadata_diff::compute_metadata_diff_with_progress( + db.conn(), + &data_dir, + &project_id, + |current, total| { + tm.report_progress( + tid, + Some(current as f32 / total.max(1) as f32), + Some(t(locale, "metadataDiff.running")), + ); + !tm.is_cancelled(tid) + }, + ) + .map_err(|error| error.to_string())?; + tm.report_progress(tid, Some(1.0), Some(t(locale, "metadataDiff.complete"))); + Ok(report) }, - Message::MetadataDiffLoaded, + |task_id, result| Message::MetadataDiffLoaded { task_id, result }, ) } @@ -6113,10 +6332,11 @@ impl BdsApp { let message_request = request.clone(); Task::perform( async move { + let Some(worker) = task_manager.admit(task_id).await else { + return Err("cancelled".to_string()); + }; tokio::task::spawn_blocking(move || { - if !task_manager.wait_until_runnable(task_id) { - return Err("cancelled".to_string()); - } + let _worker = worker; task_manager.report_progress( task_id, Some(0.0), @@ -6268,10 +6488,11 @@ impl BdsApp { let task_manager = Arc::clone(&self.task_manager); Task::perform( async move { + let Some(worker) = task_manager.admit(task_id).await else { + return Err("cancelled".to_string()); + }; tokio::task::spawn_blocking(move || { - if !task_manager.wait_until_runnable(task_id) { - return Err("cancelled".to_string()); - } + let _worker = worker; let db = Database::open(&db_path).map_err(|error| error.to_string())?; engine::gallery_import::enrich_imported_image( db.conn(), @@ -7501,54 +7722,50 @@ impl BdsApp { } fn delete_tag(&mut self, tag_id: &str) -> Task { - let Some(db) = &self.db else { - return Task::none(); - }; - let Some(data_dir) = &self.data_dir else { - return Task::none(); - }; - let Some(project) = &self.active_project else { - return Task::none(); - }; - match engine::tag::delete_tag(db.conn(), data_dir, &project.id, tag_id) { - Ok(()) => { - self.reload_tags_state(); - if let Some(state) = self.tags_view_state.as_mut() - && state.editing_tag.as_ref().map(|tag| tag.id.as_str()) == Some(tag_id) - { - state.editing_tag = None; + let tag_id = tag_id.to_string(); + let message_tag_id = tag_id.clone(); + let locale = self.ui_locale; + self.spawn_result_task( + "modal.confirmDelete.delete", + move |db_path, project_id, data_dir, tm, tid| { + tm.report_progress( + tid, + Some(0.0), + Some(t(locale, "modal.confirmDelete.delete")), + ); + if tm.is_cancelled(tid) { + return Err("operation cancelled".into()); } - self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); - } - Err(e) => self.notify_operation_failed("modal.confirmDelete.delete", e), - } - Task::none() + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + engine::tag::delete_tag(db.conn(), &data_dir, &project_id, &tag_id) + .map_err(|error| error.to_string()) + }, + move |task_id, result| Message::TagDeleted { + task_id, + tag_id: message_tag_id.clone(), + result, + }, + ) } fn merge_tags(&mut self, sources: &[String], target: &str) -> Task { - let Some(db) = &self.db else { - return Task::none(); - }; - let Some(data_dir) = &self.data_dir else { - return Task::none(); - }; - let Some(project) = &self.active_project else { - return Task::none(); - }; - let source_refs = sources.iter().map(String::as_str).collect::>(); - match engine::tag::merge_tags(db.conn(), data_dir, &project.id, &source_refs, target) { - Ok(()) => { - self.reload_tags_state(); - if let Some(state) = self.tags_view_state.as_mut() { - state.selected_tags.clear(); - state.merge_target = None; - state.editing_tag = None; + let sources = sources.to_vec(); + let target = target.to_string(); + let locale = self.ui_locale; + self.spawn_result_task( + "tags.merge", + move |db_path, project_id, data_dir, tm, tid| { + tm.report_progress(tid, Some(0.0), Some(t(locale, "tags.merge"))); + if tm.is_cancelled(tid) { + return Err("operation cancelled".into()); } - self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); - } - Err(e) => self.notify_operation_failed("tags.merge", e), - } - Task::none() + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + let source_refs = sources.iter().map(String::as_str).collect::>(); + engine::tag::merge_tags(db.conn(), &data_dir, &project_id, &source_refs, &target) + .map_err(|error| error.to_string()) + }, + |task_id, result| Message::TagsMerged { task_id, result }, + ) } fn hydrate_settings_state(&self) -> SettingsViewState { @@ -7819,39 +8036,42 @@ impl BdsApp { } } TagsMsg::SaveTag => { - if let Some(editing) = state.editing_tag.clone() - && let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) - { - let rename_result = if editing.name != editing.original_name { - engine::tag::rename_tag( - db.conn(), - data_dir, - self.active_project - .as_ref() - .map(|project| project.id.as_str()) - .unwrap_or_default(), - &editing.id, - &editing.name, - ) - } else { - Ok(()) - }; - match rename_result.and_then(|_| { - engine::tag::update_tag( - db.conn(), - data_dir, - &editing.id, - None, - Some(&editing.color), - Some(&editing.template_slug), - ) - }) { - Ok(()) => { - self.reload_tags_state(); - self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); - } - Err(e) => self.notify_operation_failed("common.save", e), - } + if let Some(editing) = state.editing_tag.clone() { + let locale = self.ui_locale; + return self.spawn_result_task( + "common.save", + move |db_path, project_id, data_dir, tm, tid| { + tm.report_progress(tid, Some(0.0), Some(t(locale, "common.save"))); + if tm.is_cancelled(tid) { + return Err("operation cancelled".into()); + } + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + if editing.name != editing.original_name { + engine::tag::rename_tag( + db.conn(), + &data_dir, + &project_id, + &editing.id, + &editing.name, + ) + .map_err(|error| error.to_string())?; + tm.report_progress(tid, Some(0.5), Some(t(locale, "common.save"))); + if tm.is_cancelled(tid) { + return Err("operation cancelled".into()); + } + } + engine::tag::update_tag( + db.conn(), + &data_dir, + &editing.id, + None, + Some(&editing.color), + Some(&editing.template_slug), + ) + .map_err(|error| error.to_string()) + }, + |task_id, result| Message::TagSaved { task_id, result }, + ); } } TagsMsg::DeleteTag(id) => { @@ -8502,16 +8722,39 @@ impl BdsApp { state.semantic_similarity_enabled = value; } SettingsMsg::RebuildPosts => { + let locale = self.ui_locale; return self.spawn_engine_task( "settings.rebuildPosts", - |db_path, project_id, data_dir, _tm, _tid| { + move |db_path, project_id, data_dir, tm, tid| { let db = Database::open(&db_path).map_err(|e| e.to_string())?; - let report = engine::post::rebuild_posts_from_filesystem( + let progress = Arc::clone(&tm); + let report = engine::post::rebuild_posts_from_filesystem_with_progress( db.conn(), &data_dir, &project_id, + Some(Box::new(move |current, total, name| { + progress.report_progress( + tid, + Some(current as f32 / total.max(1) as f32), + Some(tw( + locale, + "engine.checkingItem", + &[ + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ("name", name), + ], + )), + ); + !progress.is_cancelled(tid) + })), ) .map_err(|e| e.to_string())?; + tm.report_progress( + tid, + Some(1.0), + Some(t(locale, "engine.progress.rebuildComplete")), + ); Ok(format!( "created={}, updated={}, translations={}", report.posts_created, @@ -8522,16 +8765,39 @@ impl BdsApp { ); } SettingsMsg::RebuildMedia => { + let locale = self.ui_locale; return self.spawn_engine_task( "settings.rebuildMedia", - |db_path, project_id, data_dir, _tm, _tid| { + move |db_path, project_id, data_dir, tm, tid| { let db = Database::open(&db_path).map_err(|e| e.to_string())?; - let report = engine::media::rebuild_media_from_filesystem( + let progress = Arc::clone(&tm); + let report = engine::media::rebuild_media_from_filesystem_with_progress( db.conn(), &data_dir, &project_id, + Some(Box::new(move |current, total, name| { + progress.report_progress( + tid, + Some(current as f32 / total.max(1) as f32), + Some(tw( + locale, + "engine.checkingItem", + &[ + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ("name", name), + ], + )), + ); + !progress.is_cancelled(tid) + })), ) .map_err(|e| e.to_string())?; + tm.report_progress( + tid, + Some(1.0), + Some(t(locale, "engine.progress.rebuildComplete")), + ); Ok(format!( "created={}, updated={}, translations={}", report.media_created, @@ -8542,16 +8808,45 @@ impl BdsApp { ); } SettingsMsg::RebuildScripts => { + let locale = self.ui_locale; return self.spawn_engine_task( "settings.rebuildScripts", - |db_path, project_id, data_dir, _tm, _tid| { + move |db_path, project_id, data_dir, tm, tid| { + tm.report_progress( + tid, + Some(0.0), + Some(t(locale, "settings.rebuildScripts")), + ); let db = Database::open(&db_path).map_err(|e| e.to_string())?; - let report = engine::script_rebuild::rebuild_scripts_from_filesystem( - db.conn(), - &data_dir, - &project_id, - ) - .map_err(|e| e.to_string())?; + let progress = Arc::clone(&tm); + let report = + engine::script_rebuild::rebuild_scripts_from_filesystem_with_progress( + db.conn(), + &data_dir, + &project_id, + Some(Box::new(move |current, total, name| { + progress.report_progress( + tid, + Some(current as f32 / total.max(1) as f32), + Some(tw( + locale, + "engine.checkingItem", + &[ + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ("name", name), + ], + )), + ); + !progress.is_cancelled(tid) + })), + ) + .map_err(|e| e.to_string())?; + tm.report_progress( + tid, + Some(1.0), + Some(t(locale, "engine.progress.rebuildComplete")), + ); Ok(format!( "created={}, updated={}, errors={}", report.created, @@ -8562,16 +8857,44 @@ impl BdsApp { ); } SettingsMsg::RebuildTemplates => { + let locale = self.ui_locale; return self.spawn_engine_task( "settings.rebuildTemplates", - |db_path, project_id, data_dir, _tm, _tid| { + move |db_path, project_id, data_dir, tm, tid| { + tm.report_progress( + tid, + Some(0.0), + Some(t(locale, "settings.rebuildTemplates")), + ); let db = Database::open(&db_path).map_err(|e| e.to_string())?; - let report = engine::template_rebuild::rebuild_templates_from_filesystem( + let progress = Arc::clone(&tm); + let report = engine::template_rebuild::rebuild_templates_from_filesystem_with_progress( db.conn(), &data_dir, &project_id, + Some(Box::new(move |current, total, name| { + progress.report_progress( + tid, + Some(current as f32 / total.max(1) as f32), + Some(tw( + locale, + "engine.checkingItem", + &[ + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ("name", name), + ], + )), + ); + !progress.is_cancelled(tid) + })), ) .map_err(|e| e.to_string())?; + tm.report_progress( + tid, + Some(1.0), + Some(t(locale, "engine.progress.rebuildComplete")), + ); Ok(format!( "created={}, updated={}, errors={}", report.created, @@ -8582,17 +8905,72 @@ impl BdsApp { ); } SettingsMsg::RebuildLinks => { + let locale = self.ui_locale; return self.spawn_engine_task( "settings.rebuildLinks", - |db_path, project_id, data_dir, _tm, _tid| { + move |db_path, project_id, data_dir, tm, tid| { let db = Database::open(&db_path).map_err(|e| e.to_string())?; - let rebuilt = bds_core::engine::post::rebuild_all_links( + tm.report_progress( + tid, + Some(0.0), + Some(t(locale, "settings.rebuildPostLinks")), + ); + let post_progress = Arc::clone(&tm); + let post_links = bds_core::engine::post::rebuild_all_links_with_progress( db.conn(), &data_dir, &project_id, + Some(Box::new(move |current, total, name| { + post_progress.report_progress( + tid, + Some(current as f32 / total.max(1) as f32 * 0.5), + Some(tw( + locale, + "engine.checkingItem", + &[ + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ("name", name), + ], + )), + ); + !post_progress.is_cancelled(tid) + })), ) .map_err(|e| e.to_string())?; - Ok(format!("rebuilt={rebuilt}")) + tm.report_progress( + tid, + Some(0.5), + Some(t(locale, "settings.rebuildMediaLinks")), + ); + let media_progress = Arc::clone(&tm); + let media_links = + bds_core::engine::media::rebuild_media_links_with_progress( + db.conn(), + &data_dir, + &project_id, + Some(Box::new(move |current, total, name| { + media_progress.report_progress( + tid, + Some(0.5 + current as f32 / total.max(1) as f32 * 0.5), + Some(tw( + locale, + "engine.checkingItem", + &[ + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ("name", name), + ], + )), + ); + !media_progress.is_cancelled(tid) + })), + ) + .map_err(|e| e.to_string())?; + Ok(format!( + "post_links={post_links}, media_links={}", + media_links.links + )) }, ); } @@ -9551,6 +9929,61 @@ impl BdsApp { .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) }, move |result| Message::OneShotAiFinished { + task_id: None, + entity_id: entity_id.clone(), + action: action.clone(), + result, + }, + ) + } + + fn start_translation_ai( + &mut self, + entity_id: String, + action: OneShotAiAction, + request: ai::OneShotRequest, + ) -> Task { + let label = t(self.ui_locale, "editor.translate"); + let task_id = self.task_manager.submit(&label); + self.refresh_task_snapshots(); + let db_path = self.db_path.clone(); + let offline_mode = self.offline_mode; + let locale = self.ui_locale; + let task_manager = Arc::clone(&self.task_manager); + Task::perform( + async move { + let Some(worker) = task_manager.admit(task_id).await else { + return Err("cancelled".to_string()); + }; + tokio::task::spawn_blocking(move || { + let _worker = worker; + task_manager.report_progress( + task_id, + Some(0.1), + Some(t(locale, "editor.translate")), + ); + if task_manager.is_cancelled(task_id) { + return Err("operation cancelled".to_string()); + } + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + task_manager.report_progress( + task_id, + Some(0.5), + Some(t(locale, "editor.translate")), + ); + let response = ai::run_one_shot(db.conn(), offline_mode, &request) + .map(|(response, _usage)| response) + .map_err(|error| error.to_string())?; + if task_manager.is_cancelled(task_id) { + return Err("operation cancelled".to_string()); + } + Ok(response) + }) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) + }, + move |result| Message::OneShotAiFinished { + task_id: Some(task_id), entity_id: entity_id.clone(), action: action.clone(), result, @@ -9707,7 +10140,7 @@ impl BdsApp { if let Some(editor) = self.post_editors.get_mut(post_id) { editor.ai_activity = Some(t(self.ui_locale, "editor.translate")); } - self.start_one_shot_ai( + self.start_translation_ai( post_id.to_string(), OneShotAiAction::PostTranslation { target_language: target_language.to_string(), @@ -9853,7 +10286,7 @@ impl BdsApp { if let Some(editor) = self.media_editors.get_mut(media_id) { editor.ai_activity = Some(t(self.ui_locale, "editor.translate")); } - self.start_one_shot_ai( + self.start_translation_ai( media_id.to_string(), OneShotAiAction::MediaTranslation { target_language: target_language.to_string(), @@ -9884,6 +10317,9 @@ impl BdsApp { let response = match result { Ok(response) => response, + Err(error) if error == "operation cancelled" || error == "cancelled" => { + return Task::none(); + } Err(error) => { self.notify(ToastLevel::Error, &error); return Task::none(); @@ -10188,7 +10624,7 @@ mod tests { use crate::i18n::t; use crate::platform::menu::MenuAction; use crate::state::ToastLevel; - use crate::state::navigation::SidebarView; + use crate::state::navigation::{SidebarView, TaskSource}; use crate::state::sidebar_filter::{MediaFilter, PostFilter}; use crate::state::tabs::{Tab, TabType}; use crate::views::chat_view::ChatEditorState; @@ -10207,7 +10643,7 @@ mod tests { use bds_core::engine::generation::GenerationReport; use bds_core::engine::task::{TaskStatus, TaskStatus::*}; use bds_core::engine::{ - ai, blogmark, chat, media, menu, meta, post, script, tag, template, wordpress_import, + self, ai, blogmark, chat, media, menu, meta, post, script, tag, template, wordpress_import, }; use bds_core::i18n::UiLocale; use bds_core::model::{ @@ -10218,6 +10654,7 @@ mod tests { use std::io::{Read, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; + use std::sync::Arc; use std::thread; use tempfile::TempDir; @@ -10889,6 +11326,43 @@ mod tests { bds_core::engine::meta::write_project_json(tmp.path(), &metadata).unwrap(); } + fn queue_prepared_generation_for_test( + app: &mut BdsApp, + validation: Option, + force: bool, + ) { + let metadata = + bds_core::engine::meta::read_project_json(app.data_dir.as_ref().unwrap()).unwrap(); + let prepared = Arc::new( + engine::generation::prepare_site_generation( + app.db.as_ref().unwrap().conn(), + app.data_dir.as_ref().unwrap(), + &app.active_project.as_ref().unwrap().id, + &metadata, + &[], + ) + .unwrap(), + ); + let sections = validation.as_ref().map_or_else( + || engine::generation::GenerationSection::ALL.to_vec(), + |report| engine::generation::sections_from_validation_report(report, &metadata), + ); + let counts = sections + .into_iter() + .map(|section| { + ( + section, + engine::generation::prepared_section_page_count( + &prepared, + validation.as_ref(), + section, + ), + ) + }) + .collect(); + let _ = app.queue_prepared_site_generation(validation, force, prepared, counts); + } + fn open_post_editor(app: &mut BdsApp, post: &bds_core::model::Post) { let tab = crate::state::tabs::Tab { id: post.id.clone(), @@ -11192,7 +11666,7 @@ mod tests { } #[test] - fn search_index_rebuild_requires_confirmation_and_blocks_editing() { + fn search_index_rebuild_requires_confirmation_and_can_be_cancelled() { let (db, project, tmp) = setup(); let mut app = make_app(db, project, &tmp); app.search_index_rebuild_required = true; @@ -11214,9 +11688,26 @@ mod tests { modal::ConfirmAction::RebuildSearchIndex, )); assert!(app.search_index_rebuild_running); + let task_id = app + .search_index_rebuild_task_id + .expect("reindex task should be retained"); assert!(matches!( app.active_modal, - Some(modal::ModalState::SearchIndexRebuilding) + Some(modal::ModalState::SearchIndexRebuilding { + task_id: modal_task_id, + cancellation_requested: false, + }) + if modal_task_id == task_id + )); + + let _ = app.update(Message::CancelTask(TaskSource::Local, task_id)); + assert!(app.task_manager.is_cancelled(task_id)); + assert!(matches!( + app.active_modal, + Some(modal::ModalState::SearchIndexRebuilding { + task_id: modal_task_id, + cancellation_requested: true, + }) if modal_task_id == task_id )); } @@ -11226,7 +11717,7 @@ mod tests { enable_generation(&tmp); let mut app = make_app(db, project, &tmp); - let _task = app.queue_site_generation(None); + queue_prepared_generation_for_test(&mut app, None, false); let snapshots = app.task_manager.snapshots(); assert_eq!( @@ -11276,7 +11767,7 @@ mod tests { enable_generation(&tmp); let mut app = make_app(db, project, &tmp); - let _task = app.handle_engine_message(Message::ForceGenerateSite); + queue_prepared_generation_for_test(&mut app, None, true); let snapshots = app.task_manager.snapshots(); assert_eq!(snapshots.len(), 5); @@ -11297,7 +11788,7 @@ mod tests { let (db, project, tmp) = setup(); enable_generation(&tmp); let mut app = make_app(db, project, &tmp); - let _task = app.queue_site_generation(None); + queue_prepared_generation_for_test(&mut app, None, false); let group_id = app.task_manager.snapshots()[0].group_id.clone().unwrap(); let render_ids = app.site_generation_workflows[&group_id] .render_task_ids @@ -11323,7 +11814,7 @@ mod tests { let (db, project, tmp) = setup(); enable_generation(&tmp); let mut app = make_app(db, project, &tmp); - let _task = app.queue_site_generation(None); + queue_prepared_generation_for_test(&mut app, None, false); let snapshots = app.task_manager.snapshots(); let group_id = snapshots[0].group_id.clone().unwrap(); let first_id = snapshots[0].id; @@ -11358,11 +11849,11 @@ mod tests { let (db, project, tmp) = setup(); enable_generation(&tmp); let mut app = make_app(db, project, &tmp); - let _task = app.queue_site_generation(None); + queue_prepared_generation_for_test(&mut app, None, false); let snapshots = app.task_manager.snapshots(); let group_id = snapshots[0].group_id.clone().unwrap(); - let _task = app.update(Message::CancelTask(snapshots[0].id)); + let _task = app.update(Message::CancelTask(TaskSource::Local, snapshots[0].id)); let snapshots = app.task_manager.snapshots(); assert!( @@ -11389,7 +11880,7 @@ mod tests { ..Default::default() }; - let _task = app.queue_site_generation(Some(validation)); + queue_prepared_generation_for_test(&mut app, Some(validation), false); let snapshots = app.task_manager.snapshots(); assert_eq!( snapshots @@ -11421,12 +11912,25 @@ mod tests { result: Ok(GenerationReport::default()), }); } - assert!( - app.task_manager - .snapshots() - .iter() - .any(|task| task.label == "Build Search Index") + let calendar_id = app.site_generation_workflows[&group_id] + .calendar_task_id + .expect("calendar phase should follow affected date rendering"); + assert_eq!( + app.task_manager.status(calendar_id), + Some(TaskStatus::Running) ); + let _ = app.handle_engine_message(Message::SiteGenerationCalendarDone { + group_id: group_id.clone(), + task_id: calendar_id, + result: Ok(()), + }); + let index_id = app.site_generation_workflows[&group_id] + .index_task_id + .expect("search indexing should follow calendar regeneration"); + assert!(matches!( + app.task_manager.status(index_id), + Some(TaskStatus::Running | TaskStatus::Pending) + )); } #[test] diff --git a/crates/bds-ui/src/app/engine_handlers.rs b/crates/bds-ui/src/app/engine_handlers.rs index 912f34b..e9e4925 100644 --- a/crates/bds-ui/src/app/engine_handlers.rs +++ b/crates/bds-ui/src/app/engine_handlers.rs @@ -12,6 +12,7 @@ impl BdsApp { let on_progress: engine::rebuild::ProgressFn = Arc::new(move |pct, event| { tm.report_progress(tid, Some(pct), Some(event.localized(locale))); + !tm.is_cancelled(tid) }); let report = engine::rebuild::rebuild_from_filesystem_with_progress( db.conn(), @@ -42,14 +43,28 @@ impl BdsApp { "engine.calendarStarted", move |db_path, project_id, data_dir, tm, tid| { let db = Database::open(&db_path).map_err(|e| e.to_string())?; - tm.report_progress(tid, Some(0.20), Some(t(locale, "engine.loadingPosts"))); - engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id) - .map_err(|e| e.to_string())?; - tm.report_progress( - tid, - Some(0.90), - Some(t(locale, "engine.writingCalendar")), - ); + engine::calendar::regenerate_calendar_with_progress( + db.conn(), + &data_dir, + &project_id, + |current, total, name| { + tm.report_progress( + tid, + Some(current as f32 / total.max(1) as f32), + Some(tw( + locale, + "engine.checkingItem", + &[ + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ("name", name), + ], + )), + ); + !tm.is_cancelled(tid) + }, + ) + .map_err(|e| e.to_string())?; Ok("done".to_string()) }, ) @@ -59,24 +74,39 @@ impl BdsApp { TabType::TranslationValidation, "tabBar.translationValidation", ); - let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else { + if self.active_project.is_none() || self.data_dir.is_none() { return Task::none(); - }; + } self.translation_validation_state.is_running = true; self.translation_validation_state.error_message = None; - let db_path = self.db_path.clone(); - let project_id = project.id.clone(); - let data_dir = data_dir.clone(); - Task::perform( - async move { - tokio::task::spawn_blocking(move || { - let db = Database::open(&db_path).map_err(|e| e.to_string())?; - let meta = engine::meta::read_project_json(&data_dir) - .map_err(|e| e.to_string())?; - let main_lang = meta.main_language.as_deref().unwrap_or("en"); - let blog_langs = meta.blog_languages.clone(); - let on_item: engine::validate_translations::ItemProgressFn = - Box::new(move |_current, _total, _name| {}); + let locale = self.ui_locale; + self.spawn_result_task( + "menu.item.validateTranslations", + move |db_path, project_id, data_dir, tm, tid| { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + let meta = engine::meta::read_project_json(&data_dir) + .map_err(|e| e.to_string())?; + let main_lang = meta.main_language.as_deref().unwrap_or("en"); + let blog_langs = meta.blog_languages.clone(); + let progress_tm = Arc::clone(&tm); + let on_item: engine::validate_translations::ItemProgressFn = + Box::new(move |current, total, name| { + progress_tm.report_progress( + tid, + Some(current as f32 / total.max(1) as f32), + Some(tw( + locale, + "engine.checkingItem", + &[ + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ("name", name), + ], + )), + ); + !progress_tm.is_cancelled(tid) + }); + let report = engine::validate_translations::validate_translations_with_progress( db.conn(), &data_dir, @@ -85,16 +115,31 @@ impl BdsApp { main_lang, Some(on_item), ) - .map_err(|e| e.to_string()) - }) - .await - .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) + .map_err(|e| e.to_string())?; + tm.report_progress( + tid, + Some(1.0), + Some(tw( + locale, + "engine.validateTranslationsComplete", + &[ + ("dbIssues", &report.db_issues.len().to_string()), + ("fsIssues", &report.fs_issues.len().to_string()), + ], + )), + ); + Ok(report) }, - Message::TranslationValidationLoaded, + |task_id, result| Message::TranslationValidationLoaded { task_id, result }, ) } - Message::TranslationValidationLoaded(result) => { + Message::TranslationValidationLoaded { task_id, result } => { + let cancelled = self.finish_result_task(task_id, &result); + self.refresh_task_snapshots(); self.translation_validation_state.is_running = false; + if cancelled { + return Task::none(); + } match result { Ok(report) => { self.translation_validation_state.report = Some(report); @@ -149,8 +194,13 @@ impl BdsApp { self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff"); self.start_metadata_diff() } - Message::MetadataDiffLoaded(result) => { + Message::MetadataDiffLoaded { task_id, result } => { + let cancelled = self.finish_result_task(task_id, &result); + self.refresh_task_snapshots(); self.metadata_diff_state.is_running = false; + if cancelled { + return Task::none(); + } match result { Ok(report) => { self.metadata_diff_state.report = Some(report); @@ -170,36 +220,38 @@ impl BdsApp { else { return Task::none(); }; - let (Some(project), Some(data_dir)) = - (self.active_project.as_ref(), self.data_dir.as_ref()) - else { + if self.active_project.is_none() || self.data_dir.is_none() { return Task::none(); - }; + } self.metadata_diff_state.is_repairing = true; - let db_path = self.db_path.clone(); - let project_id = project.id.clone(); - let data_dir = data_dir.clone(); - Task::perform( - async move { - tokio::task::spawn_blocking(move || { - let db = Database::open(&db_path).map_err(|error| error.to_string())?; - engine::metadata_diff::repair_metadata_diff_item( - db.conn(), - &data_dir, - &project_id, - direction, - &item, - ) - .map_err(|error| error.to_string()) - }) - .await - .map_err(|error| error.to_string())? + let locale = self.ui_locale; + self.spawn_result_task( + "metadataDiff.repair", + move |db_path, project_id, data_dir, tm, tid| { + tm.report_progress(tid, Some(0.0), Some(t(locale, "metadataDiff.repair"))); + if tm.is_cancelled(tid) { + return Err("operation cancelled".into()); + } + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + engine::metadata_diff::repair_metadata_diff_item( + db.conn(), + &data_dir, + &project_id, + direction, + &item, + ) + .map_err(|error| error.to_string()) }, - Message::MetadataDiffItemRepaired, + |task_id, result| Message::MetadataDiffItemRepaired { task_id, result }, ) } - Message::MetadataDiffItemRepaired(result) => { + Message::MetadataDiffItemRepaired { task_id, result } => { + let cancelled = self.finish_result_task(task_id, &result); + self.refresh_task_snapshots(); self.metadata_diff_state.is_repairing = false; + if cancelled { + return Task::none(); + } match result { Ok(()) => { self.notify( @@ -214,6 +266,62 @@ impl BdsApp { } } } + Message::ImportMetadataOrphan(index) => { + let Some(orphan) = self + .metadata_diff_state + .report + .as_ref() + .and_then(|report| report.orphans.get(index)) + .cloned() + else { + return Task::none(); + }; + self.metadata_diff_state.is_repairing = true; + let locale = self.ui_locale; + self.spawn_result_task( + "metadataDiff.importOrphan", + move |db_path, project_id, data_dir, tm, tid| { + tm.report_progress( + tid, + Some(0.0), + Some(t(locale, "metadataDiff.importOrphan")), + ); + if tm.is_cancelled(tid) { + return Err("operation cancelled".into()); + } + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + engine::metadata_diff::import_orphan_file( + db.conn(), + &data_dir, + &project_id, + &orphan, + ) + .map_err(|error| error.to_string()) + }, + |task_id, result| Message::MetadataOrphanImported { task_id, result }, + ) + } + Message::MetadataOrphanImported { task_id, result } => { + let cancelled = self.finish_result_task(task_id, &result); + self.refresh_task_snapshots(); + self.metadata_diff_state.is_repairing = false; + if cancelled { + return Task::none(); + } + match result { + Ok(()) => { + self.notify( + ToastLevel::Success, + &t(self.ui_locale, "metadataDiff.orphanImported"), + ); + self.start_metadata_diff() + } + Err(error) => { + self.notify(ToastLevel::Error, &error); + Task::none() + } + } + } Message::RunSiteValidation => self.start_site_validation(), Message::ApplySiteValidation => self.apply_site_validation(), Message::EngineTaskDone { @@ -223,15 +331,14 @@ impl BdsApp { result, } => { let search_rebuild_finished = self.search_index_rebuild_task_id == Some(task_id); - let cancelled = self.task_manager.status(task_id) == Some(TaskStatus::Cancelled); - let refresh_semantic_tags = !cancelled + let cancellation_requested = self.task_manager.is_cancelled(task_id); + let refresh_semantic_tags = !cancellation_requested && result.is_ok() && matches!( operation, "embeddings.indexing" | "menu.item.rebuildEmbeddingIndex" ); match &result { - _ if cancelled => {} Ok(detail) => { self.task_manager.complete(task_id); if operation == "engine.rebuildStarted" { @@ -258,12 +365,14 @@ impl BdsApp { } Err(err) => { self.task_manager.fail(task_id, err.clone()); - let message = tw( - self.ui_locale, - "common.operationFailed", - &[("operation", &label), ("error", err)], - ); - self.notify(ToastLevel::Error, &message); + if !cancellation_requested { + let message = tw( + self.ui_locale, + "common.operationFailed", + &[("operation", &label), ("error", err)], + ); + self.notify(ToastLevel::Error, &message); + } } } if search_rebuild_finished { @@ -293,12 +402,41 @@ impl BdsApp { self.refresh_task_snapshots(); Task::batch([sidebar_task, semantic_task]) } + Message::SiteGenerationPrepared { + task_id, + validation, + force, + result, + } => { + let cancelled = self.finish_result_task(task_id, &result); + self.refresh_task_snapshots(); + if cancelled { + if validation.is_some() { + self.site_validation_state.is_applying = false; + } + return Task::none(); + } + match result { + Ok((prepared, counts)) => { + self.queue_prepared_site_generation(validation, force, prepared, counts) + } + Err(error) => { + if validation.is_some() { + self.site_validation_state.is_applying = false; + self.site_validation_state.error_message = Some(error.clone()); + } + self.notify(ToastLevel::Error, &error); + Task::none() + } + } + } Message::SiteGenerationSectionDone { group_id, task_id, result, } => { - if self.task_manager.status(task_id) == Some(TaskStatus::Cancelled) { + if self.task_manager.is_cancelled(task_id) { + self.finish_result_task(task_id, &result); self.refresh_task_snapshots(); return Task::none(); } @@ -335,21 +473,54 @@ impl BdsApp { } } - let should_index = + let next_phase = self.site_generation_workflows .get(&group_id) - .is_some_and(|workflow| { - workflow.index_task_id.is_none() + .and_then(|workflow| { + (workflow.calendar_task_id.is_none() + && workflow.index_task_id.is_none() && workflow.render_task_ids.iter().all(|task_id| { self.task_manager.status(*task_id) == Some(TaskStatus::Completed) - }) + })) + .then_some(workflow.calendar_needed) }); self.refresh_task_snapshots(); - if should_index { - self.queue_site_search_index(&group_id) - } else { - Task::none() + match next_phase { + Some(true) => self.queue_site_calendar(&group_id), + Some(false) => self.queue_site_search_index(&group_id), + None => Task::none(), + } + } + Message::SiteGenerationCalendarDone { + group_id, + task_id, + result, + } => { + if self.task_manager.is_cancelled(task_id) { + self.finish_result_task(task_id, &result); + self.refresh_task_snapshots(); + return Task::none(); + } + match result { + Ok(()) => { + self.task_manager.complete(task_id); + self.refresh_task_snapshots(); + self.queue_site_search_index(&group_id) + } + Err(error) => { + self.task_manager.fail(task_id, error.clone()); + self.task_manager.cancel_group(&group_id); + if let Some(workflow) = self.site_generation_workflows.remove(&group_id) + && workflow.kind == SiteGenerationKind::Validation + { + self.site_validation_state.is_applying = false; + self.site_validation_state.error_message = Some(error.clone()); + } + self.notify(ToastLevel::Error, &error); + self.refresh_task_snapshots(); + Task::none() + } } } Message::SiteGenerationIndexDone { @@ -357,7 +528,8 @@ impl BdsApp { task_id, result, } => { - if self.task_manager.status(task_id) == Some(TaskStatus::Cancelled) { + if self.task_manager.is_cancelled(task_id) { + self.finish_result_task(task_id, &result); self.refresh_task_snapshots(); return Task::none(); } @@ -415,8 +587,13 @@ impl BdsApp { } } } - Message::SiteValidationLoaded(result) => { + Message::SiteValidationLoaded { task_id, result } => { + let cancelled = self.finish_result_task(task_id, &result); + self.refresh_task_snapshots(); self.site_validation_state.is_running = false; + if cancelled { + return Task::none(); + } self.site_validation_state.has_run = true; match result { Ok(report) => { diff --git a/crates/bds-ui/src/app/git_handlers.rs b/crates/bds-ui/src/app/git_handlers.rs index 3e30bf2..2143d3a 100644 --- a/crates/bds-ui/src/app/git_handlers.rs +++ b/crates/bds-ui/src/app/git_handlers.rs @@ -259,10 +259,11 @@ impl BdsApp { Task::perform( async move { + let Some(worker) = task_manager.admit(task_id).await else { + return Err("cancelled".to_string()); + }; tokio::task::spawn_blocking(move || { - if !task_manager.wait_until_runnable(task_id) { - return Err("cancelled".to_string()); - } + let _worker = worker; let engine = GitEngine::new(&data_dir); let old_head = (operation == GitOperation::Pull) .then(|| engine.head()) diff --git a/crates/bds-ui/src/app/tasks.rs b/crates/bds-ui/src/app/tasks.rs index bfdf784..806dc1a 100644 --- a/crates/bds-ui/src/app/tasks.rs +++ b/crates/bds-ui/src/app/tasks.rs @@ -14,35 +14,40 @@ fn shorten_task_url(url: &str) -> String { } impl BdsApp { + pub(super) fn finish_result_task( + &self, + task_id: TaskId, + result: &Result, + ) -> bool { + let cancellation_requested = self.task_manager.is_cancelled(task_id); + match result { + Ok(_) => self.task_manager.complete(task_id), + Err(error) => self.task_manager.fail(task_id, error.clone()), + } + cancellation_requested && result.is_err() + } + pub(super) fn refresh_task_snapshots(&mut self) { self.task_snapshots = self .task_manager .snapshots() .into_iter() - .map(|snapshot| { - let status_str = match &snapshot.status { - TaskStatus::Pending => t(self.ui_locale, "tasks.statusPending"), - TaskStatus::Running => t(self.ui_locale, "tasks.statusRunning"), - TaskStatus::Completed => t(self.ui_locale, "tasks.statusCompleted"), - TaskStatus::Failed(error) => { - tw(self.ui_locale, "tasks.statusFailed", &[("error", error)]) - } - TaskStatus::Cancelled => t(self.ui_locale, "tasks.statusCancelled"), - }; - TaskSnapshot { - id: snapshot.id, - label: snapshot.label, - group_id: snapshot.group_id, - group_name: snapshot.group_name, - status: status_str, - progress: snapshot.progress, - message: snapshot.message, - is_cancellable: matches!( - snapshot.status, - TaskStatus::Pending | TaskStatus::Running - ), - } + .map(|snapshot| TaskSnapshot { + id: snapshot.id, + source: crate::state::navigation::TaskSource::Local, + label: snapshot.label, + group_id: snapshot.group_id, + group_name: snapshot.group_name, + status: snapshot.status.clone(), + progress: snapshot.progress, + message: snapshot.message, + cancellation_requested: snapshot.cancellation_requested, + is_cancellable: matches!( + snapshot.status, + TaskStatus::Pending | TaskStatus::Running + ) && !snapshot.cancellation_requested, }) + .chain(self.remote_task_snapshots.iter().cloned()) .collect(); } @@ -69,7 +74,7 @@ impl BdsApp { } else { SiteGenerationKind::Full }; - let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else { + let Some(data_dir) = &self.data_dir else { if kind == SiteGenerationKind::Validation { self.site_validation_state.is_applying = false; } @@ -79,6 +84,16 @@ impl BdsApp { ); return Task::none(); }; + if self.active_project.is_none() { + if kind == SiteGenerationKind::Validation { + self.site_validation_state.is_applying = false; + } + self.notify( + ToastLevel::Error, + &t(self.ui_locale, "engine.generateSiteNoProject"), + ); + return Task::none(); + } let metadata = match engine::meta::read_project_json(data_dir) { Ok(metadata) => metadata, Err(error) => { @@ -107,37 +122,122 @@ impl BdsApp { return Task::none(); } - let sections = validation.as_ref().map_or_else( - || engine::generation::GenerationSection::ALL.to_vec(), - |validation| engine::generation::sections_from_validation_report(validation, &metadata), - ); - if sections.is_empty() { - return Task::none(); - } + let task_validation = validation.clone(); + let locale = self.ui_locale; + self.spawn_result_task( + "engine.prepareGeneration", + move |db_path, project_id, data_dir, tm, tid| { + tm.report_progress( + tid, + Some(0.0), + Some(t(locale, "engine.preparingGeneration")), + ); + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + let metadata = engine::meta::read_project_json(&data_dir) + .map_err(|error| error.to_string())?; + if tm.is_cancelled(tid) { + return Err("operation cancelled".into()); + } + let posts = + bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id) + .map_err(|error| error.to_string())? + .into_iter() + .filter(engine::generation::has_published_snapshot) + .collect::>(); + let total_posts = posts.len(); + let mut sources = Vec::with_capacity(total_posts); + for (index, post) in posts.into_iter().enumerate() { + tm.report_progress( + tid, + Some(0.05 + (index + 1) as f32 / total_posts.max(1) as f32 * 0.35), + Some(t(locale, "engine.loadingPosts")), + ); + if tm.is_cancelled(tid) { + return Err("operation cancelled".into()); + } + if let Some(source) = + engine::generation::load_published_post_source(&data_dir, post) + .map_err(|error| error.to_string())? + { + sources.push(source); + } + } + tm.report_progress( + tid, + Some(0.45), + Some(t(locale, "engine.preparingGeneration")), + ); + let prepared = Arc::new( + engine::generation::prepare_site_generation( + db.conn(), + &data_dir, + &project_id, + &metadata, + &sources, + ) + .map_err(|error| error.to_string())?, + ); + if tm.is_cancelled(tid) { + return Err("operation cancelled".into()); + } + let sections = task_validation.as_ref().map_or_else( + || engine::generation::GenerationSection::ALL.to_vec(), + |report| engine::generation::sections_from_validation_report(report, &metadata), + ); + let counts = sections + .into_iter() + .map(|section| { + ( + section, + engine::generation::prepared_section_page_count( + &prepared, + task_validation.as_ref(), + section, + ), + ) + }) + .collect(); + tm.report_progress(tid, Some(1.0), Some(t(locale, "engine.generationPrepared"))); + Ok((prepared, counts)) + }, + move |task_id, result| Message::SiteGenerationPrepared { + task_id, + validation: validation.clone(), + force, + result, + }, + ) + } + pub(super) fn queue_prepared_site_generation( + &mut self, + validation: Option, + force: bool, + prepared: Arc, + page_estimates: HashMap, + ) -> Task { + let kind = if validation.is_some() { + SiteGenerationKind::Validation + } else if force { + SiteGenerationKind::Forced + } else { + SiteGenerationKind::Full + }; + let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else { + return Task::none(); + }; + let sections = page_estimates.keys().copied().collect::>(); + let calendar_needed = kind == SiteGenerationKind::Validation + && sections.iter().any(|section| { + matches!( + section, + engine::generation::GenerationSection::Core + | engine::generation::GenerationSection::Date + ) + }); let project_id = project.id.clone(); let db_path = self.db_path.clone(); let data_dir = data_dir.clone(); - let published_posts = match self.db.as_ref().and_then(|db| { - bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id).ok() - }) { - Some(posts) => posts - .into_iter() - .filter(engine::generation::has_published_snapshot) - .collect::>(), - None => return Task::none(), - }; - let page_estimates = match bds_core::render::estimate_site_render_pages( - &data_dir, - &metadata, - &published_posts, - ) { - Ok(estimates) => estimates, - Err(error) => { - self.notify(ToastLevel::Error, &error.to_string()); - return Task::none(); - } - }; let group_id = format!("site-generation:{}", Uuid::new_v4()); let group_name = t( self.ui_locale, @@ -150,7 +250,10 @@ impl BdsApp { let mut render_task_ids = Vec::new(); let mut tasks = Vec::new(); - for section in sections { + for section in engine::generation::GenerationSection::ALL + .into_iter() + .filter(|section| sections.contains(section)) + { let label = t(self.ui_locale, generation_section_label_key(section)); self.add_output(&label); let page_work = page_estimates[§ion]; @@ -173,14 +276,20 @@ impl BdsApp { let task_data_dir = data_dir.clone(); let task_group_id = group_id.clone(); let task_validation = validation.clone(); + let task_prepared = Arc::clone(&prepared); let locale = self.ui_locale; tasks.push(Task::perform( async move { + let Some(worker) = task_manager.admit(task_id).await else { + return Err("cancelled".to_string()); + }; tokio::task::spawn_blocking(move || { + let _worker = worker; run_site_generation_section( task_db_path, task_project_id, task_data_dir, + task_prepared, task_manager, task_id, section, @@ -210,6 +319,8 @@ impl BdsApp { data_dir, group_name, render_task_ids, + calendar_needed, + calendar_task_id: None, index_task_id: None, report: engine::generation::GenerationReport::default(), }, @@ -218,6 +329,75 @@ impl BdsApp { Task::batch(tasks) } + pub(super) fn queue_site_calendar(&mut self, group_id: &str) -> Task { + let Some(workflow) = self.site_generation_workflows.get(group_id).cloned() else { + return Task::none(); + }; + let label = t(self.ui_locale, "engine.calendarStarted"); + let task_id = self + .task_manager + .submit_grouped(&label, group_id, &workflow.group_name); + if let Some(workflow) = self.site_generation_workflows.get_mut(group_id) { + workflow.calendar_task_id = Some(task_id); + } + self.refresh_task_snapshots(); + let task_manager = Arc::clone(&self.task_manager); + let task_group_id = group_id.to_string(); + let locale = self.ui_locale; + Task::perform( + async move { + let Some(worker) = task_manager.admit(task_id).await else { + return Err("cancelled".to_string()); + }; + tokio::task::spawn_blocking(move || { + let _worker = worker; + task_manager.report_progress( + task_id, + Some(0.0), + Some(t(locale, "engine.loadingPosts")), + ); + let db = + Database::open(&workflow.db_path).map_err(|error| error.to_string())?; + engine::calendar::regenerate_calendar_with_progress( + db.conn(), + &workflow.data_dir, + &workflow.project_id, + |current, total, name| { + task_manager.report_progress( + task_id, + Some(current as f32 / total.max(1) as f32), + Some(tw( + locale, + "engine.checkingItem", + &[ + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ("name", name), + ], + )), + ); + !task_manager.is_cancelled(task_id) + }, + ) + .map_err(|error| error.to_string())?; + task_manager.report_progress( + task_id, + Some(1.0), + Some(t(locale, "engine.writingCalendar")), + ); + Ok(()) + }) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) + }, + move |result| Message::SiteGenerationCalendarDone { + group_id: task_group_id.clone(), + task_id, + result, + }, + ) + } + pub(super) fn queue_site_search_index(&mut self, group_id: &str) -> Task { let Some(workflow) = self.site_generation_workflows.get(group_id).cloned() else { return Task::none(); @@ -237,10 +417,11 @@ impl BdsApp { let task_group_id = group_id.to_string(); Task::perform( async move { + let Some(worker) = task_manager.admit(task_id).await else { + return Err("cancelled".to_string()); + }; tokio::task::spawn_blocking(move || { - if !task_manager.wait_until_runnable(task_id) { - return Err("cancelled".to_string()); - } + let _worker = worker; let db = Database::open(&workflow.db_path).map_err(|error| error.to_string())?; let metadata = engine::meta::read_project_json(&workflow.data_dir) @@ -312,7 +493,7 @@ impl BdsApp { if workflow.kind == SiteGenerationKind::Validation { self.site_validation_state.is_applying = false; } - self.add_output(&t(self.ui_locale, "engine.generationCancelled")); + self.add_output(&t(self.ui_locale, "engine.generationCancellationRequested")); self.refresh_task_snapshots(); true } @@ -337,7 +518,10 @@ impl BdsApp { let task_id = self.task_manager.submit(&label); self.search_index_rebuild_running = true; self.search_index_rebuild_task_id = Some(task_id); - self.active_modal = Some(modal::ModalState::SearchIndexRebuilding); + self.active_modal = Some(modal::ModalState::SearchIndexRebuilding { + task_id, + cancellation_requested: false, + }); self.refresh_task_snapshots(); self.sync_menu_state(); @@ -346,10 +530,11 @@ impl BdsApp { let task_manager = Arc::clone(&self.task_manager); Task::perform( async move { + let Some(worker) = task_manager.admit(task_id).await else { + return Err("cancelled".to_string()); + }; tokio::task::spawn_blocking(move || { - if !task_manager.wait_until_runnable(task_id) { - return Err("cancelled".to_string()); - } + let _worker = worker; let db = Database::open(&db_path).map_err(|error| error.to_string())?; let progress_manager = Arc::clone(&task_manager); let on_item: engine::search::ItemProgressFn = @@ -373,6 +558,7 @@ impl BdsApp { Some(progress), Some(message), ); + !progress_manager.is_cancelled(task_id) }); let report = engine::search::rebuild_search_index(db.conn(), Some(on_item)) .map_err(|error| error.to_string())?; @@ -403,6 +589,49 @@ impl BdsApp { self.spawn_engine_task_in_group(label_key, None, work) } + pub(super) fn spawn_result_task( + &mut self, + label_key: &'static str, + work: F, + message: M, + ) -> Task + where + T: Send + 'static, + F: FnOnce(PathBuf, String, PathBuf, Arc, TaskId) -> Result + + Send + + 'static, + M: Fn(TaskId, Result) -> Message + Send + Sync + 'static, + { + let (Some(project_id), Some(data_dir)) = ( + self.active_project + .as_ref() + .map(|project| project.id.clone()), + self.data_dir.clone(), + ) else { + return Task::none(); + }; + let label = t(self.ui_locale, label_key); + self.add_output(&label); + let task_id = self.task_manager.submit(&label); + self.refresh_task_snapshots(); + let task_manager = Arc::clone(&self.task_manager); + let db_path = self.db_path.clone(); + Task::perform( + async move { + let Some(worker) = task_manager.admit(task_id).await else { + return Err("cancelled".to_string()); + }; + tokio::task::spawn_blocking(move || { + let _worker = worker; + work(db_path, project_id, data_dir, task_manager, task_id) + }) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) + }, + move |result| message(task_id, result), + ) + } + pub(super) fn spawn_grouped_engine_task( &mut self, label_key: &'static str, @@ -453,10 +682,11 @@ impl BdsApp { Task::perform( async move { + let Some(worker) = tm.admit(task_id).await else { + return Err("cancelled".to_string()); + }; tokio::task::spawn_blocking(move || { - if !tm.wait_until_runnable(task_id) { - return Err("cancelled".to_string()); - } + let _worker = worker; work(db_path, project_id, data_dir, tm, task_id) }) .await @@ -490,6 +720,7 @@ fn run_site_generation_section( db_path: PathBuf, project_id: String, data_dir: PathBuf, + prepared: Arc, task_manager: Arc, task_id: TaskId, section: engine::generation::GenerationSection, @@ -498,9 +729,6 @@ fn run_site_generation_section( locale: UiLocale, expected_pages: usize, ) -> Result { - if !task_manager.wait_until_runnable(task_id) { - return Err("cancelled".to_string()); - } task_manager.report_progress( task_id, Some(if expected_pages == 0 { 1.0 } else { 0.0 }), @@ -511,26 +739,6 @@ fn run_site_generation_section( )), ); let db = Database::open(&db_path).map_err(|error| error.to_string())?; - let metadata = engine::meta::read_project_json(&data_dir).map_err(|error| error.to_string())?; - let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id) - .map_err(|error| error.to_string())?; - let sources = posts - .into_iter() - .filter(engine::generation::has_published_snapshot) - .map(|post| engine::generation::load_published_post_source(&data_dir, post)) - .collect::, _>>() - .map_err(|error| error.to_string())? - .into_iter() - .flatten() - .collect::>(); - let prepared = engine::generation::prepare_site_generation( - db.conn(), - &data_dir, - &project_id, - &metadata, - &sources, - ) - .map_err(|error| error.to_string())?; let output_dir = data_dir.join("html"); std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?; let render_manager = Arc::clone(&task_manager); @@ -565,7 +773,7 @@ fn run_site_generation_section( db.conn(), &output_dir, &project_id, - &prepared, + prepared.as_ref(), &validation, section, on_page, @@ -576,7 +784,7 @@ fn run_site_generation_section( db.conn(), &output_dir, &project_id, - &prepared, + prepared.as_ref(), section, force, &on_rendered, diff --git a/crates/bds-ui/src/state/navigation.rs b/crates/bds-ui/src/state/navigation.rs index 34bb107..2eac964 100644 --- a/crates/bds-ui/src/state/navigation.rs +++ b/crates/bds-ui/src/state/navigation.rs @@ -107,15 +107,23 @@ impl PanelTab { #[derive(Debug, Clone)] pub struct TaskSnapshot { pub id: u64, + pub source: TaskSource, pub label: String, pub group_id: Option, pub group_name: Option, - pub status: String, + pub status: bds_core::engine::task::TaskStatus, pub progress: Option, pub message: Option, + pub cancellation_requested: bool, pub is_cancellable: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskSource { + Local, + Remote, +} + /// A single line of output shown in the panel. #[derive(Debug, Clone)] pub struct OutputEntry { diff --git a/crates/bds-ui/src/views/git.rs b/crates/bds-ui/src/views/git.rs index 0ff9d95..0127c2e 100644 --- a/crates/bds-ui/src/views/git.rs +++ b/crates/bds-ui/src/views/git.rs @@ -386,7 +386,10 @@ fn network_output(run: &GitNetworkRunState, locale: UiLocale) -> Element<'static ) .padding(6), button(text(t(locale, "common.cancel")).size(11)) - .on_press(Message::CancelTask(run.task_id)) + .on_press(Message::CancelTask( + crate::state::navigation::TaskSource::Local, + run.task_id, + )) .padding([4, 7]) .style(inputs::secondary_button), ] diff --git a/crates/bds-ui/src/views/metadata_diff.rs b/crates/bds-ui/src/views/metadata_diff.rs index b525af4..0857113 100644 --- a/crates/bds-ui/src/views/metadata_diff.rs +++ b/crates/bds-ui/src/views/metadata_diff.rs @@ -101,19 +101,33 @@ pub fn view<'a>(state: &'a MetadataDiffState, locale: UiLocale) -> Element<'a, M )); } if !report.orphans.is_empty() { - let rows = report - .orphans - .iter() - .fold(column!().spacing(4), |column, orphan| { - column.push( + let rows = report.orphans.iter().enumerate().fold( + column!().spacing(8), + |column, (index, orphan)| { + let mut item = row![ text(tw( locale, "metadataDiff.orphan", &[("reason", &orphan.reason), ("path", &orphan.file_path)], )) .size(12), - ) - }); + Space::with_width(Length::Fill), + ] + .align_y(iced::Alignment::Center); + if orphan.reason == "file_without_db_entry" { + item = item.push( + button(text(t(locale, "metadataDiff.importOrphan")).size(12)) + .on_press_maybe( + (!state.is_repairing) + .then_some(Message::ImportMetadataOrphan(index)), + ) + .style(inputs::secondary_button) + .padding([5, 10]), + ); + } + column.push(item) + }, + ); content = content.push(inputs::card( column![text(t(locale, "metadataDiff.orphans")).size(16), rows].spacing(8), )); diff --git a/crates/bds-ui/src/views/modal.rs b/crates/bds-ui/src/views/modal.rs index ff3aebb..edfa02d 100644 --- a/crates/bds-ui/src/views/modal.rs +++ b/crates/bds-ui/src/views/modal.rs @@ -64,7 +64,10 @@ pub enum ModalState { on_confirm: ConfirmAction, }, SearchIndexRepair, - SearchIndexRebuilding, + SearchIndexRebuilding { + task_id: bds_core::engine::task::TaskId, + cancellation_requested: bool, + }, FindReplace { query: String, replacement: String, @@ -552,8 +555,11 @@ pub fn view( .into() } - ModalState::SearchIndexRebuilding => container( - column![ + ModalState::SearchIndexRebuilding { + task_id, + cancellation_requested, + } => { + let mut content = column![ text(t(locale, "searchIndexRepair.rebuildingTitle")) .size(16) .shaping(Shaping::Advanced) @@ -563,12 +569,31 @@ pub fn view( .size(13) .shaping(Shaping::Advanced) .color(Color::from_rgb(0.80, 0.80, 0.85)), - ] - .padding(20), - ) - .width(Length::Fixed(420.0)) - .style(modal_box_style) - .into(), + ]; + content = if cancellation_requested { + content.push(Space::with_height(16.0)).push( + text(t(locale, "tasks.status.cancelling")) + .size(13) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.80, 0.80, 0.85)), + ) + } else { + content.push(Space::with_height(16.0)).push(row![ + Space::with_width(Length::Fill), + button(text(t(locale, "tasks.cancelTask")).size(13)) + .on_press(Message::CancelTask( + crate::state::navigation::TaskSource::Local, + task_id, + )) + .padding([6, 16]) + .style(cancel_button_style), + ]) + }; + container(content.padding(20)) + .width(Length::Fixed(420.0)) + .style(modal_box_style) + .into() + } ModalState::PostInsertLink { post_id: _post_id, diff --git a/crates/bds-ui/src/views/panel.rs b/crates/bds-ui/src/views/panel.rs index 8aa0d4a..e037124 100644 --- a/crates/bds-ui/src/views/panel.rs +++ b/crates/bds-ui/src/views/panel.rs @@ -3,17 +3,28 @@ use iced::widget::{Space, button, column, container, row, scrollable, text}; use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; use bds_core::engine::git::GitCommit; +use bds_core::engine::task::TaskStatus; use bds_core::i18n::UiLocale; use crate::app::Message; use crate::components::inputs; -use crate::i18n::t; +use crate::i18n::{t, tw}; use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot}; use crate::state::tabs::{Tab, TabType}; use crate::views::post_editor::ResolvedPostLink; use std::collections::HashSet; fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Message> { + let status = match &snapshot.status { + TaskStatus::Pending => t(locale, "tasks.statusPending"), + TaskStatus::Running if snapshot.cancellation_requested => { + t(locale, "tasks.statusCancelling") + } + TaskStatus::Running => t(locale, "tasks.statusRunning"), + TaskStatus::Completed => t(locale, "tasks.statusCompleted"), + TaskStatus::Failed(error) => tw(locale, "tasks.statusFailed", &[("error", error)]), + TaskStatus::Cancelled => t(locale, "tasks.statusCancelled"), + }; let progress = snapshot .progress .map(|value| format!(" ({:.0}%)", value * 100.0)) @@ -25,7 +36,7 @@ fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Messa .unwrap_or_default(); let label = text(format!( "{} — {}{}{}", - snapshot.label, snapshot.status, progress, phase + snapshot.label, status, progress, phase )) .size(11) .shaping(Shaping::Advanced) @@ -34,7 +45,7 @@ fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Messa if snapshot.is_cancellable { content = content.push(Space::with_width(Length::Fill)).push( button(text(t(locale, "tasks.cancelTask")).size(11)) - .on_press(Message::CancelTask(snapshot.id)) + .on_press(Message::CancelTask(snapshot.source, snapshot.id)) .padding([3, 8]) .style(inputs::secondary_button), ); @@ -46,12 +57,9 @@ fn group_progress(members: &[&TaskSnapshot]) -> Option { (!members.is_empty()).then(|| { members .iter() - .map(|member| { - if member.is_cancellable { - member.progress.unwrap_or(0.0) - } else { - 1.0 - } + .map(|member| match member.status { + TaskStatus::Completed | TaskStatus::Failed(_) | TaskStatus::Cancelled => 1.0, + TaskStatus::Pending | TaskStatus::Running => member.progress.unwrap_or(0.0), }) .sum::() / members.len() as f32 @@ -219,7 +227,7 @@ pub fn view( .padding(8) .into() } else { - let visible = task_snapshots.iter().rev().take(10).collect::>(); + let visible = task_snapshots.iter().collect::>(); let mut rendered_groups = HashSet::new(); let mut items: Vec> = Vec::new(); for snapshot in &visible { @@ -441,16 +449,19 @@ mod progress_tests { fn group_progress_includes_pending_tasks_as_zero_like_bds2() { let complete = TaskSnapshot { id: 1, + source: crate::state::navigation::TaskSource::Local, label: String::new(), group_id: None, group_name: None, - status: String::new(), + status: TaskStatus::Completed, progress: Some(1.0), message: None, + cancellation_requested: false, is_cancellable: false, }; let pending = TaskSnapshot { id: 2, + status: TaskStatus::Pending, progress: None, is_cancellable: true, ..complete.clone() diff --git a/crates/bds-ui/src/views/status_bar.rs b/crates/bds-ui/src/views/status_bar.rs index 7faef5e..10d1b72 100644 --- a/crates/bds-ui/src/views/status_bar.rs +++ b/crates/bds-ui/src/views/status_bar.rs @@ -2,10 +2,11 @@ use iced::widget::text::Shaping; use iced::widget::{Space, button, container, row, text}; use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; +use bds_core::engine::task::TaskStatus; use bds_core::i18n::UiLocale; use crate::app::Message; -use crate::i18n::tw; +use crate::i18n::{t, tw}; use crate::state::navigation::TaskSnapshot; use crate::views::project_selector; @@ -131,23 +132,38 @@ pub fn view( let project_name = active_project_name.unwrap_or("\u{2014}"); let project_trigger = project_selector::trigger_button(project_name); - let running: Vec<&TaskSnapshot> = task_snapshots + let active: Vec<&TaskSnapshot> = task_snapshots .iter() - .filter(|t| t.status == "running") + .filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running)) .collect(); - let task_indicator: Element<'static, Message> = if !running.is_empty() { - let first = &running[0]; + let first = active + .iter() + .find(|task| task.status == TaskStatus::Running) + .or_else(|| active.first()); + let task_indicator: Element<'static, Message> = if let Some(first) = first { let progress_str = first .progress .map(|p| format!(" {:.0}%", p * 100.0)) .unwrap_or_default(); let phase_str = first.message.as_deref().unwrap_or(""); - let extra = if running.len() > 1 { - format!(" (+{})", running.len() - 1) + let extra = if active.len() > 1 { + format!(" (+{})", active.len() - 1) } else { String::new() }; - let display = if phase_str.is_empty() { + let display = if first.cancellation_requested { + format!( + "{}: {}{progress_str}{extra}", + t(locale, "tasks.statusCancelling"), + first.label + ) + } else if first.status == TaskStatus::Pending { + format!( + "{}: {}{extra}", + t(locale, "tasks.statusPending"), + first.label + ) + } else if phase_str.is_empty() { format!("{}{progress_str}{extra}", first.label) } else { format!("{phase_str}{progress_str}{extra}") diff --git a/docs/scripting/API_REFERENCE.md b/docs/scripting/API_REFERENCE.md index e17994b..c823657 100644 --- a/docs/scripting/API_REFERENCE.md +++ b/docs/scripting/API_REFERENCE.md @@ -3687,6 +3687,10 @@ local result = bds.publish.upload_site({ host = "example.com", username = "autho ```lua { + cancellable = true, + cancellation_requested = true, + group_id = "example-id", + group_name = "example", id = "example-id", message = "Working", name = "Example", @@ -4705,6 +4709,10 @@ local result = bds.tasks.get("example-id") ```lua { + cancellable = true, + cancellation_requested = true, + group_id = "example-id", + group_name = "example", id = "example-id", message = "Working", name = "Example", @@ -4746,6 +4754,10 @@ local result = bds.tasks.status_snapshot() running_count = 1, tasks = { { + cancellable = true, + cancellation_requested = true, + group_id = "example-id", + group_name = "example", id = "example-id", message = "Working", name = "Example", @@ -4817,6 +4829,10 @@ local result = bds.tasks.get_all() ```lua { { + cancellable = true, + cancellation_requested = true, + group_id = "example-id", + group_name = "example", id = "example-id", message = "Working", name = "Example", @@ -4855,6 +4871,10 @@ local result = bds.tasks.get_running() ```lua { { + cancellable = true, + cancellation_requested = true, + group_id = "example-id", + group_name = "example", id = "example-id", message = "Working", name = "Example", diff --git a/docs/scripting/TYPES.md b/docs/scripting/TYPES.md index 7fd0b39..30e6498 100644 --- a/docs/scripting/TYPES.md +++ b/docs/scripting/TYPES.md @@ -269,6 +269,10 @@ Public task snapshot returned by the task manager. ```lua { + cancellable = true, + cancellation_requested = true, + group_id = "example-id", + group_name = "example", id = "example-id", message = "Working", name = "Example", @@ -279,6 +283,10 @@ Public task snapshot returned by the task manager. | Field | Type | Required | Meaning | | --- | --- | --- | --- | +| `cancellable` | `boolean` | Yes | Whether a cancellation request can still be submitted. | +| `cancellation_requested` | `boolean` | Yes | Whether cooperative cancellation is in progress. | +| `group_id` | `string \| nil` | No | Identifier shared by tasks in one workflow. | +| `group_name` | `string \| nil` | No | Human-readable workflow name. | | `id` | `string` | Yes | Stable record identifier. | | `message` | `string \| nil` | No | Latest user-facing task message. | | `name` | `string` | Yes | Human-readable name. | @@ -298,6 +306,10 @@ Aggregate task status snapshot. running_count = 1, tasks = { { + cancellable = true, + cancellation_requested = true, + group_id = "example-id", + group_name = "example", id = "example-id", message = "Working", name = "Example", diff --git a/docs/scripting/api.json b/docs/scripting/api.json index 598cfec..c35e9cb 100644 --- a/docs/scripting/api.json +++ b/docs/scripting/api.json @@ -1833,6 +1833,10 @@ { "description": "Public task snapshot returned by the task manager.", "fields": { + "cancellable": "boolean", + "cancellation_requested": "boolean", + "group_id": "string | nil", + "group_name": "string | nil", "id": "string", "message": "string | nil", "name": "string", diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index b3b8956..5438ad9 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -33,6 +33,7 @@ editor-matchingTags = Passende Tags editor-createTag = Tag erstellen: { $name } duplicates-title = Doppelte Beiträge finden duplicates-searching = Duplikate werden gesucht… +duplicates-complete = Duplikatsuche abgeschlossen duplicates-count = { $count } mögliche Paare duplicates-disabled = Semantische Ähnlichkeit ist für dieses Projekt nicht aktiviert. duplicates-empty = Keine möglichen doppelten Beiträge gefunden. @@ -187,6 +188,7 @@ tasks-noActive = Keine aktiven Aufgaben tasks-cancelTask = Aufgabe abbrechen tasks-statusPending = Ausstehend tasks-statusRunning = Laufend +tasks-statusCancelling = Wird abgebrochen… tasks-statusCompleted = Abgeschlossen tasks-statusFailed = Fehlgeschlagen: { $error } tasks-statusCancelled = Abgebrochen @@ -238,6 +240,7 @@ siteValidation-apply = Validierung anwenden siteValidation-applying = Wird angewendet... siteValidation-idle = Starte die Website-Validierung, um generierte Dateien mit dem erwarteten Output zu vergleichen. siteValidation-running = Validierung läuft... +siteValidation-complete = Validierung abgeschlossen siteValidation-clean = Es wurden keine fehlenden, zusätzlichen oder veralteten generierten Dateien gefunden. siteValidation-error = Validierung fehlgeschlagen siteValidation-missing = Fehlende Dateien @@ -569,6 +572,8 @@ settings-rebuildMedia = Medien neu aufbauen settings-rebuildScripts = Skripte neu aufbauen settings-rebuildTemplates = Vorlagen neu aufbauen settings-rebuildLinks = Links neu aufbauen +settings-rebuildPostLinks = Beitragslinks werden neu aufgebaut… +settings-rebuildMediaLinks = Medienlinks werden neu aufgebaut… settings-regenerateThumbnails = Vorschaubilder regenerieren settings-openDataFolder = Datenordner öffnen settings-installCli = CLI installieren @@ -641,6 +646,10 @@ metadataDiff-dbToFile = Datenbank verwenden metadataDiff-orphans = Verwaiste Dateien metadataDiff-errors = Fehler metadataDiff-repaired = Metadatenabweichung repariert +metadataDiff-repair = Metadaten werden repariert… +metadataDiff-complete = Metadatenvergleich abgeschlossen +metadataDiff-importOrphan = Datei importieren +metadataDiff-orphanImported = Verwaiste Datei importiert metadataDiff-field = { $field }: DB = { $database } · Datei = { $file } metadataDiff-entity = { $entity } · { $path } metadataDiff-orphan = { $reason } · { $path } @@ -659,6 +668,7 @@ engine-progress-mediaItem = Medien: { $current }/{ $total } — { $name } engine-progress-rebuildingTemplates = Vorlagen werden neu aufgebaut… engine-progress-rebuildingScripts = Skripte werden neu aufgebaut… engine-progress-importingTags = Schlagwörter werden importiert… +engine-progress-rebuildingThumbnails = Fehlende Vorschaubilder werden neu aufgebaut… engine-progress-refreshingSemanticIndex = Semantischer Index wird aktualisiert… engine-progress-rebuildComplete = Neuaufbau abgeschlossen translationValidation-run = Prüfen @@ -683,6 +693,9 @@ engine-renderingPages = Seiten werden gerendert ({ $current }/{ $total }) engine-generatedPage = Erstellt { $url } ({ $current }/{ $total }) engine-rewrotePage = Neu geschrieben { $url } ({ $current }/{ $total }) engine-renderSiteGroup = Website rendern +engine-prepareGeneration = Website-Generierung vorbereiten +engine-preparingGeneration = Routen und Vorlagen werden vorbereitet… +engine-generationPrepared = Website-Generierung vorbereitet engine-forceRenderSiteGroup = Website vollständig neu generieren engine-applyValidationGroup = Website-Validierung anwenden engine-renderSiteCore = Website-Kern rendern @@ -692,7 +705,7 @@ engine-renderTagArchives = Schlagwortarchive rendern engine-renderDateArchives = Datumsarchive rendern engine-buildSearchIndex = Suchindex erstellen engine-builtSearchFile = { $path } erstellt ({ $current }/{ $total }) -engine-generationCancelled = Website-Erstellung abgebrochen +engine-generationCancellationRequested = Website-Erstellung wird abgebrochen… engine-generationSummary = Website-Erstellung abgeschlossen: geschrieben={ $written }, übersprungen={ $skipped }, gelöscht={ $deleted }, Ausgabe={ $output } engine-publicUrlRequired = Vor der Website-Erstellung ist eine öffentliche URL erforderlich settings-imageImportConcurrency = Gleichzeitige Bildimporte (1–8) @@ -845,6 +858,7 @@ tui-categoryEditing = Kategorien bearbeiten tui-categoryEditingGuiOnly = Kategorien können weiterhin in der Desktop-Anwendung hinzugefügt, bearbeitet und entfernt werden tui-openItemDeleted = Der geöffnete Eintrag wurde von einem anderen Client gelöscht. tui-taskRunning = { $label } — läuft +tui-taskCancelling = { $label } — wird abgebrochen… tui-taskProgress = { $label } — { $percent } % tui-taskComplete = { $label } — abgeschlossen tui-taskFailed = { $label } — { $error } diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index b7011a5..5a13df1 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -33,6 +33,7 @@ editor-matchingTags = Matching tags editor-createTag = Create tag: { $name } duplicates-title = Find Duplicate Posts duplicates-searching = Searching for duplicates… +duplicates-complete = Duplicate search complete duplicates-count = { $count } potential pairs duplicates-disabled = Semantic similarity is not enabled for this project. duplicates-empty = No potential duplicate posts found. @@ -187,6 +188,7 @@ tasks-noActive = No active tasks tasks-cancelTask = Cancel task tasks-statusPending = Pending tasks-statusRunning = Running +tasks-statusCancelling = Cancelling… tasks-statusCompleted = Completed tasks-statusFailed = Failed: { $error } tasks-statusCancelled = Cancelled @@ -238,6 +240,7 @@ siteValidation-apply = Apply Validation siteValidation-applying = Applying... siteValidation-idle = Run site validation to compare generated files against expected output. siteValidation-running = Validation in progress... +siteValidation-complete = Validation complete siteValidation-clean = No missing, extra, or stale generated files were found. siteValidation-error = Validation failed siteValidation-missing = Missing Files @@ -569,6 +572,8 @@ settings-rebuildMedia = Rebuild Media settings-rebuildScripts = Rebuild Scripts settings-rebuildTemplates = Rebuild Templates settings-rebuildLinks = Rebuild Links +settings-rebuildPostLinks = Rebuilding post links… +settings-rebuildMediaLinks = Rebuilding media links… settings-regenerateThumbnails = Regenerate Thumbnails settings-openDataFolder = Open Data Folder settings-installCli = Install CLI @@ -641,6 +646,10 @@ metadataDiff-dbToFile = Use Database metadataDiff-orphans = Orphan files metadataDiff-errors = Errors metadataDiff-repaired = Metadata difference repaired +metadataDiff-repair = Repairing metadata… +metadataDiff-complete = Metadata comparison complete +metadataDiff-importOrphan = Import File +metadataDiff-orphanImported = Orphan file imported metadataDiff-field = { $field }: DB = { $database } · File = { $file } metadataDiff-entity = { $entity } · { $path } metadataDiff-orphan = { $reason } · { $path } @@ -659,6 +668,7 @@ engine-progress-mediaItem = Media: { $current }/{ $total } — { $name } engine-progress-rebuildingTemplates = Rebuilding templates… engine-progress-rebuildingScripts = Rebuilding scripts… engine-progress-importingTags = Importing tags… +engine-progress-rebuildingThumbnails = Rebuilding missing thumbnails… engine-progress-refreshingSemanticIndex = Refreshing semantic index… engine-progress-rebuildComplete = Rebuild complete translationValidation-run = Validate @@ -683,6 +693,9 @@ engine-renderingPages = Rendering pages ({ $current }/{ $total }) engine-generatedPage = Generated { $url } ({ $current }/{ $total }) engine-rewrotePage = Rewrote { $url } ({ $current }/{ $total }) engine-renderSiteGroup = Render Site +engine-prepareGeneration = Prepare site generation +engine-preparingGeneration = Preparing routes and templates… +engine-generationPrepared = Site generation prepared engine-forceRenderSiteGroup = Force Render Site engine-applyValidationGroup = Apply Site Validation engine-renderSiteCore = Render Site Core @@ -692,7 +705,7 @@ engine-renderTagArchives = Render Tag Archives engine-renderDateArchives = Render Date Archives engine-buildSearchIndex = Build Search Index engine-builtSearchFile = Built { $path } ({ $current }/{ $total }) -engine-generationCancelled = Site generation cancelled +engine-generationCancellationRequested = Cancelling site generation… engine-generationSummary = Site generation complete: written={ $written }, skipped={ $skipped }, deleted={ $deleted }, output={ $output } engine-publicUrlRequired = Public URL is required before generating the site settings-imageImportConcurrency = Image Import Concurrency (1–8) @@ -845,6 +858,7 @@ tui-categoryEditing = Category Editing tui-categoryEditingGuiOnly = Category add, edit, and removal remain in the desktop application tui-openItemDeleted = The open item was deleted by another client. tui-taskRunning = { $label } — running +tui-taskCancelling = { $label } — cancelling… tui-taskProgress = { $label } — { $percent }% tui-taskComplete = { $label } — complete tui-taskFailed = { $label } — { $error } diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index 044d4d7..1885df6 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -33,6 +33,7 @@ editor-matchingTags = Etiquetas coincidentes editor-createTag = Crear etiqueta: { $name } duplicates-title = Buscar publicaciones duplicadas duplicates-searching = Buscando duplicados… +duplicates-complete = Búsqueda de duplicados completada duplicates-count = { $count } pares potenciales duplicates-disabled = La similitud semántica no está activada para este proyecto. duplicates-empty = No se encontraron publicaciones posiblemente duplicadas. @@ -187,6 +188,7 @@ tasks-noActive = No hay tareas activas tasks-cancelTask = Cancelar tarea tasks-statusPending = Pendiente tasks-statusRunning = En ejecución +tasks-statusCancelling = Cancelando… tasks-statusCompleted = Completada tasks-statusFailed = Error: { $error } tasks-statusCancelled = Cancelada @@ -238,6 +240,7 @@ siteValidation-apply = Aplicar validación siteValidation-applying = Aplicando... siteValidation-idle = Ejecuta la validación del sitio para comparar los archivos generados con la salida esperada. siteValidation-running = Validación en curso... +siteValidation-complete = Validación completada siteValidation-clean = No se encontraron archivos generados faltantes, extra o desactualizados. siteValidation-error = La validación falló siteValidation-missing = Archivos faltantes @@ -569,6 +572,8 @@ settings-rebuildMedia = Reconstruir medios settings-rebuildScripts = Reconstruir scripts settings-rebuildTemplates = Reconstruir plantillas settings-rebuildLinks = Reconstruir enlaces +settings-rebuildPostLinks = Reconstruyendo enlaces de entradas… +settings-rebuildMediaLinks = Reconstruyendo enlaces de medios… settings-regenerateThumbnails = Regenerar miniaturas settings-openDataFolder = Abrir carpeta de datos settings-installCli = Instalar CLI @@ -641,6 +646,10 @@ metadataDiff-dbToFile = Usar base de datos metadataDiff-orphans = Archivos huérfanos metadataDiff-errors = Errores metadataDiff-repaired = Diferencia de metadatos reparada +metadataDiff-repair = Reparando metadatos… +metadataDiff-complete = Comparación de metadatos completada +metadataDiff-importOrphan = Importar archivo +metadataDiff-orphanImported = Archivo huérfano importado metadataDiff-field = { $field }: BD = { $database } · Archivo = { $file } metadataDiff-entity = { $entity } · { $path } metadataDiff-orphan = { $reason } · { $path } @@ -659,6 +668,7 @@ engine-progress-mediaItem = Medios: { $current }/{ $total } — { $name } engine-progress-rebuildingTemplates = Reconstruyendo las plantillas… engine-progress-rebuildingScripts = Reconstruyendo los scripts… engine-progress-importingTags = Importando las etiquetas… +engine-progress-rebuildingThumbnails = Reconstruyendo miniaturas faltantes… engine-progress-refreshingSemanticIndex = Actualizando el índice semántico… engine-progress-rebuildComplete = Reconstrucción completada translationValidation-run = Validar @@ -683,6 +693,9 @@ engine-renderingPages = Renderizando páginas ({ $current }/{ $total }) engine-generatedPage = Generado { $url } ({ $current }/{ $total }) engine-rewrotePage = Reescrito { $url } ({ $current }/{ $total }) engine-renderSiteGroup = Renderizar sitio +engine-prepareGeneration = Preparar la generación del sitio +engine-preparingGeneration = Preparando rutas y plantillas… +engine-generationPrepared = Generación del sitio preparada engine-forceRenderSiteGroup = Forzar la regeneración del sitio engine-applyValidationGroup = Aplicar validación del sitio engine-renderSiteCore = Renderizar núcleo del sitio @@ -692,7 +705,7 @@ engine-renderTagArchives = Renderizar archivos de etiquetas engine-renderDateArchives = Renderizar archivos por fecha engine-buildSearchIndex = Crear índice de búsqueda engine-builtSearchFile = Creado { $path } ({ $current }/{ $total }) -engine-generationCancelled = Generación del sitio cancelada +engine-generationCancellationRequested = Cancelando la generación del sitio… engine-generationSummary = Generación completada: escritos={ $written }, omitidos={ $skipped }, eliminados={ $deleted }, salida={ $output } engine-publicUrlRequired = Se requiere una URL pública antes de generar el sitio settings-imageImportConcurrency = Importaciones de imágenes simultáneas (1–8) @@ -845,6 +858,7 @@ tui-categoryEditing = Edición de categorías tui-categoryEditingGuiOnly = La creación, edición y eliminación de categorías permanecen en la aplicación de escritorio tui-openItemDeleted = Otro cliente eliminó el elemento abierto. tui-taskRunning = { $label } — en curso +tui-taskCancelling = { $label } — cancelando… tui-taskProgress = { $label } — { $percent } % tui-taskComplete = { $label } — completado tui-taskFailed = { $label } — { $error } diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index cfc3e3f..ed8d646 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -33,6 +33,7 @@ editor-matchingTags = Étiquettes correspondantes editor-createTag = Créer l’étiquette : { $name } duplicates-title = Rechercher les articles en double duplicates-searching = Recherche des doublons… +duplicates-complete = Recherche des doublons terminée duplicates-count = { $count } paires potentielles duplicates-disabled = La similarité sémantique n’est pas activée pour ce projet. duplicates-empty = Aucun doublon potentiel trouvé. @@ -187,6 +188,7 @@ tasks-noActive = Aucune tâche active tasks-cancelTask = Annuler la tâche tasks-statusPending = En attente tasks-statusRunning = En cours +tasks-statusCancelling = Annulation… tasks-statusCompleted = Terminée tasks-statusFailed = Échec : { $error } tasks-statusCancelled = Annulée @@ -238,6 +240,7 @@ siteValidation-apply = Appliquer la validation siteValidation-applying = Application en cours... siteValidation-idle = Lancez la validation du site pour comparer les fichiers générés à la sortie attendue. siteValidation-running = Validation en cours... +siteValidation-complete = Validation terminée siteValidation-clean = Aucun fichier généré manquant, supplémentaire ou obsolète n'a été trouvé. siteValidation-error = La validation a échoué siteValidation-missing = Fichiers manquants @@ -569,6 +572,8 @@ settings-rebuildMedia = Reconstruire les médias settings-rebuildScripts = Reconstruire les scripts settings-rebuildTemplates = Reconstruire les modèles settings-rebuildLinks = Reconstruire les liens +settings-rebuildPostLinks = Reconstruction des liens d’articles… +settings-rebuildMediaLinks = Reconstruction des liens de médias… settings-regenerateThumbnails = Régénérer les miniatures settings-openDataFolder = Ouvrir le dossier de données settings-installCli = Installer la CLI @@ -641,6 +646,10 @@ metadataDiff-dbToFile = Utiliser la base metadataDiff-orphans = Fichiers orphelins metadataDiff-errors = Erreurs metadataDiff-repaired = Différence de métadonnées réparée +metadataDiff-repair = Réparation des métadonnées… +metadataDiff-complete = Comparaison des métadonnées terminée +metadataDiff-importOrphan = Importer le fichier +metadataDiff-orphanImported = Fichier orphelin importé metadataDiff-field = { $field } : base = { $database } · Fichier = { $file } metadataDiff-entity = { $entity } · { $path } metadataDiff-orphan = { $reason } · { $path } @@ -659,6 +668,7 @@ engine-progress-mediaItem = Médias : { $current }/{ $total } — { $name } engine-progress-rebuildingTemplates = Reconstruction des modèles… engine-progress-rebuildingScripts = Reconstruction des scripts… engine-progress-importingTags = Importation des étiquettes… +engine-progress-rebuildingThumbnails = Reconstruction des miniatures manquantes… engine-progress-refreshingSemanticIndex = Actualisation de l’index sémantique… engine-progress-rebuildComplete = Reconstruction terminée translationValidation-run = Valider @@ -683,6 +693,9 @@ engine-renderingPages = Rendu des pages ({ $current }/{ $total }) engine-generatedPage = Généré { $url } ({ $current }/{ $total }) engine-rewrotePage = Réécrit { $url } ({ $current }/{ $total }) engine-renderSiteGroup = Générer le site +engine-prepareGeneration = Préparer la génération du site +engine-preparingGeneration = Préparation des routes et des modèles… +engine-generationPrepared = Génération du site préparée engine-forceRenderSiteGroup = Forcer la régénération du site engine-applyValidationGroup = Appliquer la validation du site engine-renderSiteCore = Générer le cœur du site @@ -692,7 +705,7 @@ engine-renderTagArchives = Générer les archives de mots-clés engine-renderDateArchives = Générer les archives chronologiques engine-buildSearchIndex = Construire l’index de recherche engine-builtSearchFile = Construit { $path } ({ $current }/{ $total }) -engine-generationCancelled = Génération du site annulée +engine-generationCancellationRequested = Annulation de la génération du site… engine-generationSummary = Génération terminée : écrits={ $written }, ignorés={ $skipped }, supprimés={ $deleted }, sortie={ $output } engine-publicUrlRequired = Une URL publique est requise avant de générer le site settings-imageImportConcurrency = Imports d’images simultanés (1–8) @@ -845,6 +858,7 @@ tui-categoryEditing = Modification des catégories tui-categoryEditingGuiOnly = L’ajout, la modification et la suppression de catégories restent dans l’application de bureau tui-openItemDeleted = L’élément ouvert a été supprimé par un autre client. tui-taskRunning = { $label } — en cours +tui-taskCancelling = { $label } — annulation… tui-taskProgress = { $label } — { $percent } % tui-taskComplete = { $label } — terminé tui-taskFailed = { $label } — { $error } diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index a33d17d..592d317 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -33,6 +33,7 @@ editor-matchingTags = Tag corrispondenti editor-createTag = Crea tag: { $name } duplicates-title = Trova post duplicati duplicates-searching = Ricerca duplicati… +duplicates-complete = Ricerca duplicati completata duplicates-count = { $count } coppie potenziali duplicates-disabled = La somiglianza semantica non è abilitata per questo progetto. duplicates-empty = Nessun possibile post duplicato trovato. @@ -187,6 +188,7 @@ tasks-noActive = Nessuna attività attiva tasks-cancelTask = Annulla attività tasks-statusPending = In attesa tasks-statusRunning = In esecuzione +tasks-statusCancelling = Annullamento… tasks-statusCompleted = Completata tasks-statusFailed = Non riuscita: { $error } tasks-statusCancelled = Annullata @@ -238,6 +240,7 @@ siteValidation-apply = Applica validazione siteValidation-applying = Applicazione in corso... siteValidation-idle = Esegui la validazione del sito per confrontare i file generati con l'output previsto. siteValidation-running = Validazione in corso... +siteValidation-complete = Validazione completata siteValidation-clean = Non sono stati trovati file generati mancanti, aggiuntivi o obsoleti. siteValidation-error = Validazione non riuscita siteValidation-missing = File mancanti @@ -569,6 +572,8 @@ settings-rebuildMedia = Ricostruisci media settings-rebuildScripts = Ricostruisci script settings-rebuildTemplates = Ricostruisci modelli settings-rebuildLinks = Ricostruisci link +settings-rebuildPostLinks = Ricostruzione dei link degli articoli… +settings-rebuildMediaLinks = Ricostruzione dei link multimediali… settings-regenerateThumbnails = Rigenera miniature settings-openDataFolder = Apri cartella dati settings-installCli = Installa CLI @@ -641,6 +646,10 @@ metadataDiff-dbToFile = Usa database metadataDiff-orphans = File orfani metadataDiff-errors = Errori metadataDiff-repaired = Differenza nei metadati riparata +metadataDiff-repair = Riparazione dei metadati… +metadataDiff-complete = Confronto dei metadati completato +metadataDiff-importOrphan = Importa file +metadataDiff-orphanImported = File orfano importato metadataDiff-field = { $field }: DB = { $database } · File = { $file } metadataDiff-entity = { $entity } · { $path } metadataDiff-orphan = { $reason } · { $path } @@ -659,6 +668,7 @@ engine-progress-mediaItem = Media: { $current }/{ $total } — { $name } engine-progress-rebuildingTemplates = Ricostruzione dei modelli… engine-progress-rebuildingScripts = Ricostruzione degli script… engine-progress-importingTags = Importazione dei tag… +engine-progress-rebuildingThumbnails = Ricostruzione delle miniature mancanti… engine-progress-refreshingSemanticIndex = Aggiornamento dell’indice semantico… engine-progress-rebuildComplete = Ricostruzione completata translationValidation-run = Verifica @@ -683,6 +693,9 @@ engine-renderingPages = Rendering delle pagine ({ $current }/{ $total }) engine-generatedPage = Generato { $url } ({ $current }/{ $total }) engine-rewrotePage = Riscritto { $url } ({ $current }/{ $total }) engine-renderSiteGroup = Genera sito +engine-prepareGeneration = Prepara la generazione del sito +engine-preparingGeneration = Preparazione di percorsi e modelli… +engine-generationPrepared = Generazione del sito preparata engine-forceRenderSiteGroup = Forza la rigenerazione del sito engine-applyValidationGroup = Applica convalida del sito engine-renderSiteCore = Genera nucleo del sito @@ -692,7 +705,7 @@ engine-renderTagArchives = Genera archivi dei tag engine-renderDateArchives = Genera archivi per data engine-buildSearchIndex = Crea indice di ricerca engine-builtSearchFile = Creato { $path } ({ $current }/{ $total }) -engine-generationCancelled = Generazione del sito annullata +engine-generationCancellationRequested = Annullamento della generazione del sito… engine-generationSummary = Generazione completata: scritti={ $written }, ignorati={ $skipped }, eliminati={ $deleted }, output={ $output } engine-publicUrlRequired = È richiesto un URL pubblico prima di generare il sito settings-imageImportConcurrency = Importazioni immagini simultanee (1–8) @@ -845,6 +858,7 @@ tui-categoryEditing = Modifica delle categorie tui-categoryEditingGuiOnly = L’aggiunta, la modifica e la rimozione delle categorie restano nell’applicazione desktop tui-openItemDeleted = L’elemento aperto è stato eliminato da un altro client. tui-taskRunning = { $label } — in esecuzione +tui-taskCancelling = { $label } — annullamento… tui-taskProgress = { $label } — { $percent }% tui-taskComplete = { $label } — completato tui-taskFailed = { $label } — { $error } diff --git a/specs/generation.allium b/specs/generation.allium index 1f799b2..d7409e2 100644 --- a/specs/generation.allium +++ b/specs/generation.allium @@ -44,9 +44,10 @@ surface GenerationRuntimeSurface { -- "Generated" for a full render and "Rewrote" for a validation apply — -- and advances the bar by the number of URLs written. -- - -- Before section work begins, generation counts its planned URLs. The - -- task reports 0/total immediately and advances once per rendered URL, - -- matching bDS2's route-based generation progress. + -- Route preparation and counting happen in background work before + -- section rendering. Validation totals count only prepared URLs + -- selected by the validation report, not the whole section. The task + -- reports 0/total immediately and advances once per rendered URL. -- -- Full generation and validation apply share the same structuring: a -- task group containing one task per section (Render Site Core, Render @@ -310,6 +311,9 @@ rule ApplyValidation { -- unchanged post route refreshes its tracked generation time so the -- automatic follow-up validation does not report it stale again. ensures: GenerateSiteRequested(plan_generation(project_id, sections)) + -- Observable phases are prepare, affected render sections, optional + -- calendar regeneration, search indexing, and final validation. Failure or + -- cancellation stops every dependent phase. } -- Day-block grouping for archives diff --git a/specs/layout.allium b/specs/layout.allium index a8ba0d8..a856f84 100644 --- a/specs/layout.allium +++ b/specs/layout.allium @@ -262,7 +262,9 @@ invariant PanelTabFallback { -- git_log unavailable when neither post nor media tab is active } --- Tasks tab: last 10 tasks, newest first, with progress/cancel. +-- Tasks tab: every active task plus the 10 newest finished tasks, with +-- progress/cancel. Active groups always include every child needed to show a +-- complete group even when this exceeds the finished-history limit. -- Tasks with shared group_id are collapsible groups showing bDS2-style -- average child progress; pending children contribute zero. -- Output tab: log entries with copy-all button. @@ -287,6 +289,12 @@ value StatusBarLeft { running_task_overflow: Integer? -- "+N more" count when multiple running } +invariant StatusBarTaskSelection { + -- The indicator selects the first running task, otherwise the first + -- pending task. Overflow counts every other running or pending task. + -- Status remains typed until its display label is localized. +} + value StatusBarRight { -- In display order (left to right): post_status: String? -- draft|published|archived dot, when post tab active diff --git a/specs/script.allium b/specs/script.allium index 67e8787..3fbc656 100644 --- a/specs/script.allium +++ b/specs/script.allium @@ -135,6 +135,11 @@ surface ScriptRuntimeSurface { -- sync_from_posts. -- bds.tasks: get, status_snapshot, cancel, get_all, get_running, -- clear_completed. + -- get and get_all inspect every retained task rather than the + -- status-snapshot history cap; get_all is newest first. get_running + -- contains only running tasks. cancel succeeds only for a pending or + -- running task. clear_completed removes completed tasks while keeping + -- failed and cancelled results. -- bds.sync: check_availability, get_repo_state, get_status, -- get_history, get_remote_state, fetch, pull, push, commit_all. -- bds.publish: upload_site. diff --git a/specs/task.allium b/specs/task.allium index 2e795bb..2f52a6e 100644 --- a/specs/task.allium +++ b/specs/task.allium @@ -6,6 +6,9 @@ entity Task { name: String status: pending | running | completed | failed | cancelled + source: local | remote + cancellable: Boolean + cancellation_requested: Boolean progress: Decimal? -- 0.0..1.0 message: String? group_id: String? -- Optional task grouping @@ -28,6 +31,7 @@ surface TaskControlSurface { SubmitTaskRequested(name, work) CancelTaskRequested(task) RegisterExternalTaskRequested(name) + TaskStateRequested() } surface TaskRuntimeSurface { @@ -38,6 +42,8 @@ surface TaskRuntimeSurface { TaskWorkFailed(task, error_message) ProgressReported(task, value, message) FinishedTaskEvictionDue() + WorkerCapacityAvailable() + TaskWorkStopped(task) } surface TaskSurface { @@ -46,6 +52,7 @@ surface TaskSurface { exposes: task.name task.status + task.source task.progress when task.progress != null task.message when task.message != null task.group_id when task.group_id != null @@ -74,13 +81,23 @@ invariant FifoQueue { rule SubmitTask { when: SubmitTaskRequested(name, work) - let running_tasks = Tasks where status = running ensures: - let task = Task.created(name: name, status: pending) + let task = Task.created( + name: name, + status: pending, + source: local, + cancellable: true, + cancellation_requested: false + ) task.status = pending - if running_tasks.count < config.max_concurrent: - task.status = running - TaskStarted(task, work) +} + +rule StartNextTask { + when: WorkerCapacityAvailable() + let pending_tasks = Tasks where status = pending + requires: pending_tasks.count > 0 + ensures: pending_tasks.first.status = running + ensures: TaskStarted(pending_tasks.first) } rule CompleteTask { @@ -99,8 +116,28 @@ rule FailTask { rule CancelTask { when: CancelTaskRequested(task) - requires: task.status = running or task.status = pending - -- Cancellation uses a runtime-specific cancellation mechanism + requires: task.status = pending + ensures: task.status = cancelled + ensures: NextQueuedTaskStarted() +} + +rule RequestRunningTaskCancellation { + when: CancelTaskRequested(task) + requires: task.status = running + requires: task.cancellable + ensures: task.cancellation_requested = true + ensures: StopTaskWorkRequested(task) + @guidance + -- Long operations observe the request at phase or item boundaries. + -- Owned child processes are terminated when the platform permits it. + -- The task remains running and occupies its worker slot until work + -- actually stops; status surfaces expose cancellation_requested. +} + +rule FinishRunningTaskCancellation { + when: TaskWorkStopped(task) + requires: task.status = running + requires: task.cancellation_requested ensures: task.status = cancelled ensures: NextQueuedTaskStarted() } @@ -110,6 +147,9 @@ rule ReportProgress { -- Progress events throttled to 250ms ensures: task.progress = value ensures: task.message = message + @guidance + -- A report whose value is 1.0 is always accepted so the terminal + -- localized phase text cannot be dropped by throttling. } invariant ProgressThrottled { @@ -139,11 +179,24 @@ rule EvictFinishedTasks { ensures: not exists task } +rule PruneFinishedTasksOnAccess { + when: TaskStateRequested() + for task in Tasks where status in {completed, failed, cancelled}: + if now - task.finished_at >= config.finished_task_ttl: + ensures: not exists task +} + -- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts) rule RegisterExternalTask { when: RegisterExternalTaskRequested(name) ensures: - let task = Task.created(name: name, status: running) + let task = Task.created( + name: name, + status: running, + source: remote, + cancellable: true, + cancellation_requested: false + ) task.status = running @guidance -- External tasks are not managed by the queue