fix: more work on background tasks

This commit is contained in:
2026-07-24 11:43:11 +02:00
parent c187108f89
commit da13bac755
45 changed files with 2905 additions and 732 deletions

View File

@@ -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.

View File

@@ -355,7 +355,7 @@ fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> {
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<CommandOutput> {
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<CommandOutput> {
"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

View File

@@ -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<String, u32> = BTreeMap::new();
let mut days: BTreeMap<String, u32> = 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());
}
}

View File

@@ -15,6 +15,8 @@ pub enum EngineError {
Conflict(String),
#[error("validation error: {0}")]
Validation(String),
#[error("operation cancelled")]
Cancelled,
}
impl From<crate::db::DatabaseError> for EngineError {

View File

@@ -63,6 +63,15 @@ pub struct PreparedSiteGeneration {
generated_hashes: Arc<HashMap<String, String>>,
}
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::<HashSet<_>>()
});
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,

View File

@@ -53,9 +53,18 @@ pub fn rebuild_media_links(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<MediaLinkRebuildReport> {
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<ItemProgressFn>,
) -> EngineResult<MediaLinkRebuildReport> {
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<MediaLinkRebuildReport> {
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<ThumbnailRepairReport> {
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<ThumbnailRepairReport> {
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<dyn Fn(usize, usize, &str) + Send>;
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> 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) => {

View File

@@ -70,8 +70,27 @@ pub fn compute_metadata_diff(
data_dir: &Path,
project_id: &str,
) -> EngineResult<DiffReport> {
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<DiffReport> {
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)
}

View File

@@ -716,7 +716,7 @@ pub fn rebuild_posts_from_filesystem(
}
/// Per-item progress callback: (current_item, total_items, item_description).
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> 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<usize> {
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<ItemProgressFn>,
) -> EngineResult<usize> {
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()

View File

@@ -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<PublishJob> {
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<PublishJob> {
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<PublishJob> {
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"));

View File

@@ -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<String>,
}
@@ -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<dyn Fn(f32, &RebuildProgress) + Send + Sync>;
pub type ProgressFn = Arc<dyn Fn(f32, &RebuildProgress) -> bool + Send + Sync>;
/// Orchestrate a full rebuild from filesystem into the database.
///
@@ -178,23 +202,26 @@ fn rebuild_from_filesystem_inner(
on_progress: Option<ProgressFn>,
) -> EngineResult<FullRebuildReport> {
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<post::ItemProgressFn> = 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<media::ItemProgressFn> = 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();

View File

@@ -17,6 +17,8 @@ pub struct ScriptRebuildReport {
pub errors: Vec<String>,
}
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> 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<ScriptRebuildReport> {
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<ItemProgressFn>,
) -> EngineResult<ScriptRebuildReport> {
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::<Vec<_>>();
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) {

View File

@@ -67,7 +67,7 @@ pub struct ReindexReport {
}
/// Per-item progress callback: (current_item, total_items, item_description).
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> 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();

View File

@@ -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<f32>,
pub message: Option<String>,
pub cancellation_requested: bool,
pub created_at: Instant,
}
@@ -42,228 +46,340 @@ struct TaskEntry {
created_at: Instant,
finished_at: Option<Instant>,
last_progress_report: Option<Instant>,
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<TaskId>,
tasks: Mutex<Vec<TaskEntry>>,
state: Mutex<TaskState>,
state_changed: Condvar,
async_changed: Notify,
}
struct TaskState {
next_id: TaskId,
tasks: HashMap<TaskId, TaskEntry>,
order: VecDeque<TaskId>,
pending: VecDeque<TaskId>,
worker_count: usize,
}
/// Capacity reservation held from asynchronous admission until the worker exits.
pub struct TaskWorker {
manager: Arc<TaskManager>,
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<Self>, task_id: TaskId) -> Option<TaskWorker> {
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<Self>, task_id: TaskId) -> Option<TaskWorker> {
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::<Vec<_>>();
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<String> {
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<Arc<AtomicBool>> {
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<TaskStatus> {
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<f32>, message: Option<String>) {
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<f32> {
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<TaskSnapshot> {
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::<Vec<_>>();
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::<HashSet<_>>();
let mut finished = state
.tasks
.values()
.filter(|task| task.finished_at.is_some())
.collect::<Vec<_>>();
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::<HashSet<_>>();
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<TaskSnapshot> {
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<TaskSnapshot> {
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<TaskSnapshot> {
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::<HashSet<_>>();
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));
}
}

View File

@@ -17,6 +17,8 @@ pub struct TemplateRebuildReport {
pub errors: Vec<String>,
}
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> 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<TemplateRebuildReport> {
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<ItemProgressFn>,
) -> EngineResult<TemplateRebuildReport> {
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::<Vec<_>>();
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) {

View File

@@ -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<SiteValidationReport> {
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<SiteValidationReport> {
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::<HashSet<_>>();
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::<HashMap<_, _>>();
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,

View File

@@ -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<dyn Fn(usize, usize, &str) + Send>;
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> 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;

View File

@@ -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,

View File

@@ -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<String>>,
) -> 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,

View File

@@ -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::<Vec<_>>();
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<Value> {
}
fn public_task(value: TaskSnapshot) -> HostResult<Value> {
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<Value> {
};
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)}),
)
}

View File

@@ -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.",

View File

@@ -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

View File

@@ -57,7 +57,12 @@ pub enum ServerMessage {
pub struct RemoteTask {
pub id: u64,
pub label: String,
pub group_id: Option<String>,
pub group_name: Option<String>,
pub status: String,
pub progress: Option<f32>,
pub message: Option<String>,
#[serde(default)]
pub cancellation_requested: bool,
pub cancellable: bool,
}

View File

@@ -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<BackgroundResult> + Send + 'static,
work: impl FnOnce(Arc<TaskManager>, TaskId) -> Result<BackgroundResult> + 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<String> {
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<String> {
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 {

File diff suppressed because it is too large Load Diff

View File

@@ -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", &current.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", &current.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) => {

View File

@@ -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())

View File

@@ -14,35 +14,40 @@ fn shorten_task_url(url: &str) -> String {
}
impl BdsApp {
pub(super) fn finish_result_task<T>(
&self,
task_id: TaskId,
result: &Result<T, String>,
) -> 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::<Vec<_>>();
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<engine::validate_site::SiteValidationReport>,
force: bool,
prepared: Arc<engine::generation::PreparedSiteGeneration>,
page_estimates: HashMap<engine::generation::GenerationSection, usize>,
) -> Task<Message> {
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::<Vec<_>>();
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::<Vec<_>>(),
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[&section];
@@ -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<Message> {
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", &current.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<Message> {
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<T, F, M>(
&mut self,
label_key: &'static str,
work: F,
message: M,
) -> Task<Message>
where
T: Send + 'static,
F: FnOnce(PathBuf, String, PathBuf, Arc<TaskManager>, TaskId) -> Result<T, String>
+ Send
+ 'static,
M: Fn(TaskId, Result<T, String>) -> 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<F>(
&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<engine::generation::PreparedSiteGeneration>,
task_manager: Arc<TaskManager>,
task_id: TaskId,
section: engine::generation::GenerationSection,
@@ -498,9 +729,6 @@ fn run_site_generation_section(
locale: UiLocale,
expected_pages: usize,
) -> Result<engine::generation::GenerationReport, String> {
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::<Result<Vec<_>, _>>()
.map_err(|error| error.to_string())?
.into_iter()
.flatten()
.collect::<Vec<_>>();
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,

View File

@@ -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<String>,
pub group_name: Option<String>,
pub status: String,
pub status: bds_core::engine::task::TaskStatus,
pub progress: Option<f32>,
pub message: Option<String>,
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 {

View File

@@ -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),
]

View File

@@ -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),
));

View File

@@ -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,

View File

@@ -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<f32> {
(!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::<f32>()
/ members.len() as f32
@@ -219,7 +227,7 @@ pub fn view(
.padding(8)
.into()
} else {
let visible = task_snapshots.iter().rev().take(10).collect::<Vec<_>>();
let visible = task_snapshots.iter().collect::<Vec<_>>();
let mut rendered_groups = HashSet::new();
let mut items: Vec<Element<'static, Message>> = 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()

View File

@@ -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}")

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (18)
@@ -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 }

View File

@@ -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 (18)
@@ -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 }

View File

@@ -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 (18)
@@ -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 }

View File

@@ -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 nest 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 darticles…
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 lindex 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 lindex 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 dimages simultanés (18)
@@ -845,6 +858,7 @@ tui-categoryEditing = Modification des catégories
tui-categoryEditingGuiOnly = Lajout, la modification et la suppression de catégories restent dans lapplication 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 }

View File

@@ -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 dellindice 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 (18)
@@ -845,6 +858,7 @@ tui-categoryEditing = Modifica delle categorie
tui-categoryEditingGuiOnly = Laggiunta, la modifica e la rimozione delle categorie restano nellapplicazione desktop
tui-openItemDeleted = Lelemento 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 }

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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